Skip to content

feat(llc)!: bound and authenticate a connection attempt - #160

Open
xsahil03x wants to merge 41 commits into
feat/token-manager-user-switchingfrom
feat/ws-connection-lifecycle
Open

feat(llc)!: bound and authenticate a connection attempt#160
xsahil03x wants to merge 41 commits into
feat/token-manager-user-switchingfrom
feat/ws-connection-lifecycle

Conversation

@xsahil03x

@xsahil03x xsahil03x commented Aug 20, 2026

Copy link
Copy Markdown
Member

Submit a pull request

Linear: FLU-

Github Issue: #

CLA

  • I have signed the Stream CLA (required).
  • The code changes follow best practices
  • Code changes are tested (add some information if not applicable)

Stacked on #159 — review that one first. This PR's base is feat/token-manager-user-switching, so its diff is the WebSocket layer plus the credential path that feeds it.

Description of the pull request

StreamWebSocketClient treated opening the socket as the end of connecting: it called onConnectionEstablished, 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.

optionsoptionsBuilder

typedef WebSocketOptionsBuilder = WebSocketOptions Function();

Called once per attempt. The options carry values that change over a client's lifetime — the stream-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.

onConnectionEstablishedonAuthenticate

typedef WsRequestSender = Result<void> Function(WsRequest request);
typedef WebSocketAuthenticator = Future<void> Function(WsRequestSender send, StreamApiError? previousError);

Renamed 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 an async callback whose future was then discarded — so a token that failed to load left the connection sitting in Authenticating until 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 new AuthenticationFailed source carrying the cause, and is not reconnected.

It is handed a WsRequestSender rather 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 as AuthenticationFailed.

It also fixes the doc example, which never compiled: onConnectionEstablished: () { client.send(...) } is referenced_before_declaration.

previousError: telling an authenticator why the last attempt was refused

The 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.

connectTimeout was dead API

Declared on WebSocketOptions and 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 watches Authenticating. Abandoning it reports the new ConnectTimeout source.

The field is no longer nullable. Its doc claimed null meant "the platform default", which was never consulted, so null meant no timeout at all; it now defaults to WebSocketOptions.defaultConnectTimeout, 30 seconds.

What reconnects, and what does not

ConnectTimeout is eligible for automatic reconnection — a handshake that was slow once may not be next time. AuthenticationFailed is 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. ConnectionRecoveryHandler recovers 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 through connectionState and left there — making another attempt belongs to whoever called connect. DisconnectionSource.isReconnectable is new and is the whole of isAutomaticReconnectionEnabled; 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 isClientError compared 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 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 state is Disconnecting or Disconnected.

A handshake that failed read as a deliberate close

Found while reviewing the above, and the sharpest bug here. connect closed 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.fromUser

Here because 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, which the hand-rolled mappings got wrong.

StreamWebSocketClient.dispose

The client is now Disposable: dispose closes the connection along with events and connectionState. connect throws a StateError afterwards, 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

  • AuthInterceptor extends Interceptor rather than QueuedInterceptor. A queue slot is freed only once a handler completes, so the retry sent from onError waited behind the request still holding one and neither finished. TokenManager serialises 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.
  • StreamApiErrorisTokenExpiredError now means code 40 alone. The codes another token cannot fix (41–43) and a wrong API key (2) are the new isInvalidTokenError. 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.
  • ResultgetOrElse, getOrDefault, recover and recoverCatching return the result's own type and no longer take a type parameter. The old signatures used an unchecked data as R, which threw at runtime for any R that was not T.

Migration

// before
StreamWebSocketClient(
  options: WebSocketOptions(url: url),
  onConnectionEstablished: () => client.send(authRequest),
);

// after
StreamWebSocketClient(
  optionsBuilder: () => WebSocketOptions(url: url),
  onAuthenticate: (send, previousError) async => send(authRequest).getOrThrow(),
);

Breaking, and stream-video-flutter has two call sites that will need updating when it bumps — it pins stream_core: ^0.4.0, so nothing there breaks today:

  • coordinator_ws.dart:37options:optionsBuilder:
  • coordinator_ws.dart:41onConnectionEstablished: _authenticateUseronAuthenticate:, and _authenticateUser (:115) has to change shape from Future<void> Function() to Future<void> Function(WsRequestSender, StreamApiError?)
  • coordinator_ws.dart:116 — reads _client.options.url for a log line; the options field is gone
  • sfu_ws.dart:67options:optionsBuilder:
  • sfu_ws.dart:85String get url => _client.options.url; is a public getter on SfuWs, so this one surfaces in video's own API

stream-feeds-flutter pins 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 by CloseCode.normalClosure.

Behaviour change, not just API

connectTimeout was 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 test in packages/stream_core544 pass, up from 383 on the base branch
  • dart analyze — clean
  • dart format — clean

StreamWebSocketClient had no test file at all before this, so most of the +161 is new coverage rather than adjusted coverage; the AuthInterceptor suite 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 client
  • onAuthenticate — called once the socket is open while Authenticating, once per attempt, handed a sender that reaches the socket; a throw closes the connection as AuthenticationFailed and is not retried; a sender belonging to an abandoned attempt fails rather than reaching the connection that replaced it
  • previousError — 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 → connected
  • connect timeout (9, on fake_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 first
  • health check while disconnecting (2) — does not report the connection as established again, and leaves the disconnection source intact once the socket closes
  • recovery — a first attempt that never connected is not retried; one that dropped after being established is; and a retry that fails its handshake keeps the recovery going rather than ending it
  • reconnection rulesConnectTimeout and UnHealthyConnection reconnect, AuthenticationFailed and UserInitiated do not, close code 1000 does not, an expired token and a rate limit do, an invalid signature does not
  • plus closeReason uniqueness across all six sources, dispose, the AuthInterceptor suite rebuilt around one fake backend, and Result's new signatures

Adds fake_async as 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 of filter.dart is unchanged at 100%.

Screenshots / Videos

n/a — no UI changes.

🤖 Generated with Claude Code

`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>
@xsahil03x
xsahil03x requested a review from a team as a code owner August 20, 2026 11:32
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01d85174-f0ee-4a92-a170-03a63027036c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.14286% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.44%. Comparing base (863c881) to head (9c666e0).

Files with missing lines Patch % Lines
...stream_core/lib/src/api/stream_core_dio_error.dart 66.66% 2 Missing ⚠️
...lib/src/ws/client/web_socket_connection_state.dart 88.88% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@renefloor renefloor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. An authenticator that throws (rather than returning a failed Result) produces an unhandled async error and leaves the connection stuck in Authenticating. This is the shape almost everyone will write, because TokenManager.getToken() throws. See the comment on _authenticate.
  2. The new connect timer can overwrite a ServerInitiated source and flip isAutomaticReconnectionEnabled from true to false — the same bug class this PR fixes for late pongs, but the guard only went into the pong path. See the comment on disconnect.

Worth a deliberate decision

  1. Whether ConnectTimeout and AuthenticationFailed should really block reconnection, given UnHealthyConnection doesn't. See the comment on isAutomaticReconnectionEnabled.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (ConnectTimeoutAuthenticationFailed). 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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to push back on both of these, and it's the PR description's own reasoning that makes me want to.

ConnectTimeoutUnHealthyConnection (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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

xsahil03x and others added 9 commits August 20, 2026 16:59
…ycle

Brings the #159 review fixes under this stacked branch so #160's diff stays
limited to the WebSocket layer.
…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
xsahil03x added a commit that referenced this pull request Aug 20, 2026
…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>
xsahil03x and others added 15 commits August 20, 2026 17:56
…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>
xsahil03x and others added 16 commits August 20, 2026 21:31
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>
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.

2 participants