diff --git a/docs/client.rst b/docs/client.rst index 07d120e..df3ac9e 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -87,12 +87,12 @@ Asyncio clients can also use a coroutine:: A catch-all event handler receives the event name as a first argument. The remaining arguments are the same as for a regular event handler. -Connect, Connect Error and Disconnect Event Handlers ----------------------------------------------------- +Connect, Connect Error, Disconnect and Disconnect Final Event Handlers +---------------------------------------------------------------------- -The ``connect``, ``connect_error`` and ``disconnect`` events are special; they -are invoked automatically when a client connects or disconnects from the -server:: +The ``connect``, ``connect_error``, ``disconnect`` and ``disconnect_final`` +events are special; they are invoked automatically when a client connects or +disconnects from the server:: @sio.event def connect(): @@ -104,7 +104,11 @@ server:: @sio.event def disconnect(): - print("I'm disconnected!") + print("I'm disconnected (reconnection may be attempted)!") + + @sio.event + def disconnect_final(): + print("I'm disconnected (no more reconnection attempts will be made)!") The ``connect_error`` handler is invoked when a connection attempt fails. If the server provides arguments, these are passed on to the handler. The server @@ -116,10 +120,14 @@ server initiated disconnects, or accidental disconnects, for example due to networking failures. In the case of an accidental disconnection, the client is going to attempt to reconnect immediately after invoking the disconnect handler. As soon as the connection is re-established the connect handler will -be invoked once again. - -The ``connect``, ``connect_error`` and ``disconnect`` events have to be -defined explicitly and are not invoked on a catch-all event handler. +be invoked once again. When the client decides that no more reconnection +attempts will be issued, it invokes the ``disconnect_final`` handler. This can +happen when reconnections are disabled, or when the configured number of +attempts is reached without success. + +The ``connect``, ``connect_error``, ``disconnect`` and ``disconnect_final`` +events have to be defined explicitly and are not invoked on a catch-all event +handler. Connecting to a Server ---------------------- diff --git a/src/socketio/asyncio_client.py b/src/socketio/asyncio_client.py index e68e270..42656ce 100644 --- a/src/socketio/asyncio_client.py +++ b/src/socketio/asyncio_client.py @@ -143,9 +143,10 @@ class AsyncClient(client.Client): transports=transports, engineio_path=socketio_path) except engineio.exceptions.ConnectionError as exc: - await self._trigger_event( - 'connect_error', '/', - exc.args[1] if len(exc.args) > 1 else exc.args[0]) + for n in self.connection_namespaces: + await self._trigger_event( + 'connect_error', n, + exc.args[1] if len(exc.args) > 1 else exc.args[0]) raise exceptions.ConnectionError(exc.args[0]) from None if wait: @@ -369,6 +370,7 @@ class AsyncClient(client.Client): return namespace = namespace or '/' await self._trigger_event('disconnect', namespace=namespace) + await self._trigger_event('disconnect_final', namespace=namespace) if namespace in self.namespaces: del self.namespaces[namespace] if not self.namespaces: @@ -469,6 +471,8 @@ class AsyncClient(client.Client): try: await asyncio.wait_for(self._reconnect_abort.wait(), delay) self.logger.info('Reconnect task aborted') + for n in self.connection_namespaces: + await self._trigger_event('disconnect_final', namespace=n) break except (asyncio.TimeoutError, asyncio.CancelledError): pass @@ -490,6 +494,8 @@ class AsyncClient(client.Client): attempt_count >= self.reconnection_attempts: self.logger.info( 'Maximum reconnection attempts reached, giving up') + for n in self.connection_namespaces: + await self._trigger_event('disconnect_final', namespace=n) break client.reconnecting_clients.remove(self) @@ -533,15 +539,18 @@ class AsyncClient(client.Client): async def _handle_eio_disconnect(self): """Handle the Engine.IO disconnection event.""" self.logger.info('Engine.IO connection dropped') + will_reconnect = self.reconnection and self.eio.state == 'connected' if self.connected: for n in self.namespaces: await self._trigger_event('disconnect', namespace=n) + if not will_reconnect: + await self._trigger_event('disconnect_final', namespace=n) self.namespaces = {} self.connected = False self.callbacks = {} self._binary_packet = None self.sid = None - if self.eio.state == 'connected' and self.reconnection: + if will_reconnect: self._reconnect_task = self.start_background_task( self._handle_reconnect) diff --git a/src/socketio/client.py b/src/socketio/client.py index 2f64533..67960f7 100644 --- a/src/socketio/client.py +++ b/src/socketio/client.py @@ -92,7 +92,8 @@ class Client(object): fatal errors are logged even when ``engineio_logger`` is ``False``. """ - reserved_events = ['connect', 'connect_error', 'disconnect'] + reserved_events = ['connect', 'connect_error', 'disconnect', + 'disconnect_final'] def __init__(self, reconnection=True, reconnection_attempts=0, reconnection_delay=1, reconnection_delay_max=5, @@ -332,9 +333,10 @@ class Client(object): transports=transports, engineio_path=socketio_path) except engineio.exceptions.ConnectionError as exc: - self._trigger_event( - 'connect_error', '/', - exc.args[1] if len(exc.args) > 1 else exc.args[0]) + for n in self.connection_namespaces: + self._trigger_event( + 'connect_error', n, + exc.args[1] if len(exc.args) > 1 else exc.args[0]) raise exceptions.ConnectionError(exc.args[0]) from None if wait: @@ -569,6 +571,7 @@ class Client(object): return namespace = namespace or '/' self._trigger_event('disconnect', namespace=namespace) + self._trigger_event('disconnect_final', namespace=namespace) if namespace in self.namespaces: del self.namespaces[namespace] if not self.namespaces: @@ -654,6 +657,8 @@ class Client(object): delay)) if self._reconnect_abort.wait(delay): self.logger.info('Reconnect task aborted') + for n in self.connection_namespaces: + self._trigger_event('disconnect_final', namespace=n) break attempt_count += 1 try: @@ -673,6 +678,8 @@ class Client(object): attempt_count >= self.reconnection_attempts: self.logger.info( 'Maximum reconnection attempts reached, giving up') + for n in self.connection_namespaces: + self._trigger_event('disconnect_final', namespace=n) break reconnecting_clients.remove(self) @@ -716,15 +723,18 @@ class Client(object): def _handle_eio_disconnect(self): """Handle the Engine.IO disconnection event.""" self.logger.info('Engine.IO connection dropped') + will_reconnect = self.reconnection and self.eio.state == 'connected' if self.connected: for n in self.namespaces: self._trigger_event('disconnect', namespace=n) + if not will_reconnect: + self._trigger_event('disconnect_final', namespace=n) self.namespaces = {} self.connected = False self.callbacks = {} self._binary_packet = None self.sid = None - if self.eio.state == 'connected' and self.reconnection: + if will_reconnect: self._reconnect_task = self.start_background_task( self._handle_reconnect) diff --git a/tests/asyncio/test_asyncio_client.py b/tests/asyncio/test_asyncio_client.py index b8d5c4c..75862db 100644 --- a/tests/asyncio/test_asyncio_client.py +++ b/tests/asyncio/test_asyncio_client.py @@ -631,12 +631,15 @@ class TestAsyncClient(unittest.TestCase): c.connected = True c._trigger_event = AsyncMock() _run(c._handle_disconnect('/')) - c._trigger_event.mock.assert_called_once_with( + c._trigger_event.mock.assert_any_call( 'disconnect', namespace='/' ) + c._trigger_event.mock.assert_any_call( + 'disconnect_final', namespace='/' + ) assert not c.connected _run(c._handle_disconnect('/')) - assert c._trigger_event.mock.call_count == 1 + assert c._trigger_event.mock.call_count == 2 def test_handle_disconnect_namespace(self): c = asyncio_client.AsyncClient() @@ -644,11 +647,23 @@ class TestAsyncClient(unittest.TestCase): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = AsyncMock() _run(c._handle_disconnect('/foo')) - c._trigger_event.mock.assert_called_once_with( + c._trigger_event.mock.assert_any_call( 'disconnect', namespace='/foo' ) + c._trigger_event.mock.assert_any_call( + 'disconnect_final', namespace='/foo' + ) assert c.namespaces == {'/bar': '2'} assert c.connected + _run(c._handle_disconnect('/bar')) + c._trigger_event.mock.assert_any_call( + 'disconnect', namespace='/bar' + ) + c._trigger_event.mock.assert_any_call( + 'disconnect_final', namespace='/bar' + ) + assert c.namespaces == {} + assert not c.connected def test_handle_disconnect_unknown_namespace(self): c = asyncio_client.AsyncClient() @@ -656,9 +671,12 @@ class TestAsyncClient(unittest.TestCase): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = AsyncMock() _run(c._handle_disconnect('/baz')) - c._trigger_event.mock.assert_called_once_with( + c._trigger_event.mock.assert_any_call( 'disconnect', namespace='/baz' ) + c._trigger_event.mock.assert_any_call( + 'disconnect_final', namespace='/baz' + ) assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -668,7 +686,9 @@ class TestAsyncClient(unittest.TestCase): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = AsyncMock() _run(c._handle_disconnect('/')) - c._trigger_event.mock.assert_called_with('disconnect', namespace='/') + c._trigger_event.mock.assert_any_call('disconnect', namespace='/') + c._trigger_event.mock.assert_any_call('disconnect_final', + namespace='/') assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -929,7 +949,9 @@ class TestAsyncClient(unittest.TestCase): @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_max_attempts(self, random, wait_for): c = asyncio_client.AsyncClient(reconnection_attempts=2, logger=True) + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' + c._trigger_event = AsyncMock() c.connect = AsyncMock( side_effect=[ValueError, exceptions.ConnectionError, None] ) @@ -940,6 +962,8 @@ class TestAsyncClient(unittest.TestCase): 1.5, ] assert c._reconnect_task == 'foo' + c._trigger_event.mock.assert_called_once_with('disconnect_final', + namespace='/') @mock.patch( 'asyncio.wait_for', @@ -949,7 +973,9 @@ class TestAsyncClient(unittest.TestCase): @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_aborted(self, random, wait_for): c = asyncio_client.AsyncClient(logger=True) + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' + c._trigger_event = AsyncMock() c.connect = AsyncMock( side_effect=[ValueError, exceptions.ConnectionError, None] ) @@ -960,6 +986,8 @@ class TestAsyncClient(unittest.TestCase): 1.5, ] assert c._reconnect_task == 'foo' + c._trigger_event.mock.assert_called_once_with('disconnect_final', + namespace='/') def test_handle_eio_connect(self): c = asyncio_client.AsyncClient() @@ -1057,10 +1085,11 @@ class TestAsyncClient(unittest.TestCase): _run(c._handle_eio_message('9')) def test_eio_disconnect(self): - c = asyncio_client.AsyncClient(reconnection=False) + c = asyncio_client.AsyncClient() c.namespaces = {'/': '1'} c.connected = True c._trigger_event = AsyncMock() + c.start_background_task = mock.MagicMock() c.sid = 'foo' c.eio.state = 'connected' _run(c._handle_eio_disconnect()) @@ -1099,7 +1128,19 @@ class TestAsyncClient(unittest.TestCase): def test_eio_disconnect_no_reconnect(self): c = asyncio_client.AsyncClient(reconnection=False) + c.namespaces = {'/': '1'} + c.connected = True + c._trigger_event = AsyncMock() c.start_background_task = mock.MagicMock() + c.sid = 'foo' c.eio.state = 'connected' _run(c._handle_eio_disconnect()) + c._trigger_event.mock.assert_any_call( + 'disconnect', namespace='/' + ) + c._trigger_event.mock.assert_any_call( + 'disconnect_final', namespace='/' + ) + assert c.sid is None + assert not c.connected c.start_background_task.assert_not_called() diff --git a/tests/common/test_client.py b/tests/common/test_client.py index a9415ef..8b05978 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -752,10 +752,11 @@ class TestClient(unittest.TestCase): c.connected = True c._trigger_event = mock.MagicMock() c._handle_disconnect('/') - c._trigger_event.assert_called_once_with('disconnect', namespace='/') + c._trigger_event.assert_any_call('disconnect', namespace='/') + c._trigger_event.assert_any_call('disconnect_final', namespace='/') assert not c.connected c._handle_disconnect('/') - assert c._trigger_event.call_count == 1 + assert c._trigger_event.call_count == 2 def test_handle_disconnect_namespace(self): c = client.Client() @@ -763,15 +764,21 @@ class TestClient(unittest.TestCase): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = mock.MagicMock() c._handle_disconnect('/foo') - c._trigger_event.assert_called_once_with( + c._trigger_event.assert_any_call( 'disconnect', namespace='/foo' ) + c._trigger_event.assert_any_call( + 'disconnect_final', namespace='/foo' + ) assert c.namespaces == {'/bar': '2'} assert c.connected c._handle_disconnect('/bar') - c._trigger_event.assert_called_with( + c._trigger_event.assert_any_call( 'disconnect', namespace='/bar' ) + c._trigger_event.assert_any_call( + 'disconnect_final', namespace='/bar' + ) assert c.namespaces == {} assert not c.connected @@ -781,9 +788,12 @@ class TestClient(unittest.TestCase): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = mock.MagicMock() c._handle_disconnect('/baz') - c._trigger_event.assert_called_once_with( + c._trigger_event.assert_any_call( 'disconnect', namespace='/baz' ) + c._trigger_event.assert_any_call( + 'disconnect_final', namespace='/baz' + ) assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -793,7 +803,9 @@ class TestClient(unittest.TestCase): c.namespaces = {'/foo': '1', '/bar': '2'} c._trigger_event = mock.MagicMock() c._handle_disconnect('/') - c._trigger_event.assert_called_with('disconnect', namespace='/') + print(c._trigger_event.call_args_list) + c._trigger_event.assert_any_call('disconnect', namespace='/') + c._trigger_event.assert_any_call('disconnect_final', namespace='/') assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.connected @@ -1023,9 +1035,11 @@ class TestClient(unittest.TestCase): @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_max_attempts(self, random): c = client.Client(reconnection_attempts=2) + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' c._reconnect_abort = c.eio.create_event() c._reconnect_abort.wait = mock.MagicMock(return_value=False) + c._trigger_event = mock.MagicMock() c.connect = mock.MagicMock( side_effect=[ValueError, exceptions.ConnectionError, None] ) @@ -1036,13 +1050,17 @@ class TestClient(unittest.TestCase): mock.call(1.5), ] assert c._reconnect_task == 'foo' + c._trigger_event.assert_called_once_with('disconnect_final', + namespace='/') @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) def test_handle_reconnect_aborted(self, random): c = client.Client() + c.connection_namespaces = ['/'] c._reconnect_task = 'foo' c._reconnect_abort = c.eio.create_event() c._reconnect_abort.wait = mock.MagicMock(side_effect=[False, True]) + c._trigger_event = mock.MagicMock() c.connect = mock.MagicMock(side_effect=exceptions.ConnectionError) c._handle_reconnect() assert c._reconnect_abort.wait.call_count == 2 @@ -1051,6 +1069,8 @@ class TestClient(unittest.TestCase): mock.call(1.5), ] assert c._reconnect_task == 'foo' + c._trigger_event.assert_called_once_with('disconnect_final', + namespace='/') def test_handle_eio_connect(self): c = client.Client() @@ -1189,7 +1209,15 @@ class TestClient(unittest.TestCase): def test_eio_disconnect_no_reconnect(self): c = client.Client(reconnection=False) + c.namespaces = {'/': '1'} + c.connected = True + c._trigger_event = mock.MagicMock() c.start_background_task = mock.MagicMock() + c.sid = 'foo' c.eio.state = 'connected' c._handle_eio_disconnect() + c._trigger_event.assert_any_call('disconnect', namespace='/') + c._trigger_event.assert_any_call('disconnect_final', namespace='/') + assert c.sid is None + assert not c.connected c.start_background_task.assert_not_called()