fix(relay): accept is_reconnect kwarg in RelayAdapter.connect (#52911)

The gateway reconnect watcher (gateway/run.py) recovers a platform after a
fatal adapter error by building a fresh adapter and calling
connect(is_reconnect=True). Every BasePlatformAdapter implements
connect(*, is_reconnect: bool = False) for this — except RelayAdapter, whose
connect() was bare. So the watcher's recovery path raised:

    TypeError: connect() got an unexpected keyword argument 'is_reconnect'

Observed live on a hosted staging agent: after a fatal relay adapter error the
watcher could never re-establish relay, so the shared-bot inbound never reached
the gateway and Discord DMs stopped (dashboard surfaced the TypeError).

Relay deliberately ignores the flag: the #46621 server-side-queue-preservation
concern doesn't apply, because relay's outage buffer is the connector's durable
buffer (replayed on the transport's re-handshake), not a gateway-side queue the
adapter owns. Routine WS drops are already handled by the transport's own
reconnect supervisor (WebSocketRelayTransport, reconnect=True); the watcher path
is fatal-error recovery, and the fatal handler disconnect()s the old adapter
(cancelling its supervisor) before a fresh adapter+transport is built, so there
is no double-dial.

Adds two regression tests (both proven red without the fix): connect(is_reconnect=True)
reaches the same transport-less RuntimeError instead of TypeError, and the
signature matches BasePlatformAdapter.connect.
This commit is contained in:
Ben Barclay 2026-06-26 16:46:09 +10:00 committed by GitHub
parent 0f81b0d458
commit cb7d1f68f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 52 additions and 1 deletions

View file

@ -106,7 +106,24 @@ class RelayAdapter(BasePlatformAdapter):
return self.descriptor.supports_draft_streaming
# ── abstract methods (delegated to the transport) ────────────────────
async def connect(self) -> bool:
async def connect(self, *, is_reconnect: bool = False) -> bool:
# ``is_reconnect`` is part of the BasePlatformAdapter.connect contract:
# the gateway's reconnect watcher (gateway/run.py) re-establishes a
# platform after a fatal adapter error by building a fresh adapter and
# calling ``connect(is_reconnect=True)``. Relay MUST accept the kwarg or
# that recovery path raises TypeError and the relay platform can never
# come back through the watcher.
#
# Relay deliberately IGNORES the flag. The flag exists so adapters with a
# server-side update queue (e.g. Telegram's Bot API) preserve that queue
# across an outage instead of dropping it (#46621). Relay has no such
# gateway-side queue: messages buffered during a gap live in the
# CONNECTOR's durable buffer and are replayed when the transport
# re-handshakes. Routine WS drops are handled entirely by the transport's
# own reconnect supervisor (WebSocketRelayTransport, reconnect=True);
# a watcher-driven reconnect builds a fresh transport from scratch (the
# fatal-error handler disconnect()s the old adapter first, cancelling its
# supervisor), so there is nothing at the adapter layer to preserve.
if self._transport is None:
raise RuntimeError("RelayAdapter has no transport configured")
self._transport.set_inbound_handler(self._on_inbound)

View file

@ -69,6 +69,40 @@ async def test_connect_without_transport_raises():
await a.connect()
@pytest.mark.asyncio
async def test_connect_accepts_is_reconnect_kwarg():
"""Regression: RelayAdapter.connect must accept the BasePlatformAdapter
contract's ``is_reconnect`` kwarg. The gateway reconnect watcher recovers a
platform after a fatal adapter error by calling ``connect(is_reconnect=True)``
(gateway/run.py); before the fix, RelayAdapter.connect was bare ``connect()``
and that recovery path raised ``TypeError: connect() got an unexpected
keyword argument 'is_reconnect'`` (observed live: relay never reconnected,
no DMs). It must reach the SAME transport-less RuntimeError as connect()
i.e. accept the kwarg, never TypeError on it."""
a = _adapter()
with pytest.raises(RuntimeError, match="no transport"):
await a.connect(is_reconnect=True)
def test_connect_signature_matches_base_contract():
"""The is_reconnect parameter must be keyword-accepting and default False,
matching BasePlatformAdapter.connect, so the reconnect watcher's
``connect(is_reconnect=...)`` call is valid for relay as for every other
adapter."""
import inspect
from gateway.platforms.base import BasePlatformAdapter
sig = inspect.signature(RelayAdapter.connect)
base_sig = inspect.signature(BasePlatformAdapter.connect)
assert "is_reconnect" in sig.parameters
param = sig.parameters["is_reconnect"]
base_param = base_sig.parameters["is_reconnect"]
# Keyword-acceptable (KEYWORD_ONLY here, matching the base) with a False default.
assert param.kind is base_param.kind
assert param.default is False
@pytest.mark.asyncio
async def test_send_without_transport_returns_failure():
a = _adapter()