Browse Source

Add disconnect_final client event, triggered after giving up on reconnections

pull/1236/head
Miguel Grinberg 2 years ago
parent
commit
ebca06eddd
Failed to extract signature
  1. 28
      docs/client.rst
  2. 17
      src/socketio/asyncio_client.py
  3. 20
      src/socketio/client.py
  4. 53
      tests/asyncio/test_asyncio_client.py
  5. 40
      tests/common/test_client.py

28
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 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. 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 The ``connect``, ``connect_error``, ``disconnect`` and ``disconnect_final``
are invoked automatically when a client connects or disconnects from the events are special; they are invoked automatically when a client connects or
server:: disconnects from the server::
@sio.event @sio.event
def connect(): def connect():
@ -104,7 +104,11 @@ server::
@sio.event @sio.event
def disconnect(): 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 ``connect_error`` handler is invoked when a connection attempt fails. If
the server provides arguments, these are passed on to the handler. The server 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 networking failures. In the case of an accidental disconnection, the client is
going to attempt to reconnect immediately after invoking the disconnect going to attempt to reconnect immediately after invoking the disconnect
handler. As soon as the connection is re-established the connect handler will handler. As soon as the connection is re-established the connect handler will
be invoked once again. be invoked once again. When the client decides that no more reconnection
attempts will be issued, it invokes the ``disconnect_final`` handler. This can
The ``connect``, ``connect_error`` and ``disconnect`` events have to be happen when reconnections are disabled, or when the configured number of
defined explicitly and are not invoked on a catch-all event handler. 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 Connecting to a Server
---------------------- ----------------------

17
src/socketio/asyncio_client.py

@ -143,9 +143,10 @@ class AsyncClient(client.Client):
transports=transports, transports=transports,
engineio_path=socketio_path) engineio_path=socketio_path)
except engineio.exceptions.ConnectionError as exc: except engineio.exceptions.ConnectionError as exc:
await self._trigger_event( for n in self.connection_namespaces:
'connect_error', '/', await self._trigger_event(
exc.args[1] if len(exc.args) > 1 else exc.args[0]) 'connect_error', n,
exc.args[1] if len(exc.args) > 1 else exc.args[0])
raise exceptions.ConnectionError(exc.args[0]) from None raise exceptions.ConnectionError(exc.args[0]) from None
if wait: if wait:
@ -369,6 +370,7 @@ class AsyncClient(client.Client):
return return
namespace = namespace or '/' namespace = namespace or '/'
await self._trigger_event('disconnect', namespace=namespace) await self._trigger_event('disconnect', namespace=namespace)
await self._trigger_event('disconnect_final', namespace=namespace)
if namespace in self.namespaces: if namespace in self.namespaces:
del self.namespaces[namespace] del self.namespaces[namespace]
if not self.namespaces: if not self.namespaces:
@ -469,6 +471,8 @@ class AsyncClient(client.Client):
try: try:
await asyncio.wait_for(self._reconnect_abort.wait(), delay) await asyncio.wait_for(self._reconnect_abort.wait(), delay)
self.logger.info('Reconnect task aborted') self.logger.info('Reconnect task aborted')
for n in self.connection_namespaces:
await self._trigger_event('disconnect_final', namespace=n)
break break
except (asyncio.TimeoutError, asyncio.CancelledError): except (asyncio.TimeoutError, asyncio.CancelledError):
pass pass
@ -490,6 +494,8 @@ class AsyncClient(client.Client):
attempt_count >= self.reconnection_attempts: attempt_count >= self.reconnection_attempts:
self.logger.info( self.logger.info(
'Maximum reconnection attempts reached, giving up') 'Maximum reconnection attempts reached, giving up')
for n in self.connection_namespaces:
await self._trigger_event('disconnect_final', namespace=n)
break break
client.reconnecting_clients.remove(self) client.reconnecting_clients.remove(self)
@ -533,15 +539,18 @@ class AsyncClient(client.Client):
async def _handle_eio_disconnect(self): async def _handle_eio_disconnect(self):
"""Handle the Engine.IO disconnection event.""" """Handle the Engine.IO disconnection event."""
self.logger.info('Engine.IO connection dropped') self.logger.info('Engine.IO connection dropped')
will_reconnect = self.reconnection and self.eio.state == 'connected'
if self.connected: if self.connected:
for n in self.namespaces: for n in self.namespaces:
await self._trigger_event('disconnect', namespace=n) await self._trigger_event('disconnect', namespace=n)
if not will_reconnect:
await self._trigger_event('disconnect_final', namespace=n)
self.namespaces = {} self.namespaces = {}
self.connected = False self.connected = False
self.callbacks = {} self.callbacks = {}
self._binary_packet = None self._binary_packet = None
self.sid = None self.sid = None
if self.eio.state == 'connected' and self.reconnection: if will_reconnect:
self._reconnect_task = self.start_background_task( self._reconnect_task = self.start_background_task(
self._handle_reconnect) self._handle_reconnect)

20
src/socketio/client.py

@ -92,7 +92,8 @@ class Client(object):
fatal errors are logged even when fatal errors are logged even when
``engineio_logger`` is ``False``. ``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, def __init__(self, reconnection=True, reconnection_attempts=0,
reconnection_delay=1, reconnection_delay_max=5, reconnection_delay=1, reconnection_delay_max=5,
@ -332,9 +333,10 @@ class Client(object):
transports=transports, transports=transports,
engineio_path=socketio_path) engineio_path=socketio_path)
except engineio.exceptions.ConnectionError as exc: except engineio.exceptions.ConnectionError as exc:
self._trigger_event( for n in self.connection_namespaces:
'connect_error', '/', self._trigger_event(
exc.args[1] if len(exc.args) > 1 else exc.args[0]) 'connect_error', n,
exc.args[1] if len(exc.args) > 1 else exc.args[0])
raise exceptions.ConnectionError(exc.args[0]) from None raise exceptions.ConnectionError(exc.args[0]) from None
if wait: if wait:
@ -569,6 +571,7 @@ class Client(object):
return return
namespace = namespace or '/' namespace = namespace or '/'
self._trigger_event('disconnect', namespace=namespace) self._trigger_event('disconnect', namespace=namespace)
self._trigger_event('disconnect_final', namespace=namespace)
if namespace in self.namespaces: if namespace in self.namespaces:
del self.namespaces[namespace] del self.namespaces[namespace]
if not self.namespaces: if not self.namespaces:
@ -654,6 +657,8 @@ class Client(object):
delay)) delay))
if self._reconnect_abort.wait(delay): if self._reconnect_abort.wait(delay):
self.logger.info('Reconnect task aborted') self.logger.info('Reconnect task aborted')
for n in self.connection_namespaces:
self._trigger_event('disconnect_final', namespace=n)
break break
attempt_count += 1 attempt_count += 1
try: try:
@ -673,6 +678,8 @@ class Client(object):
attempt_count >= self.reconnection_attempts: attempt_count >= self.reconnection_attempts:
self.logger.info( self.logger.info(
'Maximum reconnection attempts reached, giving up') 'Maximum reconnection attempts reached, giving up')
for n in self.connection_namespaces:
self._trigger_event('disconnect_final', namespace=n)
break break
reconnecting_clients.remove(self) reconnecting_clients.remove(self)
@ -716,15 +723,18 @@ class Client(object):
def _handle_eio_disconnect(self): def _handle_eio_disconnect(self):
"""Handle the Engine.IO disconnection event.""" """Handle the Engine.IO disconnection event."""
self.logger.info('Engine.IO connection dropped') self.logger.info('Engine.IO connection dropped')
will_reconnect = self.reconnection and self.eio.state == 'connected'
if self.connected: if self.connected:
for n in self.namespaces: for n in self.namespaces:
self._trigger_event('disconnect', namespace=n) self._trigger_event('disconnect', namespace=n)
if not will_reconnect:
self._trigger_event('disconnect_final', namespace=n)
self.namespaces = {} self.namespaces = {}
self.connected = False self.connected = False
self.callbacks = {} self.callbacks = {}
self._binary_packet = None self._binary_packet = None
self.sid = None self.sid = None
if self.eio.state == 'connected' and self.reconnection: if will_reconnect:
self._reconnect_task = self.start_background_task( self._reconnect_task = self.start_background_task(
self._handle_reconnect) self._handle_reconnect)

53
tests/asyncio/test_asyncio_client.py

@ -631,12 +631,15 @@ class TestAsyncClient(unittest.TestCase):
c.connected = True c.connected = True
c._trigger_event = AsyncMock() c._trigger_event = AsyncMock()
_run(c._handle_disconnect('/')) _run(c._handle_disconnect('/'))
c._trigger_event.mock.assert_called_once_with( c._trigger_event.mock.assert_any_call(
'disconnect', namespace='/' 'disconnect', namespace='/'
) )
c._trigger_event.mock.assert_any_call(
'disconnect_final', namespace='/'
)
assert not c.connected assert not c.connected
_run(c._handle_disconnect('/')) _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): def test_handle_disconnect_namespace(self):
c = asyncio_client.AsyncClient() c = asyncio_client.AsyncClient()
@ -644,11 +647,23 @@ class TestAsyncClient(unittest.TestCase):
c.namespaces = {'/foo': '1', '/bar': '2'} c.namespaces = {'/foo': '1', '/bar': '2'}
c._trigger_event = AsyncMock() c._trigger_event = AsyncMock()
_run(c._handle_disconnect('/foo')) _run(c._handle_disconnect('/foo'))
c._trigger_event.mock.assert_called_once_with( c._trigger_event.mock.assert_any_call(
'disconnect', namespace='/foo' 'disconnect', namespace='/foo'
) )
c._trigger_event.mock.assert_any_call(
'disconnect_final', namespace='/foo'
)
assert c.namespaces == {'/bar': '2'} assert c.namespaces == {'/bar': '2'}
assert c.connected 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): def test_handle_disconnect_unknown_namespace(self):
c = asyncio_client.AsyncClient() c = asyncio_client.AsyncClient()
@ -656,9 +671,12 @@ class TestAsyncClient(unittest.TestCase):
c.namespaces = {'/foo': '1', '/bar': '2'} c.namespaces = {'/foo': '1', '/bar': '2'}
c._trigger_event = AsyncMock() c._trigger_event = AsyncMock()
_run(c._handle_disconnect('/baz')) _run(c._handle_disconnect('/baz'))
c._trigger_event.mock.assert_called_once_with( c._trigger_event.mock.assert_any_call(
'disconnect', namespace='/baz' 'disconnect', namespace='/baz'
) )
c._trigger_event.mock.assert_any_call(
'disconnect_final', namespace='/baz'
)
assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.namespaces == {'/foo': '1', '/bar': '2'}
assert c.connected assert c.connected
@ -668,7 +686,9 @@ class TestAsyncClient(unittest.TestCase):
c.namespaces = {'/foo': '1', '/bar': '2'} c.namespaces = {'/foo': '1', '/bar': '2'}
c._trigger_event = AsyncMock() c._trigger_event = AsyncMock()
_run(c._handle_disconnect('/')) _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.namespaces == {'/foo': '1', '/bar': '2'}
assert c.connected assert c.connected
@ -929,7 +949,9 @@ class TestAsyncClient(unittest.TestCase):
@mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5])
def test_handle_reconnect_max_attempts(self, random, wait_for): def test_handle_reconnect_max_attempts(self, random, wait_for):
c = asyncio_client.AsyncClient(reconnection_attempts=2, logger=True) c = asyncio_client.AsyncClient(reconnection_attempts=2, logger=True)
c.connection_namespaces = ['/']
c._reconnect_task = 'foo' c._reconnect_task = 'foo'
c._trigger_event = AsyncMock()
c.connect = AsyncMock( c.connect = AsyncMock(
side_effect=[ValueError, exceptions.ConnectionError, None] side_effect=[ValueError, exceptions.ConnectionError, None]
) )
@ -940,6 +962,8 @@ class TestAsyncClient(unittest.TestCase):
1.5, 1.5,
] ]
assert c._reconnect_task == 'foo' assert c._reconnect_task == 'foo'
c._trigger_event.mock.assert_called_once_with('disconnect_final',
namespace='/')
@mock.patch( @mock.patch(
'asyncio.wait_for', 'asyncio.wait_for',
@ -949,7 +973,9 @@ class TestAsyncClient(unittest.TestCase):
@mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5])
def test_handle_reconnect_aborted(self, random, wait_for): def test_handle_reconnect_aborted(self, random, wait_for):
c = asyncio_client.AsyncClient(logger=True) c = asyncio_client.AsyncClient(logger=True)
c.connection_namespaces = ['/']
c._reconnect_task = 'foo' c._reconnect_task = 'foo'
c._trigger_event = AsyncMock()
c.connect = AsyncMock( c.connect = AsyncMock(
side_effect=[ValueError, exceptions.ConnectionError, None] side_effect=[ValueError, exceptions.ConnectionError, None]
) )
@ -960,6 +986,8 @@ class TestAsyncClient(unittest.TestCase):
1.5, 1.5,
] ]
assert c._reconnect_task == 'foo' assert c._reconnect_task == 'foo'
c._trigger_event.mock.assert_called_once_with('disconnect_final',
namespace='/')
def test_handle_eio_connect(self): def test_handle_eio_connect(self):
c = asyncio_client.AsyncClient() c = asyncio_client.AsyncClient()
@ -1057,10 +1085,11 @@ class TestAsyncClient(unittest.TestCase):
_run(c._handle_eio_message('9')) _run(c._handle_eio_message('9'))
def test_eio_disconnect(self): def test_eio_disconnect(self):
c = asyncio_client.AsyncClient(reconnection=False) c = asyncio_client.AsyncClient()
c.namespaces = {'/': '1'} c.namespaces = {'/': '1'}
c.connected = True c.connected = True
c._trigger_event = AsyncMock() c._trigger_event = AsyncMock()
c.start_background_task = mock.MagicMock()
c.sid = 'foo' c.sid = 'foo'
c.eio.state = 'connected' c.eio.state = 'connected'
_run(c._handle_eio_disconnect()) _run(c._handle_eio_disconnect())
@ -1099,7 +1128,19 @@ class TestAsyncClient(unittest.TestCase):
def test_eio_disconnect_no_reconnect(self): def test_eio_disconnect_no_reconnect(self):
c = asyncio_client.AsyncClient(reconnection=False) c = asyncio_client.AsyncClient(reconnection=False)
c.namespaces = {'/': '1'}
c.connected = True
c._trigger_event = AsyncMock()
c.start_background_task = mock.MagicMock() c.start_background_task = mock.MagicMock()
c.sid = 'foo'
c.eio.state = 'connected' c.eio.state = 'connected'
_run(c._handle_eio_disconnect()) _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() c.start_background_task.assert_not_called()

40
tests/common/test_client.py

@ -752,10 +752,11 @@ class TestClient(unittest.TestCase):
c.connected = True c.connected = True
c._trigger_event = mock.MagicMock() c._trigger_event = mock.MagicMock()
c._handle_disconnect('/') 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 assert not c.connected
c._handle_disconnect('/') c._handle_disconnect('/')
assert c._trigger_event.call_count == 1 assert c._trigger_event.call_count == 2
def test_handle_disconnect_namespace(self): def test_handle_disconnect_namespace(self):
c = client.Client() c = client.Client()
@ -763,15 +764,21 @@ class TestClient(unittest.TestCase):
c.namespaces = {'/foo': '1', '/bar': '2'} c.namespaces = {'/foo': '1', '/bar': '2'}
c._trigger_event = mock.MagicMock() c._trigger_event = mock.MagicMock()
c._handle_disconnect('/foo') c._handle_disconnect('/foo')
c._trigger_event.assert_called_once_with( c._trigger_event.assert_any_call(
'disconnect', namespace='/foo' 'disconnect', namespace='/foo'
) )
c._trigger_event.assert_any_call(
'disconnect_final', namespace='/foo'
)
assert c.namespaces == {'/bar': '2'} assert c.namespaces == {'/bar': '2'}
assert c.connected assert c.connected
c._handle_disconnect('/bar') c._handle_disconnect('/bar')
c._trigger_event.assert_called_with( c._trigger_event.assert_any_call(
'disconnect', namespace='/bar' 'disconnect', namespace='/bar'
) )
c._trigger_event.assert_any_call(
'disconnect_final', namespace='/bar'
)
assert c.namespaces == {} assert c.namespaces == {}
assert not c.connected assert not c.connected
@ -781,9 +788,12 @@ class TestClient(unittest.TestCase):
c.namespaces = {'/foo': '1', '/bar': '2'} c.namespaces = {'/foo': '1', '/bar': '2'}
c._trigger_event = mock.MagicMock() c._trigger_event = mock.MagicMock()
c._handle_disconnect('/baz') c._handle_disconnect('/baz')
c._trigger_event.assert_called_once_with( c._trigger_event.assert_any_call(
'disconnect', namespace='/baz' 'disconnect', namespace='/baz'
) )
c._trigger_event.assert_any_call(
'disconnect_final', namespace='/baz'
)
assert c.namespaces == {'/foo': '1', '/bar': '2'} assert c.namespaces == {'/foo': '1', '/bar': '2'}
assert c.connected assert c.connected
@ -793,7 +803,9 @@ class TestClient(unittest.TestCase):
c.namespaces = {'/foo': '1', '/bar': '2'} c.namespaces = {'/foo': '1', '/bar': '2'}
c._trigger_event = mock.MagicMock() c._trigger_event = mock.MagicMock()
c._handle_disconnect('/') 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.namespaces == {'/foo': '1', '/bar': '2'}
assert c.connected assert c.connected
@ -1023,9 +1035,11 @@ class TestClient(unittest.TestCase):
@mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5]) @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5])
def test_handle_reconnect_max_attempts(self, random): def test_handle_reconnect_max_attempts(self, random):
c = client.Client(reconnection_attempts=2) c = client.Client(reconnection_attempts=2)
c.connection_namespaces = ['/']
c._reconnect_task = 'foo' c._reconnect_task = 'foo'
c._reconnect_abort = c.eio.create_event() c._reconnect_abort = c.eio.create_event()
c._reconnect_abort.wait = mock.MagicMock(return_value=False) c._reconnect_abort.wait = mock.MagicMock(return_value=False)
c._trigger_event = mock.MagicMock()
c.connect = mock.MagicMock( c.connect = mock.MagicMock(
side_effect=[ValueError, exceptions.ConnectionError, None] side_effect=[ValueError, exceptions.ConnectionError, None]
) )
@ -1036,13 +1050,17 @@ class TestClient(unittest.TestCase):
mock.call(1.5), mock.call(1.5),
] ]
assert c._reconnect_task == 'foo' 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]) @mock.patch('socketio.client.random.random', side_effect=[1, 0, 0.5])
def test_handle_reconnect_aborted(self, random): def test_handle_reconnect_aborted(self, random):
c = client.Client() c = client.Client()
c.connection_namespaces = ['/']
c._reconnect_task = 'foo' c._reconnect_task = 'foo'
c._reconnect_abort = c.eio.create_event() c._reconnect_abort = c.eio.create_event()
c._reconnect_abort.wait = mock.MagicMock(side_effect=[False, True]) c._reconnect_abort.wait = mock.MagicMock(side_effect=[False, True])
c._trigger_event = mock.MagicMock()
c.connect = mock.MagicMock(side_effect=exceptions.ConnectionError) c.connect = mock.MagicMock(side_effect=exceptions.ConnectionError)
c._handle_reconnect() c._handle_reconnect()
assert c._reconnect_abort.wait.call_count == 2 assert c._reconnect_abort.wait.call_count == 2
@ -1051,6 +1069,8 @@ class TestClient(unittest.TestCase):
mock.call(1.5), mock.call(1.5),
] ]
assert c._reconnect_task == 'foo' assert c._reconnect_task == 'foo'
c._trigger_event.assert_called_once_with('disconnect_final',
namespace='/')
def test_handle_eio_connect(self): def test_handle_eio_connect(self):
c = client.Client() c = client.Client()
@ -1189,7 +1209,15 @@ class TestClient(unittest.TestCase):
def test_eio_disconnect_no_reconnect(self): def test_eio_disconnect_no_reconnect(self):
c = client.Client(reconnection=False) c = client.Client(reconnection=False)
c.namespaces = {'/': '1'}
c.connected = True
c._trigger_event = mock.MagicMock()
c.start_background_task = mock.MagicMock() c.start_background_task = mock.MagicMock()
c.sid = 'foo'
c.eio.state = 'connected' c.eio.state = 'connected'
c._handle_eio_disconnect() 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() c.start_background_task.assert_not_called()

Loading…
Cancel
Save