Skip to content

Run request hooks on WebSocket upgrades; add free-form connection_init payload - #119

Merged
joadan merged 5 commits into
Linq2GraphQL:mainfrom
ricred:fix/ws-subscription-auth
Oct 18, 2026
Merged

joadan merged 5 commits into
Linq2GraphQL:mainfrom
ricred:fix/ws-subscription-auth

Conversation

@ricred

@ricred ricred commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #117.

WebSocket subscriptions ignored the client''s request hooks: GraphClientOptions.ConfigureRequest and the WithRequestConfiguration / WithAccessToken helpers that generated clients expose ran for queries, mutations, and server-sent-event subscriptions — but never for the WebSocket transport. Subscriptions to authenticated endpoints failed the upgrade (or the handshake) with no way to attach credentials.

What changed

Request hooks run on every WebSocket connect. The transport now uses the Websocket.Client 5.3.0 async connection factory (Func<Uri, CancellationToken, Task<WebSocket>>), which runs for the initial connect and for every reconnect attempt of the bounded retry loop. Before opening the socket, it:

  • runs the hooks against a dummy GET request for the subscription URL,
  • copies the resulting headers onto the upgrade request via ClientWebSocketOptions.SetRequestHeader, skipping handshake-owned headers (Host, Connection, Upgrade, Sec-WebSocket-*),
  • captures the Authorization value for the browser path below.

A refreshed token is therefore picked up on reconnect.

Connect timeout raised to 30 s. The library''s 5 s default covers the whole connection factory — hooks included — so a token fetch with a slow identity-provider round-trip would otherwise abort the connect.

Browser support (OperatingSystem.IsBrowser). Browsers cannot send custom headers on WebSocket upgrade requests, so the header copy is skipped there and the hook''s Authorization value travels inside connection_init instead (opt-in below).

New: GraphClientOptions.ConnectionInitPayload (plus a matching GraphClient.ConnectionInitPayload property). A Func<GraphClient, CancellationToken, ValueTask<object>> returning any JSON object the server expects, for example { ["Authorization"] = $"Bearer {token}" } (the graphql-ws / Apollo connection-parameter convention). Called before every (re)connect.

New: GraphClientOptions.SendAuthorizationInConnectionInit (default false). Merges the hook-produced Authorization value into the connection_init payload — the Blazor WebAssembly path for per-user tokens. Merge contract:

Payload type Result
null { "Authorization": <hook value> }
String-keyed object dictionary (IDictionary<string, object> or the read-only equivalent) Copy of the entries with Authorization overwritten
Anything else InvalidOperationException naming the expected shape

Behavior change: no background retry after a failed initial connect or handshake. Any failure during InitializeAsync is terminal and surfaces once from StartAsync. Previously the transport''s Error disconnect also started the reconnect loop, re-running the hooks up to five times and reporting a second, delayed error for a failure the caller had already handled. Reconnection after an established connection drops is unchanged.

Compatibility

Non-breaking:

  • The public surface is purely additive: two new options, one new property.
  • WSConnectionInitPayload is [Obsolete] (warning only) and still honored when ConnectionInitPayload is unset — including on the SendAuthorizationInConnectionInit merge path.

Migration for the obsolete option: replace WSConnectionInitPayload = client => ... with ConnectionInitPayload = (client, ct) => ValueTask.FromResult<object>(...), returning the payload''s JSON shape directly instead of a GraphQLRequest.

Testing

  • 33 WebSocket transport tests, new ones covering: hooks on both subprotocols (graphql-transport-ws and legacy Apollo), a fresh token per reconnect, free-form payload (delivered verbatim, re-asked per reconnect), precedence over the legacy option, every merge branch, hook-failure semantics (fails Start, no background retry), and a request hook slower than the old 5 s timeout that must still connect (pins the 30 s budget).
  • Full suite: 461/461 passing.
  • Build: 0 warnings.
  • scripts/regenerate-test-clients.ps1 -Check: clean.

Reviewer notes

The core review surface is WSClient.cs (connection factory, BuildConnectionInitPayloadAsync, terminal handling in InitializeAsync / the retry loop), GraphClient.cs (GetConnectionInitPayloadAsync), and GraphClientOptions.cs. The remaining files are tests and documentation.

Not covered by execution

The OperatingSystem.IsBrowser branches (header skip and payload-based token delivery) have no browser host in this repository and were verified by code reading only.

…m connection_init payload

WSClient never called ConfigureRequestAsync, so WithAccessToken/
WithRequestConfiguration/ConfigureRequest reached queries and SSE but not
the WebSocket subscription upgrade (issue Linq2GraphQL#117). The connection factory
is now async (Websocket.Client Func<Uri, CancellationToken,
Task<WebSocket>>) and runs the hooks on a dummy GET for the subscription
URL before every connect - initial and each reconnect - copying the
resulting headers onto the upgrade request (handshake-owned headers
skipped). On browsers, where custom WebSocket headers are impossible,
the hook's Authorization value is captured for the init payload instead.

GraphClientOptions.ConnectionInitPayload (new) sets the connection_init
payload to any JSON object (graphql-ws/Apollo connection-parameter
convention, e.g. Authorization/authToken); it is called before every
(re)connect. SendAuthorizationInConnectionInit (new) merges the hook's
Authorization header into that payload for Blazor WebAssembly.
WSConnectionInitPayload is obsolete (still honored as fallback).

Tests: upgrade header assertions for both WS protocols (server records
snapshotted upgrade headers), token refresh across reconnect, free-form
and legacy payloads, merge behaviour, hook failure on initial connect.
From four-axis review (standards, spec, graphql-architect,
performance-engineer):

- ConnectTimeout 30s (was Websocket.Client default 5s): request hooks
  (token fetch) run inside the connect budget and a slow IdP round-trip
  must not fail the connect.
- Failed initial connect no longer retries in the background:
  InitializeAsync marks the failure terminal before rethrowing from
  Start(), and the retry loop re-checks the terminal flag after each
  backoff delay. Previously a deterministic failure (e.g. a throwing
  token hook) was retried 5x with backoff and surfaced a second,
  delayed error after Start() had already thrown.
- SendAuthorizationInConnectionInit merge accepts any string-keyed
  dictionary shape (IEnumerable<KeyValuePair<string,object>>), not just
  IDictionary<string,object>; error message and docs state the contract.
- Docs: fixed broken GraphClient.WithAccessToken cref (generated-client
  method), documented GraphClient.ConnectionInitPayload (and added it to
  the CHANGELOG), wss:// note for tokens in connection_init, connect-
  timeout budget note, CHANGELOG dictionary example wrapped correctly.
- Tests: no-background-retry regression test, IReadOnlyDictionary merge
  test.
Self-review loop round: InitializeAsync now marks ANY initial
connect/handshake failure terminal (missing subprotocol,
connection_init build throw, ack timeout), not only a StartOrFail
transport error. Previously the server's later close (e.g. 4408 on its
own ack timeout) started the bounded retry loop after Start() had
already thrown, re-running hooks and reporting a second, delayed error.

Also: ConnectionInitPayload doc and the merge InvalidOperationException
state the exact dictionary shape (string keys, object values);
CHANGELOG wording broadened to connect-or-handshake.

Tests: non-dictionary payload + SendAuthorizationInConnectionInit
throws with guidance; a request hook slower than Websocket.Client's
5 s default ConnectTimeout still connects (pins the raised 30 s
budget).
@ricred ricred changed the title Run request hooks on WebSocket upgrades; free-form connection_init payload Run request hooks on WebSocket upgrades; add free-form connection_init payload Oct 16, 2026
…llow-up)

- Resolve the connection_init payload in the connection factory, before the
  socket opens (within ConnectTimeout), so a slow token fetch cannot run into
  the server's connection_init wait (graphql-ws closes with 4408).
- Send HttpClient.DefaultRequestHeaders on the upgrade request (hook headers
  win on a name conflict) and use DefaultRequestHeaders.Authorization as the
  SendAuthorizationInConnectionInit fallback, matching the query path.
- Merge the Authorization parameter into any payload that serializes to a
  JSON object (anonymous objects, Dictionary<string,string>, JsonObject,
  JsonElement, POCOs); other shapes still throw.
- Surface exceptions from request hooks and the payload delegate as their
  original type instead of Websocket.Client's WebsocketException wrapper
  (inner exception of the terminal IOException on reconnect). Cancellation
  stays wrapped.
- Make HookThrowingOnInitialConnect_DoesNotRetryInBackground deterministic
  via an internal RetryDelay seam instead of outwaiting the real backoff.

Tests: 10 new cases; each fix verified by a mutation that the tests kill.
IdleTimeoutStream armed one shared timer at the start of each read and never
disarmed it, so time the consumer spent between reads (handling an event, or a
continuation starved of a thread) counted as idle. A single stall longer than
the window failed a healthy stream with "no data received" - the cause of the
CI flake in SlowButRegularData_DoesNotTimeOut (one stalled continuation on a
loaded runner exhausted the 750 ms window).

- Each read gets its own CancellationTokenSource, linked to the caller's token,
  with CancelAfter(idleTimeout), disposed when the read completes. Consumer time
  between reads never counts, and a timer that fired during a read which still
  returned data cannot poison the next read.
- An OperationCanceledException the inner stream raises on its own is no longer
  mislabelled as an idle timeout.
- The timeout message states the configured value exactly ("750 ms", "30 s",
  "<1 ms") instead of rounding to whole seconds ("1s", "0s").

Tests: 7 new cases (consumer pause, delay before first read, fired timer with
data returned, message format theory, inner cancellation), and the external
cancel test now also asserts that the cancel propagates promptly. Each fix is
verified by a mutation that the tests kill.
@ricred

ricred commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@joadan can you please review this PR ?

@joadan
joadan merged commit 13bd9a6 into Linq2GraphQL:main Oct 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket subscriptions can't carry ConfigureRequest/WithAccessToken auth

2 participants