Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
105 commits
Select commit Hold shift + click to select a range
56bf6dc
feat(llc)!: let a TokenManager switch users
xsahil03x Aug 19, 2026
84e948e
docs(llc): drop the AuthInterceptor user_id entry from the changelog
xsahil03x Aug 19, 2026
8e963f4
test(llc): cover the deliberate user_id/token divergence in AuthInter…
xsahil03x Aug 19, 2026
ed252e8
fix(llc): discard a token load invalidated while it was in flight
xsahil03x Aug 19, 2026
a64dbb0
refactor(llc): make token provider mismatches assertable without thei…
xsahil03x Aug 19, 2026
64a85e0
refactor(llc): drop the redundant prefix from token mismatch messages
xsahil03x Aug 19, 2026
07db567
test(llc): share one JWT builder across the token tests
xsahil03x Aug 19, 2026
c91b3af
refactor(llc)!: remove AuthInterceptor.withProvider
xsahil03x Aug 19, 2026
0d88226
style(llc): align with STYLE_GUIDE and TESTING conventions
xsahil03x Aug 19, 2026
b9d1928
test(llc): keep the JWT builder local to each test file
xsahil03x Aug 19, 2026
8f834a4
test(llc): share one JWT builder, and document the pattern
xsahil03x Aug 19, 2026
500beee
docs(repo): drop the incident detail from the test-fixture rule
xsahil03x Aug 19, 2026
5215424
refactor(llc): restore the original token mismatch messages
xsahil03x Aug 19, 2026
df2d4f9
refactor(llc): drop the ArgumentError name argument
xsahil03x Aug 19, 2026
6c5eeca
docs(llc): drop an inaccurate comment on the token type check
xsahil03x Aug 19, 2026
a6bb26c
test(llc): reach the type check now that the user id is validated first
xsahil03x Aug 19, 2026
1d50f2c
feat(llc): let a TokenManager exist before its user does
xsahil03x Aug 20, 2026
1abd7d4
feat(llc)!: bound and authenticate a connection attempt
xsahil03x Aug 20, 2026
06747e0
fix(llc): report a wrong-type token as such, not as a wrong user
xsahil03x Aug 20, 2026
1ed6718
fix(llc): stop serving a token for a user the manager has dropped
xsahil03x Aug 20, 2026
7541144
fix(llc): keep the token-expired error when there is no user to refre…
xsahil03x Aug 20, 2026
49f63f3
docs(llc): record the behaviour changes raised in review
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
c03f7fd
fix(llc): compare a replacement provider by instance, not by equality
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
0605c01
feat(llc): bound a token load so one provider cannot block the rest
xsahil03x Aug 20, 2026
47b346d
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
19a1a91
Revert "feat(llc): bound a token load so one provider cannot block th…
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
4253b38
docs(llc): say that a provider's equality is never consulted
xsahil03x Aug 20, 2026
d88bb57
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
7a19674
Revert "fix(llc): compare a replacement provider by instance, not by …
xsahil03x Aug 20, 2026
c613345
Merge feat/token-manager-user-switching into feat/ws-connection-lifec…
xsahil03x Aug 20, 2026
a2e4abe
refactor(llc): name the anonymous token's claim as the user id it is
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
7ef363f
feat(llc): give a token an expiry it can be asked about
xsahil03x Aug 21, 2026
52d8a57
Merge branch 'feat/token-manager-user-switching' into feat/ws-connect…
xsahil03x Aug 21, 2026
ae08d2a
refactor(llc): read the token's expiry claim directly
xsahil03x Aug 21, 2026
2e3e2c6
Merge branch 'feat/token-manager-user-switching' into feat/ws-connect…
xsahil03x Aug 21, 2026
d568f05
feat(llc)!: keep an authenticated attempt to the attempt it belongs to
xsahil03x Aug 24, 2026
b30ab0f
fix(llc): read a Stream error from a body the server sent as text
xsahil03x Aug 24, 2026
b33ef0a
docs(llc): tighten the docs the connection work touched
xsahil03x Aug 24, 2026
e7bd778
test(llc): drive the websocket client through a fake server
xsahil03x Aug 24, 2026
89db687
test(llc): rebuild the auth interceptor tests around one fake backend
xsahil03x Aug 24, 2026
55c8f6c
test(llc): drop tests that restate the source
xsahil03x Aug 24, 2026
0ad17b3
docs(llc): record the connection work in the changelog
xsahil03x Aug 24, 2026
863c881
fix(llc): replace a cached token the server would refuse for having e…
xsahil03x Aug 24, 2026
086bdc9
Merge branch 'feat/token-manager-user-switching' into feat/ws-connect…
xsahil03x Aug 24, 2026
ba53f98
fix(llc): stop an auth retry crossing a user switch
xsahil03x Aug 24, 2026
f95b138
fix(llc): record an authentication failure on a connection already down
xsahil03x Aug 24, 2026
b5c34ee
refactor(llc): ask a disconnection source whether it is worth reconne…
xsahil03x Aug 24, 2026
9c666e0
test(llc): pin the refusal handed on after a closure not worth retrying
xsahil03x Aug 24, 2026
cc4ae54
fix(llc): report why a handshake failed, so a retry does not end the …
xsahil03x Aug 24, 2026
a790721
docs(llc): correct the connection docs that describe what the code do…
xsahil03x Aug 24, 2026
03d5648
docs(repo): follow Effective Dart on referring to parameters
xsahil03x Aug 24, 2026
ece0b67
docs(llc): document what a Dio error converts into, not how it is read
xsahil03x Aug 24, 2026
0763f3f
docs(llc): name whose response, and say the fallback once
xsahil03x Aug 24, 2026
bd9b2dd
refactor(llc): read a Stream error from one place, whichever way it a…
xsahil03x Aug 24, 2026
616fc9c
docs(llc): drop what the field these two methods write already says
xsahil03x Aug 24, 2026
d08dfd3
docs(llc): say it plainly
xsahil03x Aug 24, 2026
c4c0839
refactor(llc): abandon a failed attempt as a chain, not a branch
xsahil03x Aug 24, 2026
990707a
refactor(llc): report a failed close as a chain too
xsahil03x Aug 24, 2026
049e1a1
fix(llc): do not leave a failed attempt disconnecting when its socket…
xsahil03x Aug 24, 2026
49679be
refactor(llc): abandon a failed attempt through disconnect
xsahil03x Aug 24, 2026
32ff783
refactor(llc): read the error off the result rather than destructurin…
xsahil03x Aug 24, 2026
8421c01
refactor(llc): abandon a failed attempt with getOrElse
xsahil03x Aug 24, 2026
86ab178
refactor(llc): use getOrElse on both failure paths
xsahil03x Aug 24, 2026
c8807dc
feat(llc): report an attempt that never opened a socket as its own so…
xsahil03x Aug 24, 2026
927bdee
Revert "feat(llc): report an attempt that never opened a socket as it…
xsahil03x Aug 24, 2026
223d643
docs(llc): drop a changelog entry for a bug that never shipped, and f…
xsahil03x Aug 24, 2026
c1f707a
docs(llc): name the right product, and key the reconnection rules to …
xsahil03x Aug 24, 2026
d4c9de2
docs(llc): share the reconnection rules through a macro
xsahil03x Aug 24, 2026
33deddb
style(llc): name the engine exception through the dot shorthand
xsahil03x Aug 24, 2026
be4bc4a
docs(llc): trim the changelog to what a consumer needs
xsahil03x Aug 24, 2026
dd17dae
docs(llc): fold the changelog entries that share a cause
xsahil03x Aug 24, 2026
1b0af24
Merge branch 'main' into feat/ws-connection-lifecycle
xsahil03x Aug 24, 2026
24fd7a9
docs(llc): state the engine contract as obligations, not as observations
xsahil03x Aug 24, 2026
d096fe4
docs(llc): describe the engine's behaviour, not the implementer's duty
xsahil03x Aug 24, 2026
885a3fa
fix(llc): keep a stale handshake or closure from reporting on its suc…
xsahil03x Aug 24, 2026
c2b7959
fix(llc): ignore a pong that lands before the credentials go out
xsahil03x Aug 24, 2026
e7c5236
fix(llc): tell a newer refusal from the one being answered by identity
xsahil03x Aug 24, 2026
8756fa4
docs(changelog): log the Dart SDK bump as a breaking change
xsahil03x Aug 24, 2026
1bb50a4
fix(llc): leave the unset connect details out of the payload
xsahil03x Aug 24, 2026
61a0045
test(llc): cover how a refused request is turned into an exception
xsahil03x Aug 24, 2026
a0e5762
docs(llc): say the connection comments in one line where one will do
xsahil03x Aug 24, 2026
df49bb8
revert(llc): restore the logger files this PR never meant to remove
xsahil03x Aug 24, 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
8 changes: 6 additions & 2 deletions STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,12 @@ explaining the purpose of the item. Subsequent paragraphs elaborate. Avoid multi
sentence first paragraphs — the first paragraph gets extracted for tables of
contents.

When referencing a parameter, use backticks. When referencing a parameter that also
corresponds to a property, use square brackets instead.
When referencing a parameter, use square brackets, as
[Effective Dart](https://dart.dev/effective-dart/documentation#do-use-square-brackets-in-doc-comments-to-refer-to-in-scope-identifiers)
does — `dart doc` resolves them and links to the declaration.

Reserve backticks for names that are *not* in scope where the comment sits: a private
constructor argument named from a class-level doc, or a type from another package.

Avoid using "above" or "below" to reference other dartdoc sections. Dartdoc pages
are often viewed in isolation.
Expand Down
24 changes: 22 additions & 2 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@

### 💥 BREAKING CHANGES

- Raised the minimum Dart SDK to `^3.12.0`
- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId`
- Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead
- `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. A mismatch fails to compile in a const context, and throws in debug mode otherwise
- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, called once per connection attempt
- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the error the server closed the previous attempt with, and throws to say the credentials did not go out
- Removed `WebSocketEngineException.stopErrorCode`, use `CloseCode.normalClosure`
- `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another
- `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix
- `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range
- `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result<num> widened = intResult`) or use `fold`

### ✨ Features

Expand All @@ -16,19 +24,31 @@
- Added `User.anonymousUserId`, the id every anonymous user has
- Added `TokenManager.unconfigured`, for a client that exists before its user does, and `TokenManager.reset`, which drops the configured identity and its cached token
- Added `teams` field to `User` class
- Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else
- Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again
- 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 now `Disposable`, and `connect` throws a `StateError` afterwards
- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned

### 🐛 Bug Fixes

- Fixed three faults in `TokenManager`'s token cache: `getToken` contacted the provider on every call instead of returning the cached token, handed out a token that had already expired rather than replacing it, and cached one that finished loading after `expireToken` or `setTokenProvider` had invalidated it. A static provider is left alone, having nothing fresher to give
- Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested
- Fixed several faults in the token-expired retry: it was skipped when the response carried no JSON content type, never completed at all when the replacement was refused too, re-sent a multipart body whose streams the refused attempt had consumed, and expired a token another request had already replaced
- `StreamWebSocketClient` no longer prints to the console
- Fixed a connection that could be left open, or left disconnecting for good: `connect` leaked the socket of a failed handshake, `disconnect` completed before the socket had closed, and a close that failed or found no socket reported no closure at all
- Fixed reconnection eligibility: the deliberate-close and client-error checks never matched, and a rate limit was treated as permanent when it clears on its own
- Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which reconnected behind the caller of `connect`; only established connections are recovered now
- Fixed a health check arriving while disconnecting reporting the connection as established again, turning a deliberate disconnect into a reconnect

### 🔄 Changed

- Raised the minimum Dart SDK to `^3.12.0`
- `ConnectUserDetailsRequest` leaves its unset fields out of the JSON it serialises, rather than sending them as nulls
- Anonymous requests now always send `user_id=!anon`, rather than whatever id the `TokenManager` was configured with
- `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user
- `TokenManager.getToken` fails when `reset` runs while the token is loading, and rejects a token whose `user_id` is not the user it was loading for; a `setTokenProvider` during a load still serves the caller that started it
- `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token
- `AuthInterceptor` no longer refreshes a token when the manager has no identity, so the original error is surfaced, and no longer retries a request signed for a user it has since been pointed away from, which would have performed one user's request as another
- `StreamWebSocketEngine.open` fails when a connection is already open, rather than closing it to make room

## 0.4.0

Expand Down
3 changes: 3 additions & 0 deletions packages/stream_core/dart_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
tags:
# Tests that drive a fully wired `StreamWebSocketClient` against a fake server.
ws-client:
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'package:dio/dio.dart';
import '../stream_core_dio_error.dart';

class ApiErrorInterceptor extends Interceptor {
/// Initializes a new instance of [ApiErrorInterceptor].
/// Creates a new [ApiErrorInterceptor].
const ApiErrorInterceptor();

@override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:dio/dio.dart';

class ApiKeyInterceptor extends Interceptor {
/// Initialize a new API key interceptor
/// Creates a new [ApiKeyInterceptor].
const ApiKeyInterceptor(this.apiKey);

/// The API key to be added to the request headers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import '../../errors.dart';
import '../../user.dart';
import '../stream_core_dio_error.dart';

/// Authentication interceptor that refreshes the token if
/// an auth error is received
class AuthInterceptor extends QueuedInterceptor {
/// Initialize a new auth interceptor
/// Interceptor that signs every request with the caller's token.
///
/// A request the server refuses for an expired token is retried once, carrying a replacement.
class AuthInterceptor extends Interceptor {
/// Creates a new [AuthInterceptor].
AuthInterceptor(this._dio, this._tokenManager);

final Dio _dio;

/// The token manager used in the client
final TokenManager _tokenManager;

// Not a `QueuedInterceptor`: it frees a slot only once a handler completes, so the retry sent from
// `onError` would wait behind the request holding it. `TokenManager` serialises the token loads.

static const _retriedKey = 'stream_core.auth_token_retried';

@override
Future<void> onRequest(
RequestOptions options,
Expand All @@ -38,7 +42,7 @@ class AuthInterceptor extends QueuedInterceptor {
final dioError = StreamDioException(
exception: error,
requestOptions: options,
stackTrace: StackTrace.current,
stackTrace: stackTrace,
);

return handler.reject(dioError, true);
Expand All @@ -50,30 +54,36 @@ class AuthInterceptor extends QueuedInterceptor {
DioException err,
ErrorInterceptorHandler handler,
) async {
final data = err.response?.data;
if (data == null || data is! Map<String, dynamic>) {
return handler.next(err);
}
final error = err.apiError;
if (error == null || !error.isTokenExpiredError) return handler.next(err);

final error = StreamApiError.fromJson(data);
if (error.isTokenExpiredError) {
// Don't try to refresh the token when there is no user to load one for,
// or when the provider would return the same token again.
final canRefresh = _tokenManager.userId != null && !_tokenManager.usesStaticProvider;
if (!canRefresh) return handler.next(err);
// Otherwise, mark the current token as expired.
_tokenManager.expireToken();
final options = err.requestOptions;

try {
final options = err.requestOptions;
// ignore: inference_failure_on_function_invocation
final response = await _dio.fetch(options);
return handler.resolve(response);
} on DioException catch (exception) {
return handler.next(exception);
}
// A retry after a user switch would perform this request as the new user.
final signedFor = options.queryParameters['user_id'];
final canRefresh = signedFor == _tokenManager.userId && !_tokenManager.usesStaticProvider;
if (!canRefresh) return handler.next(err);

if (options.extra[_retriedKey] == true) return handler.next(err);

// Another request may have replaced it already, and expiring that would discard a valid token.
if (options.headers['Authorization'] == _tokenManager.peekToken()?.rawValue) {
_tokenManager.expireToken();
}

return handler.next(err);
// The multipart body is cloned because the refused attempt already consumed its streams.
final data = options.data;
final retry = options.copyWith(
extra: {...options.extra, _retriedKey: true},
data: data is FormData ? data.clone() : data,
);

try {
// ignore: inference_failure_on_function_invocation
final response = await _dio.fetch(retry);
return handler.resolve(response);
} on DioException catch (exception) {
return handler.reject(exception);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import 'package:dio/dio.dart';

typedef ConnectionIdGetter = String? Function();

/// Interceptor that injects the connection id in the request params
/// Interceptor that injects the connection id into the request params.
class ConnectionIdInterceptor extends Interceptor {
/// Initialize a new [ConnectionIdInterceptor].
/// Creates a new [ConnectionIdInterceptor].
const ConnectionIdInterceptor(this._connectionId);

/// The getter for the connection id.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import '../system_environment_manager.dart';

/// Interceptor that sets additional headers for all requests.
class HeadersInterceptor extends Interceptor {
/// Initialize a new [HeadersInterceptor].
/// Creates a new [HeadersInterceptor].
const HeadersInterceptor(this._systemEnvironmentManager);

final SystemEnvironmentManager _systemEnvironmentManager;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ void _defaultLogPrint(InterceptStep step, Object object) => print(object);

/// Interceptor dedicated to logging
class LoggingInterceptor extends Interceptor {
/// Initialize a new logging interceptor
/// Creates a new [LoggingInterceptor].
LoggingInterceptor({
this.request = true,
this.requestHeader = false,
Expand Down
31 changes: 20 additions & 11 deletions packages/stream_core/lib/src/api/stream_core_dio_error.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import 'dart:convert';

import '../../stream_core.dart';

/// Error class specific to StreamChat and Dio
/// A [DioException] carrying the Stream [ClientException] that caused it.
class StreamDioException extends DioException {
/// Initialize a stream chat dio error
/// Creates a [StreamDioException] for [exception].
StreamDioException({
required this.exception,
required super.requestOptions,
Expand All @@ -21,16 +21,25 @@ class StreamDioException extends DioException {
}

extension StreamDioExceptionExtension on DioException {
HttpClientException toClientException() {
final apiErrorResult = runSafelySync(
() => switch (response?.data) {
final Map<String, Object?> data => StreamApiError.fromJson(data),
final String data => StreamApiError.fromJson(jsonDecode(data) as Map<String, Object?>),
_ => null,
},
);
/// The Stream API error this exception's response carried, or `null` when it carried something else.
///
/// A Stream error is recognised whether the server sent it as JSON or as plain text. A body that is
/// not one — a proxy or gateway answering with an error of its own — reads as `null` rather than
/// throwing.
StreamApiError? get apiError => runSafelySync(() {
final data = response?.data;
final json = data is String ? jsonDecode(data) : data;
if (json is! Map<String, Object?>) return null;

final apiError = apiErrorResult.getOrNull();
return StreamApiError.fromJson(json);
}).getOrNull();

/// This exception as an [HttpClientException].
///
/// Takes its message, status code and cause from [apiError] when the response carried one, and
/// from what the transport reported otherwise. A request the caller cancelled is marked as such.
HttpClientException toClientException() {
final apiError = this.apiError;

return HttpClientException(
message: apiError?.message ?? response?.statusMessage ?? message ?? '',
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 (error code 40).
///
/// Distinct from [isInvalidTokenError]: an expired token is fixed by loading another one, whereas
/// an invalid token is a configuration problem that a fresh token reproduces.
bool get isTokenExpiredError => code == _expiredTokenCode;

/// Whether the token, or the API key it was signed with, cannot be accepted
/// (error codes 41 to 43, and 2).
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,8 +1,11 @@
import 'package:json_annotation/json_annotation.dart';

import '../utils/standard.dart';
import 'user.dart';

part 'connect_user_details_request.g.dart';

@JsonSerializable(createFactory: false)
@JsonSerializable(createFactory: false, includeIfNull: false)
class ConnectUserDetailsRequest {
const ConnectUserDetailsRequest({
required this.id,
Expand All @@ -13,6 +16,27 @@ class ConnectUserDetailsRequest {
this.custom,
});

/// Creates the details to send when connecting as [user].
///
/// Pass [includeDetails] as `false` to send the id alone.
///
/// The name comes from [User.originalName] rather than [User.name], so a user who was never given
/// one does not have their id sent as their name. [User.role] and [User.teams] are left out
/// deliberately: the server assigns both and ignores whatever a client claims for them.
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,
}) {
final details = user.takeIf((_) => includeDetails);

return ConnectUserDetailsRequest(
id: user.id,
name: details?.originalName,
image: details?.image,
custom: details?.custom,
);
}

final String id;
final String? image;
final bool? invisible;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading