Skip to content

fix(relay): re-dial once with a fresh token before treating a 4401 as revocation - #102602

Merged
benbarclay merged 5 commits into
mainfrom
fix/relay-4401-expired-token-retry
Sep 8, 2026
Merged

benbarclay merged 5 commits into
mainfrom
fix/relay-4401-expired-token-retry

Conversation

@benbarclay

@benbarclay benbarclay commented Sep 4, 2026 •

Copy link
Copy Markdown

Problem

A 4401 WebSocket close after a successful handshake was read unconditionally as "the connector revoked this gateway's secret" (opt-out). The transport latched auth_revoked, the adapter went relay_disabled, and messaging stopped until a manual restart.

The connector sends the same plain 4401 unauthorized for an expired upgrade token (make_upgrade_token, TTL 300s). With scale-to-zero that is routine:

  1. Instance is suspended while a relay re-dial is in flight.
  2. The upgrade token was minted before the freeze.
  3. On resume the dial completes with a token past its TTL.
  4. Connector closes 4401.
  5. Gateway sees 4401 and _handshake_succeeded and latches revocation.

Production incident 2026-09-02. The connector DB secret was never revoked.

Fix (gateway/relay/ws_transport.py)

  • The first post-handshake 4401 is provisional. The reader schedules one immediate re-dial with a freshly minted token (_redial_with_fresh_token), bypassing the reconnect backoff. The backoff supervisor design is unchanged.
  • auth_revoked latches only if that fresh-token dial is also rejected with 4401 (refused at the upgrade, or closed after a descriptor on that connection — tracked by dial generation). Terminal behaviour is otherwise as before.
  • If the fresh dial fails for a non-auth reason, it hands off to the normal backoff supervisor.
  • New _close_reason_of(exc) (sibling of _close_code_of). A 4401 whose close reason is exactly expired never latches revocation and takes the normal reconnect path. Forward-compatible hook for the connector-side change landing separately; docs/relay-connector-contract.md is intentionally not touched here.
  • disconnect() cancels the one-shot retry task alongside the supervisor.

The adapter's revocation monitor only polls auth_revoked, so the retry window is invisible to it.

Tests

tests/gateway/relay/test_ws_transport.py (real in-process websockets server, reconnect_backoff_s=5.0 so a fast recovery proves the retry bypasses backoff):

  • 4401 once, next dial accepted → reconnected, auth_revoked False, exactly 2 dials.
  • 4401 on every dial → auth_revoked True after exactly one retry (dial count == 2), no supervisor, no further dials.
  • 4401 reason expired (once, and twice in a row) → never latched, reconnects via the normal supervisor.
  • Existing 7d-B tests pass unchanged in contract (4401 before any handshake stays retryable; the revoke-every-dial stub still latches). One hardening test that injects private state directly now marks its fake socket as the retry generation.
uv run --extra dev pytest tests/gateway/relay -q   # 300 passed
uv run ruff check gateway/relay                    # All checks passed

Mutation check: with the retry removed (latch immediately, as before), the "4401 once then accepted" test fails; restored, all pass.

Review ledger (sol, reviewer profile = gpt-5.6-sol; each finding parent-reproduced before fixing)

Round Head Verdict Findings → disposition
merge 05ec6c1 — Merged origin/main (5 hunks, main's comment-trim refactor of this file); _auth_retry folded into main's teardown loop.
1 05ec6c1 REQUEST-CHANGES MAJOR: provisional-4401 reader started _redial_with_fresh_token while the supervisor was already mid-dial → two concurrent dialers (probe: 3 dials, supervisor overwrote the retry's socket). Fixed 14c4991: retry is a marker consumed by the next dial, reader arms a dialer only when none is live, reader iterates its captured ws.
2 14c4991 REQUEST-CHANGES BLOCKER (introduced by R1 fix): retry the only live dialer, its reader died mid-hello, dialer returned "connected" → no socket/reader/dialer. MAJOR: marker consumed before connect(), so a network blip un-marked the retry and the real fresh-token 4401 never latched (probe: 6 dials, revoked=False). Both fixed 5f0d500: _dial_and_start raises if its reader already finished; marker consumed only at an auth outcome.
3 5f0d500 APPROVE Both R2 findings CLOSED (next-tick late-death probe re-dials; marker lifetime traced through disconnect/dormant). Initial-connect ConnectionError judged acceptable (transient startup failure, gateway already handles). No new findings.

Every regression test added in rounds 1–3 was mutation-checked: removing the guard it protects turns it red.

… revocation

A 4401 close after a successful handshake was read unconditionally as the
connector having revoked this gateway's per-gateway secret (opt-out), so the
transport latched auth_revoked, the adapter went relay_disabled, and all
messaging stopped until a manual restart.

But the connector sends the same plain 4401 'unauthorized' for an EXPIRED
upgrade token (make_upgrade_token TTL is 300s). Scale-to-zero makes that
routine: the instance is suspended while a re-dial is in flight, the token
was minted before the freeze, the dial completes on resume with a token past
its TTL, the connector refuses it with 4401, and the gateway misreads an
expired token as a revoked credential. Production incident 2026-09-02 - the
connector DB secret was never revoked.

Fix, in gateway/relay/ws_transport.py:
- The first post-handshake 4401 is provisional. The reader schedules ONE
  immediate re-dial (_redial_with_fresh_token) that bypasses the reconnect
  backoff; _dial_and_start mints a fresh token on every call. The backoff
  supervisor design is untouched.
- Only a 4401 against that fresh token (either refused at the upgrade or
  closed after a descriptor on that connection, tracked by dial generation)
  latches auth_revoked. Terminal behaviour is otherwise unchanged.
- If the fresh dial fails for a non-auth reason, hand off to the normal
  backoff supervisor.
- Read the Close frame reason (_close_reason_of, sibling of _close_code_of).
  A 4401 whose reason is exactly 'expired' never latches revocation and takes
  the normal reconnect path - forward-compatible hook for the connector
  change landing separately.
- disconnect() cancels the one-shot retry task alongside the supervisor.

Tests (tests/gateway/relay/test_ws_transport.py, real websockets server):
- 4401 once, next dial accepted -> reconnected, auth_revoked False, exactly
  2 dials.
- 4401 on every dial -> auth_revoked True after exactly one retry (2 dials),
  no supervisor, no further dials.
- 4401 reason 'expired' (once and repeated) -> never latched, reconnects via
  the normal supervisor.
- Existing 7d-B tests keep passing (4401 before any handshake stays
  retryable; the revoking-every-dial stub still latches).
@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 5f0d500 — fix(relay): a dial whose reader died mid-hello is a failed d

⚠️ Warnings

OSV vulnerability scan · View job

28 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 5m vs 5m37s (-11.0%). 8 job(s) slower, 5 faster, 1 unchanged.

  • OS-specific tests / Windows-only tests: -39.0s
  • Python tests / Run tests: -29.0s
  • Python tests / e2e: +25.0s
  • Check no case-colliding filenames / check-case-collisions: -18.0s
  • Check contributors / check-attribution: -16.0s

…down

Three teardown tests construct WebSocketRelayTransport via object.__new__
without __init__, so the new _auth_retry attribute was absent and
disconnect() raised AttributeError. Read it with getattr like the other
optional teardown handles.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 4, 2026
…next dial, not a second dialer

Review (round 1) found a race: a reader that dies with a provisional 4401
while the backoff supervisor is already mid-dial started _redial_with_fresh_token
as a SECOND concurrent dialer; both installed sockets/readers and the
supervisor could overwrite the accepted retry socket (3 dials, wrong socket).

Now a provisional 4401 sets _auth_retry_pending; _dial_and_start consumes it
and stamps _auth_retry_generation on whichever dialer performs the next dial.
The reader arms a dialer only when none is live (_dialer_running), and an
upgrade-time 4401 on that generation latches revocation from either dialer
(_latch_if_fresh_token_refused). The reader also iterates its captured ws
handle, not self._ws.

Regression test reproduces the race with a deterministic fake connect; it
fails with the _dialer_running guard removed.
…retry marker survives network failures

Review round 2:
- BLOCKER: with one-live-dialer, a reader that dies while its own dialer is
  still inside _dial_and_start (hello in flight) arms nothing — that is the
  dialer's job — but the dialer then returned 'connected', leaving no socket,
  no reader, no dialer. _dial_and_start now raises ConnectionError when the
  reader it installed has already finished, so both dialers take their normal
  failure path (retry -> supervisor; supervisor -> backoff).
- MAJOR: _auth_retry_pending was consumed before websockets.connect, so a
  connect-time network failure un-marked the retry and the NEXT dial's real
  fresh-token 4401 read as another first strike (revocation never latched).
  The marker is now consumed only when the token reaches an auth outcome:
  upgrade accepted (stamp the generation) or upgrade 4401'd (judge it).

Two regression tests, each mutation-checked red against its own guard.
@benbarclay
benbarclay merged commit fef0e16 into main Sep 8, 2026
37 checks passed
@benbarclay
benbarclay deleted the fix/relay-4401-expired-token-retry branch September 8, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants