feat(llc)!: bound and authenticate a connection attempt - #160
Conversation
`StreamWebSocketClient` treated opening the socket as the end of connecting: it called `onConnectionEstablished`, discarded whatever that returned, and waited indefinitely for a health check to arrive. Four consequences, all reachable in the guest flow that motivated this. `options` becomes `optionsBuilder`, called once per attempt. The options carry values that change over a client's lifetime — the auth type a connection needs depends on the token it will present, and a client that switches users presents a different one — so a single instance built at construction time describes only the first attempt. `onConnectionEstablished` becomes `onAuthenticate`, which is what it is called for and when: the socket is open, the state is `Authenticating`, and the connection is not usable until credentials have been sent. It is now a `WebSocketAuthenticator` — handed a `WsSender` and returning a `Result` — so a failure to send them is observed rather than dropped. A `void Function()` could not report one, and silently accepted an `async` callback whose future was then discarded. On failure the connection is closed with the new `AuthenticationFailed` source, carrying the cause, instead of being left waiting for a reply that cannot come. The sender exists because the authenticator runs while the connection is still being established, so it cannot be handed the client itself. `WebSocketOptions.connectTimeout` was declared and never read. It now bounds the whole attempt rather than just opening the socket, since an attempt that opens but never receives its first health check is exactly the one that hangs — and nothing else watches `Authenticating`. Abandoning it reports the new `ConnectTimeout` source. The field is no longer nullable: "the platform default" was never consulted, so `null` meant no timeout at all, and it now defaults to `WebSocketOptions.defaultConnectTimeout`. Neither new source enables automatic reconnection. A handshake that never completes and credentials the server rejected both fail the same way on a retry, unlike an unhealthy connection, which was established once and may be again. Fixes a health check arriving while disconnecting being treated as one arriving on a live connection: it set the state back to `Connected`, which replaced the `Disconnecting` source. A deliberate `UserInitiated` disconnect could therefore close as `ServerInitiated` and be automatically reconnected — the opposite of what the caller asked for. Pongs are now ignored once the connection is on its way down. Adds `ConnectUserDetailsRequest.fromUser`, since an authenticator builds its auth frame from the client's `User` and every product was mapping the same four fields by hand. `role` and `teams` are deliberately left out: the server assigns both and ignores them from a client. `name` comes from `originalName`, so a user with no name does not have their id sent as one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## feat/token-manager-user-switching #160 +/- ##
=====================================================================
+ Coverage 60.32% 64.44% +4.11%
=====================================================================
Files 192 193 +1
Lines 7834 7920 +86
=====================================================================
+ Hits 4726 5104 +378
+ Misses 3108 2816 -292 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
renefloor
left a comment
There was a problem hiding this comment.
Reviewed the WS lifecycle changes with the branch checked out; suite is green (387 pass) and dart analyze --fatal-infos is clean. I probed the new lifecycle paths and four of them reproduced — details inline, ordered by how much I'd worry about them.
Worth fixing before merge
- An authenticator that throws (rather than returning a failed
Result) produces an unhandled async error and leaves the connection stuck inAuthenticating. This is the shape almost everyone will write, becauseTokenManager.getToken()throws. See the comment on_authenticate. - The new connect timer can overwrite a
ServerInitiatedsource and flipisAutomaticReconnectionEnabledfromtruetofalse— the same bug class this PR fixes for late pongs, but the guard only went into the pong path. See the comment ondisconnect.
Worth a deliberate decision
- Whether
ConnectTimeoutandAuthenticationFailedshould really block reconnection, givenUnHealthyConnectiondoesn't. See the comment onisAutomaticReconnectionEnabled.
Pre-existing, but this PR makes it sharper
connect() still doesn't guard Disconnecting, and onClose now cancels the connect timer — so a stale close from an old socket can disarm the new attempt's timeout. Comment on connect has the trace.
What I liked
optionsBuilder is the right call and correctly motivated — stream-auth-type depends on the token an attempt will present, so a single instance built at construction can only ever describe the first attempt. Handing the authenticator a WsSender instead of the client is the right boundary, and it fixes a doc example that genuinely never compiled. connectTimeout was dead API and bounding the whole handshake rather than just the socket open is the correct scope, since nothing watched Authenticating. The pong-while-disconnecting fix is a real bug with a regression test that pins the reconnect consequence rather than just the state. And 25 tests on a class that had none — with fake_async for the timers instead of real waits — is the right way to land this.
One small thing not worth its own comment: connect()'s doc still says it "completes when the connection attempt finishes". It resolves once the socket opens — before authentication, well before Connected. Given this PR is precisely about not treating an open socket as a finished attempt, that sentence should probably say so.
| } | ||
|
|
||
| Future<void> _authenticate() async { | ||
| final result = await onAuthenticate?.call(send); |
There was a problem hiding this comment.
An authenticator that throws instead of returning a failed Result isn't handled here, and since _authenticate() is unawaited the error escapes:
Bad state: token load failed
stream_web_socket_client.dart 203 StreamWebSocketClient._authenticate
stream_web_socket_client.dart 199 StreamWebSocketClient.onOpen
state: Authenticating()
Unhandled async error, and the connection sits in Authenticating until the 15s timeout — then reports ConnectTimeout, which carries no error, so the real cause is lost.
This isn't hypothetical. The natural authenticator for the flow this stack exists to serve is:
onAuthenticate: (send) async => send(ConnectRequest(token: await manager.getToken())),and getToken() throws (ClientException) on an unconfigured/reset manager or a failing provider — that's #159's own contract. The typedef asks for a Result, but the one authenticator everybody will write can't honour it without an explicit try/catch.
Could we route a throw to the same place a failed Result goes?
final result = await Result.guard(() => onAuthenticate!.call(send));so the cause lands in AuthenticationFailed(error: ...) instead of being lost to a timeout.
| if (connectionState.value is Disconnected) return; | ||
|
|
||
| // Stop the timeout from firing later and replacing this source. | ||
| _cancelConnectTimeout(); |
There was a problem hiding this comment.
Cancelling the timer here covers the case the test does not replace the source of a disconnect that came first pins — disconnect() ran first, so the timer never fires. But the reverse direction isn't covered, because disconnect() only early-returns on Disconnected, not Disconnecting.
onError (line 239) sets Disconnecting(ServerInitiated) and does not cancel the timer. If onClose doesn't follow promptly:
after onError: Disconnecting(ServerInitiated(...))
after timeout elapsed: Disconnecting(ConnectTimeout())
after onClose: Disconnected(ConnectTimeout()) autoReconnect = false
Without the timer that last state is Disconnected(ServerInitiated) with autoReconnect = **true** (web_socket_connection_state.dart:109-114). So a recoverable socket error becomes a permanent disconnect — which is the same failure this PR fixes for late pongs, just via the timer instead of a pong.
Same shape with lower stakes: if the timeout fires and the authenticator then returns a failure, the source is overwritten (ConnectTimeout → AuthenticationFailed). The engine guards the second close, and both sources are non-reconnectable, so that one is only misreporting:
after timeout: Disconnecting(ConnectTimeout()) engine closes = 1
after late auth failure: Disconnecting(AuthenticationFailed) engine closes = 1
Both fall out of one fix: have disconnect() return early (or at least not replace source) when the state is already Disconnecting. That seems better than adding _cancelConnectTimeout() to each new call site as they appear.
|
|
||
| // Open the connection using the engine. | ||
| // Open the connection using the engine, with options built for this attempt. | ||
| final options = optionsBuilder.call(); |
There was a problem hiding this comment.
Pre-existing, but the new timer gives it a sharper edge: connect() guards Connecting/Authenticating/Connected but not Disconnecting, so it proceeds while an old socket is still closing.
after disconnect: Disconnecting
after connect() during disconnecting: Authenticating <- new socket opened
after the OLD socket's onClose: Disconnected(ServerInitiated)
The stale close kills the new attempt — and because onClose now also calls _cancelConnectTimeout(), it disarms the new attempt's timer. If that socket then opens, we're back in Authenticating with nothing watching it, which is exactly the state the timeout was added for.
Adding Disconnecting to the early-return above would close it. Happy for it to be a follow-up since it predates this PR.
| SystemInitiated() => true, | ||
| UserInitiated() => false, | ||
| ConnectTimeout() => false, | ||
| AuthenticationFailed() => false, |
There was a problem hiding this comment.
I'd like to push back on both of these, and it's the PR description's own reasoning that makes me want to.
ConnectTimeout — UnHealthyConnection (no pong on an established connection) is retryable, but a missing first pong isn't. That's the same failure mode, usually a bad network, at a different moment. It also compounds connectTimeout going from "null = no timeout" to a mandatory 15s: a customer whose backend is slow to send the first health check now gets connections dropped where they previously worked, and not retried. A spurious timeout being permanent is a rough edge.
AuthenticationFailed — the description argues "credentials the server rejected fail the same way on a retry", but this source never means the server rejected anything. It fires when the client couldn't load or send credentials. send() failing because the socket died between onOpen and the send is exactly the transient case. A genuine "the server said no" arrives later, as an error frame.
One line either way, so mostly I'd like it decided deliberately rather than by analogy with UserInitiated.
There was a problem hiding this comment.
Decided as you asked, and I took your reading on one of the two.
ConnectTimeout is now reconnectable (web_socket_connection_state.dart:120). Your argument is the one that settles it: a first health check that never arrives is the same failure as one that stops arriving, and UnHealthyConnection already retries that.
Worth being explicit about what that does to the customer you raised, since it is a three-way change rather than a two-way one. For a backend slow to send the first health check: before this PR the connection hung indefinitely; with the timeout but non-reconnectable it dropped and stayed down; now it drops and reconnects with the recovery handler's backoff. So the flag turns "stays down" into "retries with backoff" rather than back into "connects eventually" — the 15s bound still applies. If that is the wrong trade for a slow backend, the lever is connectTimeout itself rather than the source, and it is per-attempt now.
AuthenticationFailed stays non-reconnectable, with your distinction written into the code as a comment: it is not the server refusing the credentials — that arrives as an error frame — but the client failing to load or send them, and it will fail the same way on a retry. The transient sub-case you named (the socket dying between onOpen and the send) is real, but it is also covered: that path closes the socket, and the resulting closure is reported by the engine rather than by this source. If it turns out to matter in practice, splitting the source is a smaller change than reversing this default.
Also fixed from your other comments: the throwing authenticator now goes through runSafely so the cause lands in AuthenticationFailed instead of escaping (:245), and disconnect early-returns when the connection is already Disconnecting, so the timer can no longer replace a ServerInitiated source — both with regression tests. The connectTimeout behaviour change is now in the changelog and the PR body, and connect's doc no longer claims its future completes when the attempt finishes.
| /// opens but is never established is abandoned once this elapses. | ||
| /// | ||
| /// Defaults to [defaultConnectTimeout]. | ||
| final Duration connectTimeout; |
There was a problem hiding this comment.
Agreed that the old null doc was a lie (nothing consulted a platform default, so null meant no timeout at all), and that a default is better than dead API.
Worth calling out in the changelog as a behaviour change though, not just an API one: every existing connection now gets abandoned after 15s if the first health check hasn't arrived, where before it waited indefinitely. Paired with ConnectTimeout not being reconnectable, a slow-first-pong backend goes from "connects eventually" to "drops and stays down".
| this.custom, | ||
| }); | ||
|
|
||
| factory ConnectUserDetailsRequest.fromUser( |
There was a problem hiding this comment.
Nit: no doc comment on new public API. The class has none either so it's consistent as-is — but the two decisions worth writing down are the ones a caller can't infer: role/teams omitted because the server assigns them, and name coming from originalName so a user with no name doesn't get their id sent as one. That last part is a good catch; every product was getting it wrong by hand.
…lpers
`getOrElse`, `getOrDefault`, `recover` and `recoverCatching` each declared a
type parameter of their own and then cast the success value into it —
`Success<T>(:final data) => data as R`. Nothing constrains `T` to be a subtype
of `R`, so the cast is unsound: with a callback that only throws, `R` infers as
`Never` and a *successful* result fails with a type error on the path that has
nothing wrong with it.
getOrElse THREW on a Success: type '(String, int)' is not a subtype of type 'Never'
That makes the natural way to turn a failure into an exception — the shorthand
`getOrThrow`'s own doc suggests — unusable. Dart cannot express Kotlin's
`T : R` bound, so the type parameter goes and the helpers return `T`. Widening
is still available through `fold`, which takes its return type honestly.
Source-breaking for callers that relied on widening; none exist in this repo or
in `stream-feeds-flutter`. Adds the first tests for `Result`, four of which pin
the success path of each helper against a throwing callback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five things about closing a connection, found while wiring `stream-feeds-flutter` onto this and in review of #160. `disconnect` returned while the socket was still closing, so a `connect` straight afterwards raced it: the engine's `open` closes any existing socket first, both closes ran to completion, and `onClose` fired twice — the second landing on a state of `Connecting` and reporting `ServerInitiated`, which is reconnect-eligible. One `disconnect(); connect();` pair could therefore end up with a spurious reconnect alongside the connection it just opened. The close is now awaited, which costs a socket flush: the returned future resolves when the close frame has been written, not when the peer replies. A failed close left the client reporting `Disconnecting` for good. The engine reports such a failure as a `Result` and skips notifying its listener, so nothing moved the state on. The connection is unusable either way, so it is now reported closed. `disconnect` no longer replaces the source of a closure already under way. `onError` sets `Disconnecting(ServerInitiated)` without cancelling the connect timer, so the timer could overwrite a reconnectable server error with a `ConnectTimeout`; the same shape turned a timeout into a late `AuthenticationFailed`. Whoever asked first describes why. An authenticator that throws now fails the connection instead of escaping. The `WebSocketAuthenticator` typedef asks for a `Result`, but the one authenticator everyone writes awaits a token — and loading one throws. The error escaped unhandled, since nothing observes that future, and the connection sat in `Authenticating` until the timeout reported a cause it does not carry. `ConnectTimeout` is now eligible for automatic reconnection. A first health check that never arrives is the same failure as one that stops arriving, which `UnHealthyConnection` already retries; making it permanent meant a backend slow to send that first check went from connecting eventually to staying down. `AuthenticationFailed` stays ineligible: it means the client could not produce credentials, not that the server refused them, and it will fail the same way on a retry. Adds `dispose`, so the client can be released rather than only closed — `StreamFeedsClient.dispose` had nothing to call, leaving both emitters open for the life of the process. It closes the connection, stops the health monitor and closes `events` and `connectionState`, and is idempotent through `Disposable`. Reporting a state guards on the emitter being closed rather than on disposal, so a close event arriving from the engine afterwards is ignored instead of thrown into a closed emitter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ConnectUserDetailsRequest.fromUser` shipped in #160 without a dartdoc, against the style guide's own rule for new public code. The two things a caller cannot infer are why `role` and `teams` are absent — the server assigns both and ignores them from a client — and that `includeDetails: false` sends the id alone. Also corrects `connect`'s dartdoc, which claimed its future completes when the connection attempt finishes. It resolves once the socket is open, before authentication and well before the connection is usable — which is precisely what the connect timeout exists to bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kotlin's `getOrElse`, `getOrDefault`, `recover` and `recoverCatching` widen through a second type parameter bounded by the receiver's — `<R, T : R>` — which is what makes their `value as T` sound. Dart has upper bounds only, so the bound cannot be stated and the previous `<R>` was a cast with nothing behind it. The capability is still reachable, just declared in a different place: `Result` is covariant, so naming the wider type on the result gives the same widening that Kotlin infers from the callback. ```dart final Result<num> widened = intResult; widened.getOrElse((_, _) => 0.5); ``` Documents that on both `get` helpers and pins it with a test, so the migration note is not the only record of it. Worth noting for the reviewer: Kotlin's `recover` also returns the receiver unchanged on success (`null -> this`) rather than rebuilding it, and its non-widening members — `getOrNull`, `getOrThrow` — take no type parameter either, which is the shape these four now have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getOrElse` and `getOrDefault` return the receiver's type, so widening the result before or after the call reads the same. `recover` returns a `Result`, so the order matters: widening afterwards gives a `Result<T>` there is nothing left to widen. Kotlin's returns `Result<R>` and infers it from the transform; ours takes it from the receiver, so the receiver has to be widened first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ycle Keeps this stacked branch current with the #159 review fixes. # Conflicts: # packages/stream_core/CHANGELOG.md
`connect` guarded `connecting`, `authenticating` and `connected` but not `disconnecting`, so it proceeded while an old socket was on its way out. The old socket's close event then reported the new connection as `Disconnected(ServerInitiated)` — and, since `onClose` cancels the connect timeout, disarmed the timer watching the new attempt, leaving it authenticating with nothing to bound it. Awaiting the engine's close made the sequential case safe; this covers the caller that does not await. Raised in review of #160 as pre-existing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ycle Keeps this stacked branch current with the #159 review fixes. # Conflicts: # packages/stream_core/CHANGELOG.md
…e rest" This reverts commit 0605c01, keeping the `fake_async` dev dependency it added since #160 uses it. The timeout was the wrong instrument. The failure it was meant to address is a load for the user who is gone blocking the user who replaced them, and its cause is that `getToken` serialises across identities — not that a load takes too long. A timeout papers over that by failing everyone once it elapses, including the caller who did nothing wrong, and imposes a default on a token endpoint whose timeout the customer already owns: shorter than theirs, and it silently fails loads that would have succeeded. The serialisation remains documented on `getToken`, which was what review asked for as a minimum. The targeted fix, if we want one, is a lock per identity, so a hang for the departed user cannot hold up the one that replaced them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ycle # Conflicts: # packages/stream_core/CHANGELOG.md
The default I picked for `connectTimeout` matched neither sibling SDK: Swift feeds waits 30 seconds for the same handshake (`FeedsClient+Connection.swift:62`) and Android core defaults to 10 (`StreamSocketConfig.kt:96`). Fifteen was a number, not a decision. Aligning with the more forgiving of the two is the right way round here, since this timeout went from absent to mandatory in this PR: the customer it can hurt is the one whose backend is slow to send its first health check, and review raised exactly that case. It is per-attempt and configurable, so anyone wanting Android's stricter bound can set it. One test now supplies its own five-second timeout rather than using the default: the default outlives the health monitor's first missed pong (25s + 3s), so elapsing past it on an established connection reports an unhealthy connection instead. That ordering is fine in production — the monitor only runs once a connection is established, and this timeout only bounds getting there — but it leaves no window for a test that wants to elapse past one and not the other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test proving the connect timeout stops mattering once a connection is established sized its window from `defaultConnectTimeout` — a number it does not care about — and so depended on that default staying under the health monitor's 28 seconds. Raising the default to 30 broke it, not because the timeout fired but because the wider window now reached the monitor's first unanswered ping. Fixing it by giving the test a five-second timeout of its own traded one problem for a worse one: a configuration that ships nowhere. It now runs the default and answers the pings, which is what an established connection actually does and the reason the monitor stays quiet in production. The answer is delivered on a timer rather than inside the send, because a pong crosses the wire — delivered synchronously it would register before the monitor arms the timeout it is meant to cancel, and the connection would be called unhealthy anyway. Verified by deleting the `_cancelConnectTimeout()` call the test exists to cover: it fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four timeout tests all stopped at `Disconnecting(ConnectTimeout)` with a comment excusing it: `disconnect` awaits the engine's subscription cancel, and a `StreamController`'s cancel completes on the event loop, which `fakeAsync` never drives. So the close stalled halfway and the tests could only ever see the transition, never the outcome — which is the part that matters, since a disconnection's source decides whether it is retried. The harness now hands the client a stream whose subscription cancels without the event loop, so a close under `fakeAsync` finishes as it does in production. All four assert `Disconnected(ConnectTimeout)`, one of them now also pinning that an abandoned attempt is eligible for reconnection, and the test that had to deliver `onClose` by hand to make progress no longer does. Same treatment for the socket-error test, which now closes as the peer would and asserts the whole consequence: a recoverable server error stays recoverable, which is what the source guard is for. Three smaller things: two tests called `onClose` after an awaited `disconnect` had already delivered it, which is a double close production cannot produce; a failed socket close is stubbed as a rejected future rather than a synchronous throw, since that is how a real sink reports one; and the assertion that a disposed client refuses to connect now says that it pins debug behaviour, with the untouched builder count covering release. Verified by removing `_startConnectTimeout`: four tests fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ycle # Conflicts: # packages/stream_core/CHANGELOG.md
…anded `ConnectionRecoveryHandler` retried any reconnect-eligible disconnection, including the very first attempt — while `connect`'s caller was being handed that same failure. Making `ConnectTimeout` retryable turned that from a corner into the common case: on a flaky network the caller is told the connection failed, the handler is already re-establishing it, and if the caller does the documented thing and retries, it gets "connection already in progress" from an attempt it did not start. Reconnection now requires a connection to have existed. The first attempt belongs to whoever called `connect` and awaited the outcome; every drop after that is the handler's, timeouts included, so a slow reconnect still keeps retrying with backoff. This is what the Android SDK does — `hasConnectedBefore && isDisconnected && …` in `StreamConnectionRecoveryEvaluatorImpl`, latched on reaching connected — and what the JS SDK arrives at structurally, by only reconnecting from close and health-check handlers and bailing out of `_reconnect` while a caller's attempt is in flight. Swift avoids it a third way, by never turning its initial-connect timeout into a disconnection source at all. The consequence worth knowing: a first attempt that fails is not retried when the network returns either. That is the caller's to handle, and it is documented on the class. Adds the first tests for this handler: a first attempt that times out is not retried, a connection that stops answering health checks is, and having been connected does not override a deliberate disconnect. Verified by removing the gate — one fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nnect The gate added in 300908c asked whether a connection had *ever* existed, which leaks across sessions: connect, disconnect, connect again, and a first attempt that fails is retried by the handler because a connection existed before the caller disconnected — the same double ownership, one session later. The question is whether a connection has existed since the caller last asked for one, so a user-initiated disconnection clears it. A system-initiated one does not: backgrounding and network loss are exactly what this handler exists to come back from, and the source already distinguishes the two. Also corrects what 300908c claimed about the Android SDK. The `hasConnectedBefore` latch is borrowed from it, but the retry model around it is not: Android's evaluator connects only on `networkBecameAvailable` or a return to the foreground, and has no failure-driven retry at all — a socket dropping on a healthy foreground network reconnects nothing there. Ours retries failures with a backoff as well, which makes the latch load-bearing here in a way it is not there. (Android does not reset it either, for that reason.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry still said the handler recovers connections that existed and cited Android's `hasConnectedBefore` as the match. Both were left behind by 0737726: the gate is per-session, cleared when the caller disconnects, and the Android comparison holds for the latch but not for the retry model around it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of the four cases had grown bodies — one doing bookkeeping and scheduling together, with the reasoning for both wedged inside the case — so the switch no longer read as what it is: a map from state to response. Each case now names what happened, and the reasoning lives with the method that acts on it. `_hasConnected` moves up with the handler's other state instead of sitting beside one of its three readers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A connection the server closed for an expired token was never reconnected, so a token expiring mid-session ended the session: nothing retried, and even the network returning could not revive it, since the policy reads the current state. That is now eligible for reconnection. On its own that would be worse than the disease. Whether a retry is worth making depends on something the connection state does not know — whether the provider can produce a different token — and a static one cannot, so a guest or a fixed JWT would have reconnected with exactly what was refused, forever, at the backoff's ceiling. `TokenRefreshReconnectionPolicy` answers that half: the state says the failure is worth retrying, the policy says whether the credential can change. Both halves are needed, and a product still has to expire the cached token between them; `stream-feeds-flutter` does that from its own connection-state listener, since only it knows there is a token manager at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rules in `isAutomaticReconnectionEnabled` could never fire, and one meant something broader than its name. The port compared the API error's `code` against 1000 — a WebSocket close code, which `WebSocketEngineException` declares as `stopErrorCode` and carries itself — so a socket the server closed deliberately was reconnected. It also compared `code` against 400..499, a range Stream's error codes never occupy: the backend numbers them -1 through ~102 and puts the HTTP status in a separate field, so every client error was treated as retryable. iOS reads `statusCode` for that, which is why its rule works. `isTokenExpiredError` covered 40..42, so it answered "the token is invalid" while being named for one particular reason. That is a distinction worth having: an expired token (40) is replaced by asking the provider for another, whereas a signature signed with the wrong secret (43), a clock the token is not valid against yet (41, 42), or a wrong API key (2) are configuration problems a fresh token presents again. Those are now `isInvalidTokenError`, and reconnection refuses them while allowing an expired one — the carve-out iOS spells out as "Expired tokens return 401, so it is considered client error". Verified each code against the backend: `monolith/errors/errors.go` defines accessKeyError=2, expiredToken=40, tokenNotValidYet=41, tokenUsedBeforeIAT=42, invalidTokenSignature=43, and returns all four token errors with a 401. The rules move out of a nested switch into a function, since three of them are about one source and read better as prose than as guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rules were the same, but split between a switch and a function, so reading what happens after a server-initiated close meant leaving the one place that lists every source. Two `ServerInitiated` cases — one guarded on the close code, one on what the API error says — keep it whole, and the switch stays exhaustive over the sealed source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`WebSocketEngineException.stopErrorCode` was a second name for 1000, kept from the iOS SDK's `WebSocketEngineError.stopErrorCode`, sitting beside a `CloseCode` extension type that already documents the whole range — and describing a normal closure as a "stop error" besides. Two names for one number in the same library is what let the reconnection rule compare it against an API error's code in the first place: a bare int has no home to be wrong about. `CloseCode.normalClosure` is the value `disconnect` and the engine's `close` already pass, and it is a `CloseCode` implementing `int`, so the comparison is unchanged. Its only readers were that rule and its test. A stale reference survives inside the commented-out block in `client_exception.dart`, left alone with the rest of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rate-limited connect was treated like every other 4xx and never retried, so a
client that hit the limit stayed down until the app intervened — for a condition
that resolves without anyone doing anything.
The backend says as much on the websocket path. It calls `SetHeaders` before the
check, so the limit, the remainder and the window's `Reset` are on the upgrade
response, then closes with `RateLimitError("Too many requests, check response
headers for more information.")` over a one-minute window
(`monolith/server/base.go:1707`). Of every 4xx it can reject a connect with, this
is the only one carrying a reset: `auth_rejected`, `app_disabled` and
`validation_failed` carry none, and `too_many_connections` carries a link to the
client-instantiation docs — a pointer at the caller's bug, not a time to retry.
Our backoff tops out at 25 seconds per attempt, comfortably inside that window.
This also gives `isRateLimitError` its first reader; it had none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…help Supersedes the arrangement built up over 72b1447 and dd48bce: a token-expired close was made reconnect-eligible, a `TokenRefreshReconnectionPolicy` vetoed the cases where that would loop, and `stream-feeds-flutter` expired the cached token from a connection-state listener so the retry would present a new one. Three pieces, in two packages, splitting one decision — and the ordering only worked because the recovery handler schedules a timer rather than connecting inline. The Android SDK does it in one place instead, inside the connect operation: socketSession.connect(data).onTokenError { error, code -> tokenManager.invalidate() tokenManager.refresh().flatMap { newToken -> socketSession.connect(data.copy(token = newToken.rawValue)) } } Invalidate, obtain another, attempt once more, and if that fails, fail the connect. Nothing consults reconnection eligibility, because the connect itself resolved it. `StreamFeedsClient.connect` already awaits the whole outcome, so it can do exactly that, and does. So automatic reconnection refuses a token-expired close again — correct for the reason it always was: the recovery handler cannot replace a credential, so a retry it makes presents the same one. `isExpiredTokenDisconnection` stays, as the way a caller that *can* replace it is told to. The cost, stated plainly: a token expiring on a live connection is no longer recovered automatically. The connection closes and waits for the app to connect again, which then refreshes as above. Android accepts the same — its evaluator reconnects only on network and lifecycle transitions — and iOS calls `connect()` explicitly rather than relying on recovery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion-lifecycle # Conflicts: # packages/stream_core/CHANGELOG.md
A connection attempt now authenticates through a handler of its own, which remembers what the server refused so the next attempt can present something else, and scopes both the sender and the failure path to the attempt that started them. An authenticator that outlives its attempt could otherwise send credentials over the connection that replaced it, or close a working connection as `AuthenticationFailed`, which is never reconnected. A refusal is forgotten once the caller disconnects: what they connect with next is theirs to decide. Also stops the client printing every state change, pong and ping to the console, and cancels the socket subscription through its field so it is visibly cancelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AuthInterceptor` only recognised a decoded JSON body, so a token-expired response delivered without a JSON content type was never retried. The same body was already read that way when the error was surfaced to the caller, so a request was reported as refused for an expired token without the token ever being replaced. The parsing now lives on `DioException.apiError`, which both callers share, so the two cannot drift apart again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Names the error codes each token predicate covers, rewraps to the repo's 120 columns, and drops a cross-reference to what the Swift and Android SDKs allow for the same handshake — what this one allows is the contract. `ConnectUserDetailsRequest.fromUser` also reads the excluded details through `takeIf` rather than repeating the condition per field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client is now exercised the way an app uses it — `connect`, `disconnect`, and frames on the wire — rather than by calling the engine and health listener callbacks it implements. A fake server decodes what the client actually sent and decides its reply, so a handshake succeeds because the credentials were accepted and a ping is answered because a ping arrived. Requests are really serialised and events really parsed; only the socket is stood in for. Adds the coverage that was missing: the health monitor, the retry strategy, the reconnection policies with network and lifecycle providers, and the token refresh loop end to end. Callback-driven tests could not produce two overlapping attempts, which is why the race they now cover went unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five near-identical adapter classes and sixteen repetitions of the same Dio setup become one configurable backend and one subject helper. Assertions that only checked something was set now check what it is: a header carrying the wrong token authenticates as the wrong user, and `throwsA(isA<DioException>())` passed for any failure at all. Covers the paths that had none: a token that cannot be loaded at all, an error carrying no JSON, and that requests are not held behind one another — the invariant that makes this an `Interceptor` rather than a `QueuedInterceptor`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirteen filter tests asserted that a constructor stored its arguments — every one of those assertions is already made by the serialisation test beside it, since a wrong field, operator or value shows up as wrong JSON. The three real-world usage examples re-ran assertions their parts already make, and a single-item list is not an edge case. Line coverage of `filter.dart` is unchanged at 100%, which is the argument for removing them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A request signed for one user was retried with whoever the `TokenManager` held by then, so it was performed as them and the caller was answered as though their own request had succeeded. The retry now only goes out for the user the request was signed for. Also reports the stack trace of where a token load failed rather than where the failure was caught, and records the engine's refusal to open a second connection over a live one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An authenticator that gave up after its attempt had been abandoned was discarded, because a closure was already recorded. The reason on record stayed `ConnectTimeout`, which is eligible for a reconnection, so the client reconnected and presented the credentials the authenticator had just given up on. A reason that rules a reconnection out is now recorded even on a connection already down, which is the rule `UserInitiated` was the only source using. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cting `isAutomaticReconnectionEnabled` decided this from a switch over the source, so `ConnectionRecoveryHandler` had to read a state back off the client to learn something about a source it had just been handed. That switch is now `DisconnectionSource.isReconnectable`, and the state getter is a lookup of it. Which also fixes the handler retrying an attempt the caller made and was handed the outcome of. Only a disconnect the caller asked for handed connecting back to them, so after any other closure it stopped at, the caller's next failed attempt was retried behind them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing tests refuse with an expired token, which is a closure the client reconnects from on its own. An invalid signature is not, and the refusal still has to reach the attempt a caller makes next, so they are told what the last credentials were refused for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Submit a pull request
Linear: FLU-
Github Issue: #
CLA
Description of the pull request
StreamWebSocketClienttreated opening the socket as the end of connecting: it calledonConnectionEstablished, discarded whatever that returned, and then waited indefinitely for a health check to arrive. Everything below follows from unpicking that, in the guest flow that motivated it.options→optionsBuilderCalled once per attempt. The options carry values that change over a client's lifetime — the
stream-auth-typea connection needs depends on the token it will present, and a client that switches users presents a different one — so a single instance built at construction time describes only the first attempt.onConnectionEstablished→onAuthenticateRenamed for what it is called for and when: the socket is open, the state is
Authenticating, and the connection is not usable until credentials have been sent.The signature change is the substantive part. As a
void Function()it could not report a failure to send those credentials, and it silently accepted anasynccallback whose future was then discarded — so a token that failed to load left the connection sitting inAuthenticatinguntil something else closed it. It is now awaited, and throwing is how it says the credentials did not go out, whether because sending failed or because it chose not to send them. The connection is then closed with the newAuthenticationFailedsource carrying the cause, and is not reconnected.It is handed a
WsRequestSenderrather than the client because it runs while the connection is being established — the client cannot hand out an interface that implies the connection is usable. The sender belongs to the attempt it was given to and fails once that attempt is no longer the one in flight, so an authenticator still awaiting a token for an abandoned attempt cannot send it over the connection that replaced it, nor close that one asAuthenticationFailed.It also fixes the doc example, which never compiled:
onConnectionEstablished: () { client.send(...) }isreferenced_before_declaration.previousError: telling an authenticator why the last attempt was refusedThe second parameter is the error the server closed the previous attempt with. Without it, an authenticator that caches its token has no way to know the token it is about to present is the one just refused, so it offers the same one for the life of the client.
Set only for the attempt directly after a refusal. Cleared once a connection is established, and once the caller disconnects — a caller that takes connecting back may sign a different user in, and a refusal recorded against the user before them says nothing about the credentials they will present. An attempt abandoned before its authenticator finished leaves the refusal behind for the attempt that replaces it, which has yet to answer it.
connectTimeoutwas dead APIDeclared on
WebSocketOptionsand never read. It now bounds the whole attempt rather than just opening the socket, because the attempt that hangs is precisely the one that opens and never receives its first health check — and nothing else watchesAuthenticating. Abandoning it reports the newConnectTimeoutsource.The field is no longer nullable. Its doc claimed
nullmeant "the platform default", which was never consulted, sonullmeant no timeout at all; it now defaults toWebSocketOptions.defaultConnectTimeout, 30 seconds.What reconnects, and what does not
ConnectTimeoutis eligible for automatic reconnection — a handshake that was slow once may not be next time.AuthenticationFailedis not: credentials that never went out will not go out on a retry either.Eligibility is necessary but not sufficient, and this is the part worth reading twice.
ConnectionRecoveryHandlerrecovers only a connection that was established. So a connection that times out on its way back is reconnected, while a first connection that times out is reported throughconnectionStateand left there — making another attempt belongs to whoever calledconnect.DisconnectionSource.isReconnectableis new and is the whole ofisAutomaticReconnectionEnabled; the established-connection gate sits on top of it.Two reconnection rules were also simply broken: the deliberate-close check compared a Stream error code against 1000 when it needed the WebSocket close code, and
isClientErrorcompared the Stream error code against 400..499, a range it never falls in. Neither had ever matched. A rate limit is now reconnectable too, since it clears on its own.A health check arriving while disconnecting
Pre-existing. A pong was handled the same whether the connection was live or already on its way down, so it set the state back to
Connected— which replaced theDisconnectingsource. A deliberateUserInitiateddisconnect could therefore close asServerInitiatedand be automatically reconnected, the opposite of what the caller asked for. Pongs are now ignored once the state isDisconnectingorDisconnected.A handshake that failed read as a deliberate close
Found while reviewing the above, and the sharpest bug here.
connectclosed the socket of a failed attempt using the engine's default close code — 1000 — and a closure with that code is never reconnected. So an established connection that dropped, then failed to upgrade on its retry, had its recovery cancelled and stayed down for good, indistinguishable from a clean server close. The original error was discarded entirely.The attempt now reports the error that actually failed it before letting go of its socket, which leaves the closure reconnectable and carrying its cause. Pinned by a test that walks the whole loop: established → drop → retry that cannot upgrade → still retrying.
ConnectUserDetailsRequest.fromUserHere because an authenticator builds its auth frame from the client's
User, and every product was mapping the same four fields by hand.roleandteamsare deliberately left out — the server assigns both and ignores them from a client.namecomes fromoriginalName, so a user with no name does not have their id sent as one, which the hand-rolled mappings got wrong.StreamWebSocketClient.disposeThe client is now
Disposable:disposecloses the connection along witheventsandconnectionState.connectthrows aStateErrorafterwards, in release builds as well as debug — it previously asserted and then returned, so a release build opened a socket nothing could observe or close.The credential path feeding all of this
AuthInterceptorextendsInterceptorrather thanQueuedInterceptor. A queue slot is freed only once a handler completes, so the retry sent fromonErrorwaited behind the request still holding one and neither finished.TokenManagerserialises the token loads, which is the part that needs it. The interceptor now retries a refused request at most once, expires only the token that request actually carried, clones a multipart body whose streams the refused attempt consumed, and refuses to retry a request signed for a user the manager has since been pointed away from — that retry would have performed one user's request as another.StreamApiError—isTokenExpiredErrornow means code 40 alone. The codes another token cannot fix (41–43) and a wrong API key (2) are the newisInvalidTokenError. This is the distinction that decides whether reconnecting is worth anything.DioException.apiError— reads the Stream error from a body Dio decoded or handed over as a string. A token-expired response sent without a JSON content type was previously never retried, while the same body was already read as a string when the error was surfaced to the caller.Result—getOrElse,getOrDefault,recoverandrecoverCatchingreturn the result's own type and no longer take a type parameter. The old signatures used an uncheckeddata as R, which threw at runtime for anyRthat was notT.Migration
Breaking, and
stream-video-flutterhas two call sites that will need updating when it bumps — it pinsstream_core: ^0.4.0, so nothing there breaks today:coordinator_ws.dart:37—options:→optionsBuilder:coordinator_ws.dart:41—onConnectionEstablished: _authenticateUser→onAuthenticate:, and_authenticateUser(:115) has to change shape fromFuture<void> Function()toFuture<void> Function(WsRequestSender, StreamApiError?)coordinator_ws.dart:116— reads_client.options.urlfor a log line; theoptionsfield is gonesfu_ws.dart:67—options:→optionsBuilder:sfu_ws.dart:85—String get url => _client.options.url;is a public getter onSfuWs, so this one surfaces in video's own APIstream-feeds-flutterpins core to a git ref and its branch already implements the two-parameter authenticator — it moves when the ref moves.Also removed:
WebSocketEngineException.stopErrorCode, replaced byCloseCode.normalClosure.Behaviour change, not just API
connectTimeoutwas declared and never read, so every connection previously waited indefinitely for its first health check. It is now abandoned after 30s. Video's two clients pass no timeout today and so inherit that default. A backend slow to send the first health check goes from "connects eventually" to "drops after 30s" — reconnected if the connection had been established before, and reported to the caller if this was its first attempt.Test plan
dart testinpackages/stream_core— 544 pass, up from 383 on the base branchdart analyze— cleandart format— cleanStreamWebSocketClienthad no test file at all before this, so most of the +161 is new coverage rather than adjusted coverage; theAuthInterceptorsuite is the one that was rewritten rather than added to. Most of it drives the real client through a fake socket — the engine, codec, authentication handler, health monitor and recovery handler are all the production ones, so a test drives the client the way an app does and the fake server answers what the client actually sent.Highlights:
optionsBuilder— called for every attempt, not once per clientonAuthenticate— called once the socket is open whileAuthenticating, once per attempt, handed a sender that reaches the socket; a throw closes the connection asAuthenticationFailedand is not retried; a sender belonging to an abandoned attempt fails rather than reaching the connection that replaced itpreviousError— handed to the attempt after a refusal and no later one, survives a closure the server did not explain, forgotten once a connection is established and once the caller disconnects, and the guest case end to end: refused expired token → fresh token → connectedfake_async) — abandons an attempt that never becomes connected, one whose socket never opens, and one whose authenticator never returns; armed again for a later attempt; honours a timeout given in the options; does not fire once established; does not replace the source of a closure or disconnect that came firstConnectTimeoutandUnHealthyConnectionreconnect,AuthenticationFailedandUserInitiateddo not, close code 1000 does not, an expired token and a rate limit do, an invalid signature does notcloseReasonuniqueness across all six sources,dispose, theAuthInterceptorsuite rebuilt around one fake backend, andResult's new signaturesAdds
fake_asyncas a dev dependency, used for the timeout tests so a 30s timer does not cost 30s of wall clock.Also drops
test/query/filter_test.dart(16 tests, 287 lines). Thirteen asserted that a constructor stored its arguments, which the serialisation test beside it already covers — a wrong field, operator or value shows up as wrong JSON. The other three re-ran assertions their parts already make. Line coverage offilter.dartis unchanged at 100%.Screenshots / Videos
n/a — no UI changes.
🤖 Generated with Claude Code