Repository: ably/ably-python (python)

Issue:
Handle realtime channel errors
Spec: [`RTL14`](https://sdk.ably.com/builds/ably/specification/main/features/#RTL14)
Transition channel state to `FAILED` upon receipt of an `ERROR` protocol message with a `channel` field matching the channel name. `RealtimeChannel.error_reason` should also be set to the error.

Pull request description:
Handle channel errors
Resolves #455 

Patch:
diff --git a/ably/realtime/connectionmanager.py b/ably/realtime/connectionmanager.py
index 22e3a1e..1737859 100644
--- a/ably/realtime/connectionmanager.py
+++ b/ably/realtime/connectionmanager.py
@@ -166,6 +166,8 @@ class ConnectionManager(EventEmitter):
             if self.transport:
                 await self.transport.dispose()
             raise exception
+        else:
+            self.on_channel_message(msg)
 
     async def on_closed(self):
         if self.transport:
diff --git a/ably/realtime/realtime_channel.py b/ably/realtime/realtime_channel.py
index 78ab6b0..8a27a77 100644
--- a/ably/realtime/realtime_channel.py
+++ b/ably/realtime/realtime_channel.py
@@ -25,6 +25,8 @@ class RealtimeChannel(EventEmitter, Channel):
         Channel name
     state: str
         Channel state
+    error_reason: AblyException
+        An AblyException instance describing the last error which occurred on the channel, if any.
 
     Methods
     -------
@@ -48,6 +50,7 @@ class RealtimeChannel(EventEmitter, Channel):
         self.__attach_resume = False
         self.__channel_serial: str | None = None
         self.__retry_timer: Timer | None = None
+        self.__error_reason: AblyException | None = None
 
         # Used to listen to state changes internally, if we use the public event emitter interface then internals
         # will be disrupted if the user called .off() to remove all listeners
@@ -74,6 +77,8 @@ class RealtimeChannel(EventEmitter, Channel):
         if self.state == ChannelState.ATTACHED:
             return
 
+        self.__error_reason = None
+
         # RTL4b
         if self.__realtime.connection.state not in [
             ConnectionState.CONNECTING,
@@ -320,6 +325,9 @@ class RealtimeChannel(EventEmitter, Channel):
             messages = Message.from_encoded_array(msg.get('messages'))
             for message in messages:
                 self.__message_emitter._emit(message.name, message)
+        elif action == ProtocolMessageAction.ERROR:
+            error = AblyException.from_dict(msg.get('error'))
+            self._notify_state(ChannelState.FAILED, reason=error)
 
     def _request_state(self, state: ChannelState):
         log.info(f'RealtimeChannel._request_state(): state = {state}')
@@ -334,6 +342,12 @@ class RealtimeChannel(EventEmitter, Channel):
         if state == self.state:
             return
 
+        if reason is not None:
+            self.__error_reason = reason
+
+        if state == ChannelState.INITIALIZED:
+            self.__error_reason = None
+
         if state == ChannelState.SUSPENDED and self.ably.connection.state == ConnectionState.CONNECTED:
             self.__start_retry_timer()
         else:
@@ -427,3 +441,9 @@ class RealtimeChannel(EventEmitter, Channel):
     @state.setter
     def state(self, state: ChannelState):
         self.__state = state
+
+    # RTL24
+    @property
+    def error_reason(self):
+        """An AblyException instance describing the last error which occurred on the channel, if any."""
+        return self.__error_reason


Test patch:
diff --git a/test/ably/realtime/realtimechannel_test.py b/test/ably/realtime/realtimechannel_test.py
index 5f52927..aaf17e1 100644
--- a/test/ably/realtime/realtimechannel_test.py
+++ b/test/ably/realtime/realtimechannel_test.py
@@ -357,3 +357,87 @@ class TestRealtimeChannel(BaseAsyncTestCase):
         ably.connect()
         assert channel.state == ChannelState.INITIALIZED
         await ably.close()
+
+    async def test_channel_error(self):
+        ably = await TestApp.get_ably_realtime()
+        channel_name = random_string(5)
+        channel = ably.channels.get(channel_name)
+        await channel.attach()
+        code = 12345
+        status_code = 123
+
+        msg = {
+            "action": ProtocolMessageAction.ERROR,
+            "channel": channel_name,
+            "error": {
+                "message": "test error",
+                "code": code,
+                "statusCode": status_code,
+            },
+        }
+
+        assert ably.connection.connection_manager.transport
+        await ably.connection.connection_manager.transport.on_protocol_message(msg)
+
+        assert channel.state == ChannelState.FAILED
+        assert channel.error_reason
+        assert channel.error_reason.code == code
+        assert channel.error_reason.status_code == status_code
+
+        await ably.close()
+
+    async def test_channel_error_cleared_upon_attach(self):
+        ably = await TestApp.get_ably_realtime()
+        channel_name = random_string(5)
+        channel = ably.channels.get(channel_name)
+        await channel.attach()
+        code = 12345
+        status_code = 123
+
+        msg = {
+            "action": ProtocolMessageAction.ERROR,
+            "channel": channel_name,
+            "error": {
+                "message": "test error",
+                "code": code,
+                "statusCode": status_code,
+            },
+        }
+
+        assert ably.connection.connection_manager.transport
+        await ably.connection.connection_manager.transport.on_protocol_message(msg)
+
+        assert channel.error_reason is not None
+        await channel.attach()
+        assert channel.error_reason is None
+
+        await ably.close()
+
+    async def test_channel_error_cleared_upon_connect_from_terminal_state(self):
+        ably = await TestApp.get_ably_realtime()
+        channel_name = random_string(5)
+        channel = ably.channels.get(channel_name)
+        await channel.attach()
+        code = 12345
+        status_code = 123
+
+        msg = {
+            "action": ProtocolMessageAction.ERROR,
+            "channel": channel_name,
+            "error": {
+                "message": "test error",
+                "code": code,
+                "statusCode": status_code,
+            },
+        }
+
+        assert ably.connection.connection_manager.transport
+        await ably.connection.connection_manager.transport.on_protocol_message(msg)
+
+        await ably.close()
+
+        assert channel.error_reason is not None
+        ably.connect()
+        assert channel.error_reason is None
+
+        await ably.close()