Repository: ably/ably-python (python)

Issue:
Send resume query param when reconnecting within `connectionStateTtl` 
Spec: [`RTN15b`](https://sdk.ably.com/builds/ably/specification/pull/108/features/#RTN15b)

Note: do not send the `connectionSerial` param (RTN15b2) as this has been removed as of protocol v2

Pull request description:
Send resume param when reconnecting
Resolves #381 

Patch:
diff --git a/ably/realtime/connection.py b/ably/realtime/connection.py
index 1f18a4a..73081b3 100644
--- a/ably/realtime/connection.py
+++ b/ably/realtime/connection.py
@@ -161,6 +161,7 @@ class ConnectionManager(EventEmitter):
         self.__timeout_in_secs = self.options.realtime_request_timeout / 1000
         self.transport: WebSocketTransport | None = None
         self.__connection_details = None
+        self.connection_id = None
         self.__fail_state = ConnectionState.DISCONNECTED
         self.transition_timer: Timer | None = None
         self.suspend_timer: Timer | None = None
@@ -185,6 +186,13 @@ class ConnectionManager(EventEmitter):
         except httpx.HTTPError:
             return False
 
+    def __get_transport_params(self):
+        protocol_version = Defaults.protocol_version
+        params = {"key": self.__ably.key, "v": protocol_version}
+        if self.connection_details:
+            params["resume"] = self.connection_details.connection_key
+        return params
+
     async def close_impl(self):
         log.debug('ConnectionManager.close_impl()')
 
@@ -258,10 +266,11 @@ class ConnectionManager(EventEmitter):
         response_time_ms = (ping_end_time - ping_start_time) * 1000
         return round(response_time_ms, 2)
 
-    def on_connected(self, connection_details: ConnectionDetails):
+    def on_connected(self, connection_details: ConnectionDetails, connection_id: str):
         self.__fail_state = ConnectionState.DISCONNECTED
 
         self.__connection_details = connection_details
+        self.connection_id = connection_id
 
         if self.__state == ConnectionState.CONNECTED:
             state_change = ConnectionStateChange(ConnectionState.CONNECTED, ConnectionState.CONNECTED,
@@ -374,7 +383,8 @@ class ConnectionManager(EventEmitter):
             self.notify_state(self.__fail_state, reason=exception)
 
     async def try_host(self, host):
-        self.transport = WebSocketTransport(self, host)
+        params = self.__get_transport_params()
+        self.transport = WebSocketTransport(self, host, params)
         self._emit('transport.pending', self.transport)
         self.transport.connect()
 
diff --git a/ably/realtime/websockettransport.py b/ably/realtime/websockettransport.py
index fef9304..4904cbd 100644
--- a/ably/realtime/websockettransport.py
+++ b/ably/realtime/websockettransport.py
@@ -7,7 +7,6 @@ import logging
 import socket
 import urllib.parse
 from ably.http.httputils import HttpUtils
-from ably.transport.defaults import Defaults
 from ably.types.connectiondetails import ConnectionDetails
 from ably.util.eventemitter import EventEmitter
 from ably.util.exceptions import AblyException
@@ -36,7 +35,7 @@ class ProtocolMessageAction(IntEnum):
 
 
 class WebSocketTransport(EventEmitter):
-    def __init__(self, connection_manager: ConnectionManager, host: str):
+    def __init__(self, connection_manager: ConnectionManager, host: str, params: dict):
         self.websocket: WebSocketClientProtocol | None = None
         self.read_loop: asyncio.Task | None = None
         self.connect_task: asyncio.Task | None = None
@@ -49,13 +48,12 @@ class WebSocketTransport(EventEmitter):
         self.max_idle_interval = None
         self.is_disposed = False
         self.host = host
+        self.params = params
         super().__init__()
 
     def connect(self):
         headers = HttpUtils.default_headers()
-        protocol_version = Defaults.protocol_version
-        params = {"key": self.connection_manager.ably.key, "v": protocol_version}
-        query_params = urllib.parse.urlencode(params)
+        query_params = urllib.parse.urlencode(self.params)
         ws_url = (f'wss://{self.host}?{query_params}')
         log.info(f'connect(): attempting to connect to {ws_url}')
         self.ws_connect_task = asyncio.create_task(self.ws_connect(ws_url, headers))
@@ -97,6 +95,7 @@ class WebSocketTransport(EventEmitter):
         log.info(f'WebSocketTransport.on_protocol_message(): receieved protocol message: {msg}')
         action = msg.get('action')
         if action == ProtocolMessageAction.CONNECTED:
+            connection_id = msg.get('connectionId')
             connection_details = ConnectionDetails.from_dict(msg.get('connectionDetails'))
             max_idle_interval = connection_details.max_idle_interval
             if max_idle_interval:
@@ -105,7 +104,7 @@ class WebSocketTransport(EventEmitter):
             self.is_connected = True
             if self.host != self.options.get_realtime_host():  # RTN17e
                 self.options.fallback_realtime_host = self.host
-            self.connection_manager.on_connected(connection_details)
+            self.connection_manager.on_connected(connection_details, connection_id)
         elif action == ProtocolMessageAction.DISCONNECTED:
             self.connection_manager.on_disconnected(msg)
         elif action == ProtocolMessageAction.CLOSED:
diff --git a/ably/types/connectiondetails.py b/ably/types/connectiondetails.py
index c338f6e..8fc98cf 100644
--- a/ably/types/connectiondetails.py
+++ b/ably/types/connectiondetails.py
@@ -5,11 +5,15 @@ from dataclasses import dataclass
 class ConnectionDetails:
     connection_state_ttl: int
     max_idle_interval: int
+    connection_key: str
 
-    def __init__(self, connection_state_ttl: int, max_idle_interval: int):
+    def __init__(self, connection_state_ttl: int, max_idle_interval: int,
+                 connection_key: str):
         self.connection_state_ttl = connection_state_ttl
         self.max_idle_interval = max_idle_interval
+        self.connection_key = connection_key
 
     @staticmethod
     def from_dict(json_dict: dict):
-        return ConnectionDetails(json_dict.get('connectionStateTtl'), json_dict.get('maxIdleInterval'))
+        return ConnectionDetails(json_dict.get('connectionStateTtl'), json_dict.get('maxIdleInterval'),
+                                 json_dict.get('connectionKey'))


Test patch:
diff --git a/test/ably/realtimeresume_test.py b/test/ably/realtimeresume_test.py
new file mode 100644
index 0000000..a4fba05
--- /dev/null
+++ b/test/ably/realtimeresume_test.py
@@ -0,0 +1,25 @@
+from ably.realtime.connection import ConnectionState
+from test.ably.restsetup import RestSetup
+from test.ably.utils import BaseAsyncTestCase
+
+
+class TestRealtimeResume(BaseAsyncTestCase):
+    async def asyncSetUp(self):
+        self.test_vars = await RestSetup.get_test_vars()
+        self.valid_key_format = "api:key"
+
+    async def test_connection_resume(self):
+        ably = await RestSetup.get_ably_realtime()
+
+        await ably.connection.once_async(ConnectionState.CONNECTED)
+        prev_connection_id = ably.connection.connection_manager.connection_id
+        connection_key = ably.connection.connection_details.connection_key
+        await ably.connection.connection_manager.transport.dispose()
+        ably.connection.connection_manager.notify_state(ConnectionState.DISCONNECTED)
+
+        await ably.connection.once_async(ConnectionState.CONNECTED)
+        new_connection_id = ably.connection.connection_manager.connection_id
+        assert ably.connection.connection_manager.transport.params["resume"] == connection_key
+        assert prev_connection_id == new_connection_id
+
+        await ably.close()