Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1abd7d4
feat(llc)!: bound and authenticate a connection attempt
xsahil03x Aug 20, 2026
15e5016
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
64c7af7
fix(llc)!: return the result's own type from Result's failure-side he…
xsahil03x Aug 20, 2026
705123f
fix(llc): make a connection going down report why, once, and stay down
xsahil03x Aug 20, 2026
ba79c6a
docs(llc): document fromUser and record this round of changes
xsahil03x Aug 20, 2026
bc46b99
docs(llc): show how to widen a Result now that the helpers do not
xsahil03x Aug 20, 2026
39952e5
docs(llc): note where widening happens for recover
xsahil03x Aug 20, 2026
32c5a40
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
b0f94d1
fix(llc): do not open a socket while the previous one is still closing
xsahil03x Aug 20, 2026
47b346d
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
e321b8e
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
e3d700b
fix(llc): allow 30 seconds for a connection to establish, not 15
xsahil03x Aug 20, 2026
7c772d5
test(llc): keep a connection alive the way production does
xsahil03x Aug 20, 2026
68cedda
test(llc): let the connect-timeout tests reach the state they are about
xsahil03x Aug 20, 2026
d88bb57
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
c613345
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
b3454b7
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
300908c
fix(llc): recover connections that existed, not attempts that never l…
xsahil03x Aug 20, 2026
0737726
fix(llc): hand connecting back to the caller after a deliberate disco…
xsahil03x Aug 20, 2026
2ad554c
docs(llc): describe the recovery gate as it ended up
xsahil03x Aug 20, 2026
f4a8f73
refactor(llc): make the connection-state switch a dispatch, not a body
xsahil03x Aug 20, 2026
72b1447
feat(llc): reconnect an expired token only when another one exists
xsahil03x Aug 20, 2026
1949cbc
fix(llc): classify token errors the way the iOS SDK does
xsahil03x Aug 20, 2026
0a6d8e6
refactor(llc): keep the reconnection rules in the switch
xsahil03x Aug 20, 2026
e4ac1b4
refactor(llc): name the close code with the type that models close codes
xsahil03x Aug 20, 2026
dd48bce
fix(llc): reconnect after a rate limit, which clears on its own
xsahil03x Aug 20, 2026
0e4b96a
fix(llc): let the caller replace a refused token, and stop trying to …
xsahil03x Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@

- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId`
- Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead
- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, and calls it for every connection attempt
- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, which is what it is called for and when
- `StreamWebSocketClient.onAuthenticate` is now a `WebSocketAuthenticator`: it is handed a `WsSender` and returns a `Result`, so a failure to authenticate can be observed
- `TokenManager.userId` is now nullable, and is `null` until an identity is configured
- `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. The constructor is `const`, so a mismatch in a const context fails to compile rather than throwing in debug mode
- `StreamApiError.isTokenExpiredError` now means the token expired (code 40) rather than any invalid-token code. The rest — not yet valid, used before issued, wrong signature — are `isInvalidTokenError`, along with a wrong API key, since another token does not fix any of them
- `StreamApiError.isClientError` compares the HTTP `statusCode` against 400..499 rather than the Stream error `code`, which never falls in that range and so never matched
- `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` no longer take a type parameter of their own and return the result's own type. They previously cast the value to the callback's type, which failed on a successful result — most visibly for a callback that only throws. Kotlin's equivalents widen through a `<R, T : R>` bound that Dart cannot express; to widen here, name the wider type on the result (`Result<num> widened = intResult`), which works because `Result` is covariant, or use `fold`

### ✨ Features

Expand All @@ -15,13 +21,28 @@
- Added `User.anonymousUserId`, the id every anonymous user has
- Added `TokenManager.unconfigured`, for a client that exists before its user does
- Added `TokenManager.reset`, which drops the configured identity and its cached token
- Added `DisconnectionSource.connectTimeout`, reported when a connection attempt is abandoned before the connection is established; it is eligible for automatic reconnection, since a handshake that did not complete in time is the same failure as a connection that stops answering health checks
- Added `DisconnectionSource.authenticationFailed`, reported with its cause when a connection opens but cannot be authenticated
- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, which is no longer nullable and defaults to `WebSocketOptions.defaultConnectTimeout`. This is a behaviour change as well as an API one: a connection previously waited indefinitely for its first health check, and is now abandoned — and reconnected — after 30 seconds, matching the wait the Swift SDK allows for the same handshake
- Added `WsSender`, the send capability handed to a `WebSocketAuthenticator`
- Added `WebSocketConnectionState.isExpiredTokenDisconnection`, so a caller that can replace the token knows when to. Automatic reconnection deliberately refuses this case: whoever retries it would present the token the server just refused, and only the caller can obtain another
- Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User`
- Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is `Disposable`, so `isDisposed` reports whether it has been called
- Added `teams` field to `User` class

### 🐛 Bug Fixes

- Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token
- Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested
- Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it
- Fixed `StreamWebSocketClient.disconnect` completing before the socket was closed, so a `connect` straight afterwards raced the closure and saw the connection go down again
- Fixed a failure to close the socket leaving `StreamWebSocketClient` reporting itself as disconnecting for good, since the engine reports such a failure rather than notifying its listener
- Fixed a `WebSocketAuthenticator` that throws, rather than returning a failure, escaping as an unhandled error and leaving the connection authenticating until it timed out — losing the cause, which the timeout does not carry. The natural authenticator throws, since loading a token does
- Fixed `StreamWebSocketClient.disconnect` replacing the source of a closure already under way, which could turn a reconnectable `ServerInitiated` error into a permanent `ConnectTimeout`
- Fixed `isAutomaticReconnectionEnabled` neither refusing a deliberate server close nor refusing client errors: it compared the API error's code against the close code 1000, and against a 400..499 range that Stream codes never occupy, so both rules were dead. It now mirrors the iOS SDK — close code 1000, invalid tokens and 4xx all refuse, an expired token does not
- A connection the server closed because the request was rate limited is now eligible for automatic reconnection. The backend closes it with the rate-limit window's reset in the response headers and a one-minute window, so the condition clears on its own — unlike every other 4xx it can close a socket with, which carry no reset
- Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which failed the caller of `connect` and reconnected behind them at the same time — and made the caller's own retry fail with "connection already in progress". It now recovers only connections that have existed since the caller last asked for one, so a deliberate `disconnect` hands connecting back and the next `connect` is the caller's attempt again
- Fixed a health check arriving while disconnecting reporting the connection as established again, which replaced the disconnection source and could turn a deliberate disconnect into an automatic reconnect

### 🔄 Changed

Expand Down
26 changes: 21 additions & 5 deletions packages/stream_core/lib/src/errors/stream_api_error.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,32 @@ class StreamApiError extends Equatable {
];
}

final _tokenInvalidErrorCodes = _range(40, 42);
final _clientErrorCodes = _range(400, 499);
// The token this was issued for has expired; another one is accepted.
const _expiredTokenCode = 40;

// The token cannot be accepted for a reason another token does not fix: not
// valid yet, used before it was issued, or signed with the wrong secret.
final _invalidTokenCodes = _range(41, 43);

// The API key itself is wrong, which no token repairs either.
const _accessKeyErrorCode = 2;

final _clientErrorStatusCodes = _range(400, 499);

/// Extension methods for [StreamApiError] to provide convenient error type checks.
extension StreamApiErrorExtension on StreamApiError {
/// Whether this error indicates an expired or invalid token.
bool get isTokenExpiredError => _tokenInvalidErrorCodes.contains(code);
/// Whether the token has expired.
///
/// Distinct from [isInvalidTokenError]: an expired token is replaced by asking
/// the provider for another, where an invalid one is a configuration problem
/// that a fresh token presents again.
bool get isTokenExpiredError => code == _expiredTokenCode;

/// Whether the token, or the key it was signed with, cannot be accepted.
bool get isInvalidTokenError => _invalidTokenCodes.contains(code) || code == _accessKeyErrorCode;

/// Whether this error is a client-side error (4xx status codes).
bool get isClientError => _clientErrorCodes.contains(code);
bool get isClientError => _clientErrorStatusCodes.contains(statusCode);

/// Whether this error indicates rate limiting (429 status code).
bool get isRateLimitError => statusCode == 429;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart';

import 'user.dart';

part 'connect_user_details_request.g.dart';

@JsonSerializable(createFactory: false)
Expand All @@ -13,6 +15,24 @@ class ConnectUserDetailsRequest {
this.custom,
});

/// Creates the details a client may send when connecting as [user].
///
/// A user's role and teams are left out: the server assigns both and does not
/// accept them from a client.
///
/// Pass [includeDetails] as `false` to send the id alone.
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.

User user, {
bool includeDetails = true,
}) {
return ConnectUserDetailsRequest(
id: user.id,
name: includeDetails ? user.originalName : null,
image: includeDetails ? user.image : null,
custom: includeDetails ? user.custom : null,
);
}

final String id;
final String? image;
final bool? invisible;
Expand Down
41 changes: 27 additions & 14 deletions packages/stream_core/lib/src/utils/result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ extension PatternMatching<T> on Result<T> {
/// Returns the encapsulated value if this instance represents [Success] or `null`
/// if it is [Failure].
///
/// This function is a shorthand for `getOrElse(() => null)` or
/// `fold(onSuccess: (it) => it, onFailure: (_) => null)`.
/// This function is a shorthand for
/// `fold(onSuccess: (it) => it, onFailure: (_, _) => null)`.
T? getOrNull() {
return switch (this) {
Success<T>(:final data) => data,
Expand Down Expand Up @@ -90,7 +90,7 @@ extension PatternMatching<T> on Result<T> {
/// Returns the encapsulated value if this instance represents [Success] or throws the encapsulated error
/// if it is [Failure].
///
/// This function is a shorthand for `getOrElse((error) => throw error)`.
/// This function is a shorthand for `getOrElse((error, _) => throw error)`.
T getOrThrow() {
return switch (this) {
Success<T>(:final data) => data,
Expand All @@ -107,20 +107,27 @@ extension PatternMatching<T> on Result<T> {
/// Note, that this function rethrows any error thrown by [onFailure] function.
///
/// This function is a shorthand for `fold(onSuccess: (it) => it, onFailure: onFailure)`.
R getOrElse<R>(R Function(Object error, StackTrace? stackTrace) onFailure) {
///
/// [onFailure] returns this result's own type. To fall back to a supertype,
/// widen the result first — `Result<num> widened = intResult` — or use [fold],
/// which takes its return type from both branches.
T getOrElse(T Function(Object error, StackTrace? stackTrace) onFailure) {
return switch (this) {
Success<T>(:final data) => data as R,
Success<T>(:final data) => data,
Failure(:final error, :final stackTrace) => onFailure(error, stackTrace),
};
}

/// Returns the encapsulated value if this instance represents [Success] or the
/// [defaultValue] if it is [Failure].
///
/// This function is a shorthand for `getOrElse((_) => defaultValue)`.
R getOrDefault<R>(R defaultValue) {
/// This function is a shorthand for `getOrElse((_, _) => defaultValue)`.
///
/// [defaultValue] is of this result's own type; widen the result to fall back
/// to a supertype.
T getOrDefault(T defaultValue) {
return switch (this) {
Success<T>(:final data) => data as R,
Success<T>(:final data) => data,
Failure() => defaultValue,
};
}
Expand Down Expand Up @@ -180,11 +187,14 @@ extension PatternMatching<T> on Result<T> {
///
/// Note, that this function rethrows any error thrown by [transform] function.
/// See [recoverCatching] for an alternative that encapsulates errors.
Result<R> recover<R>(
R Function(Object error, StackTrace? stackTrace) transform,
///
/// [transform] returns this result's own type, so widening is done on the way
/// in rather than on the way out: widen the result first, then recover.
Result<T> recover(
T Function(Object error, StackTrace? stackTrace) transform,
) {
return switch (this) {
Success<T>(:final data) => Result.success(data as R),
Success<T>() => this,
Failure(:final error, :final stackTrace) => Result.success(
transform(error, stackTrace),
),
Expand All @@ -196,11 +206,14 @@ extension PatternMatching<T> on Result<T> {
///
/// This function catches any error thrown by [transform] function and encapsulates it as a failure.
/// See [recover] for an alternative that rethrows errors.
Result<R> recoverCatching<R>(
R Function(Object error, StackTrace? stackTrace) transform,
///
/// [transform] returns this result's own type, so widening is done on the way
/// in rather than on the way out: widen the result first, then recover.
Result<T> recoverCatching(
T Function(Object error, StackTrace? stackTrace) transform,
) {
return switch (this) {
Success<T>(:final data) => Result.success(data as R),
Success<T>() => this,
Failure(:final error, :final stackTrace) => runSafelySync(
() => transform(error, stackTrace),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,6 @@ class WebSocketEngineException extends Equatable implements Exception {
return null;
}

static const stopErrorCode = 1000;

@override
List<Object?> get props => [reason, code, error];
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class WebSocketOptions {
/// Creates a new instance of [WebSocketOptions].
const WebSocketOptions({
required this.url,
this.connectTimeout,
this.connectTimeout = defaultConnectTimeout,
this.protocols,
this.queryParameters,
});
Expand All @@ -35,9 +35,17 @@ class WebSocketOptions {

/// Maximum time allowed for establishing the WebSocket connection.
///
/// When specified, the connection attempt will timeout if not completed
/// within this duration. If `null`, uses the platform default timeout.
final Duration? connectTimeout;
/// Covers the whole attempt, not just opening the socket: a connection that
/// 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".


/// The [connectTimeout] used when none is given.
///
/// Matches the wait the Swift SDK allows for the same handshake; the Android
/// one is stricter at ten seconds.
static const defaultConnectTimeout = Duration(seconds: 30);

/// WebSocket sub-protocols to negotiate during the handshake.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import 'retry_strategy.dart';
/// when reconnection should occur, implementing exponential backoff with jitter for optimal
/// retry behavior.
///
/// It recovers connections that existed: until [StreamWebSocketClient.connect] has established
/// one, connecting belongs to whoever called it and was handed the failure. A first attempt that
/// fails is therefore not retried here, including when the network returns.
///
/// ## Built-in Policies
///
/// The handler automatically includes several reconnection policies:
Expand Down Expand Up @@ -79,6 +83,11 @@ class ConnectionRecoveryHandler extends Disposable {

late final _subscriptions = CompositeSubscription();

// Whether a connection has been established since the caller last asked for
// one: it is what separates a drop this handler recovers from an attempt whose
// outcome the caller is waiting on.
var _hasConnected = false;

/// Attempts reconnection if policies allow it.
///
/// Evaluates all configured policies and initiates reconnection when conditions are met.
Expand Down Expand Up @@ -117,7 +126,15 @@ class ConnectionRecoveryHandler extends Disposable {
_reconnectionTimer = null;
}

bool _canBeReconnected() => _policies.every((it) => it.canBeReconnected());
bool _canBeReconnected() {
// Until a connection has existed, connecting belongs to whoever called
// `connect` and was handed the failure. Retrying here as well would work
// behind a caller already told the attempt failed — and would race the
// retry that caller makes in response.
if (!_hasConnected) return false;

return _policies.every((it) => it.canBeReconnected());
}

bool _canBeDisconnected() {
return switch (_client.connectionState.value) {
Expand Down Expand Up @@ -147,13 +164,29 @@ class ConnectionRecoveryHandler extends Disposable {
void _onConnectionStateChanged(WebSocketConnectionState state) {
return switch (state) {
Connecting() => _cancelReconnection(),
Connected() => _reconnectStrategy.resetConsecutiveFailures(),
Disconnected() => _scheduleReconnectionIfNeeded(),
Connected() => _onConnectionEstablished(),
Disconnected(:final source) => _onConnectionLost(source),
// These states do not require any action.
Initialized() || Authenticating() || Disconnecting() => () {},
};
}

// A connection exists, so keeping it is this handler's job from here, and the
// failures the backoff had accumulated are behind us.
void _onConnectionEstablished() {
_hasConnected = true;
return _reconnectStrategy.resetConsecutiveFailures();
}

// A disconnect the caller asked for hands connecting back to them, so the next
// `connect` is a fresh attempt they await rather than a drop to recover. A
// system-initiated one is the opposite: backgrounding and network loss are what
// this handler exists to come back from.
void _onConnectionLost(DisconnectionSource source) {
if (source is UserInitiated) _hasConnected = false;
return _scheduleReconnectionIfNeeded();
}

@override
Future<void> dispose() async {
_cancelReconnection();
Expand Down
Loading
Loading