From ac38fe8c4b58cc12eb580e87d7b641de737ea7b6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 12:22:24 +0200 Subject: [PATCH 01/78] wip: error layer design doc and ClientException cleanup (temp) Temporary checkpoint before implementing the error layer described in ERROR_LAYER.md. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 204 ++++++++++++++++++ .../lib/src/errors/client_exception.dart | 17 +- 2 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 ERROR_LAYER.md diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md new file mode 100644 index 00000000..3d16f091 --- /dev/null +++ b/ERROR_LAYER.md @@ -0,0 +1,204 @@ +# Stream Core — Error Layer + +How failures are modeled, produced, and handled across every Stream SDK (Chat, Video, Feeds). + +## The four exceptions + +Every failure a Stream SDK reports is a `StreamException`. There are exactly four kinds, named for +what the caller should do about them: + +``` +StreamException (sealed) message · cause · stackTrace +├── StreamApiException the server answered, and the answer was an error +├── StreamNetworkException the server was never heard from — outcome unknown +├── StreamAuthenticationException credentials could not be produced or sent +└── StreamClientException the SDK itself failed +``` + +```dart +sealed class StreamException implements Exception { + final String message; // always present, developer-readable; for user-facing UI, + // key your own strings off `code` (see localization note below) + final Object? cause; // the error underneath, when this wraps another + final StackTrace? stackTrace; +} + +base class StreamApiException extends StreamException { + final int statusCode; // HTTP status — independent of `code`; never derive one from the other + final int code; // Stream's stable error code — branch on this, never on message + final String? moreInfo; // docs URL; populated on REST errors, empty on WebSocket errors + final bool unrecoverable; // when true, the server says retrying will not help — authoritative. + // Absence means nothing: the backend sets it on only a few paths. + final Duration? retryAfter; // from the Retry-After header on HTTP 429; absent on WS rate limits + + bool get isTokenExpired; // code 40 — a fresh token fixes it + bool get isTokenNotYetValid; // codes 41, 42 — clock skew (nbf/iat); waiting fixes it, a fresh + // token from the same skewed clock does not + bool get isTokenSignatureInvalid; // code 43 — configuration problem; no token or wait fixes it + bool get isApiKeyInvalid; // code 2 — wrong key, or product not enabled on the app + bool get isRateLimited; // statusCode 429 +} + +base class StreamNetworkException extends StreamException { + final bool isCancelled; // the caller cancelled the request + final bool isTimeout; + final int? closeCode; // WebSocket close code, when the failure was a socket closure +} + +base class StreamAuthenticationException extends StreamException {} + +base class StreamClientException extends StreamException {} +``` + +## Which one? Three questions, asked in order + +``` +Did the Stream server answer with an error? → StreamApiException + no ↓ +Did credentials fail before anything reached it? → StreamAuthenticationException + no ↓ +Did the network / socket / timeout eat the request? → StreamNetworkException + no ↓ +It's our own code's fault → StreamClientException +``` + +One rule resolves the classic overlap: **a server that rejects your token has answered** — that is a +`StreamApiException` (check `isTokenExpired` / `isTokenInvalid`). `StreamAuthenticationException` is +only for credentials that never went out: the `TokenProvider` threw or returned nothing usable, no +user is configured, or the WebSocket auth message could not be sent. Whatever the provider threw is +preserved in `cause`. + +You will rarely see `isTokenExpired` yourself: the SDK refreshes expired tokens automatically — a +REST call refused with code 40 is retried once with a fresh token, and a reconnect passes the +refusal to the authenticator so it can send a fresh one. It surfaces only when refresh cannot help: +your provider is static, or the fresh token was refused too. + +## For app developers: catch by what you'd do + +| You caught | It means | You typically... | +|---|---|---| +| `StreamApiException` | the server said no; `message`/`code`/`moreInfo` say why | show `message`; branch on `code` for special cases | +| `StreamNetworkException` | the server was never heard from — the outcome is **unknown** | show offline UI, retry on connectivity; ignore if `isCancelled` | +| `StreamAuthenticationException` | your token provider / login setup is broken | send the user through your auth flow again | +| `StreamClientException` | the SDK (or a callback you gave it) hit an unexpected error | report to your crash tracker — not the end user's problem | + +`message` always describes the failure, but it is developer-facing English straight from the server +(REST errors even carry an internal controller-name prefix) — never show it verbatim as product UI. +For localized, user-worthy text, key your own strings off `code`; product SDKs ship a typed code +enum on top of the raw `int`. `statusCode` and `code` are independent facts: the backend maps some +codes to more than one status, so never infer one from the other. + +Failures arrive on two channels, carrying the same four types: + +- **Operations** return `Result`; a `Failure` always holds a `StreamException` — enforced by the + type system (`Failure.error` is typed `StreamException`), not by convention. Nothing is thrown. +- **Connection lifecycle** failures arrive as state: `connectionState` emits + `Disconnected(source)`, where `source` says who ended the connection and carries the error when + there was one: + +```dart +client.connectionState.listen((state) { + if (state case Disconnected(:final source)) { + switch (source) { + case UserInitiated(): break; // you called disconnect() + case ServerRefused(:final error): _onError(error); // StreamApiException — server said no + case ConnectionLost(:final error): _showReconnecting(); // StreamNetworkException — SDK retries + case AuthenticationFailed(:final error): _reLogin(); // StreamAuthenticationException + } + } +}); +``` + +```dart +final result = await client.sendMessage(...); +switch (result) { + case Success(:final data): + render(data); + case Failure(:final error): + switch (error) { + case StreamApiException(isRateLimited: true): scheduleRetry(); + case StreamApiException(:final message): showError(message); + case StreamNetworkException(isCancelled: true): break; // user navigated away + case StreamNetworkException(): showOfflineBanner(); + case StreamAuthenticationException(): redirectToLogin(); + case StreamClientException(): reportToCrashTracker(error); + } +} +``` + +The root is `sealed`, so a `switch` that misses a category does not compile. If you don't want to +branch, `error.message` is always displayable and `on StreamException` always catches everything +Stream. + +**Bugs are not in this hierarchy.** Misusing the SDK — calling `send()` before `connect()`, using a +disposed client, passing another user's token — throws Dart's own `StateError`/`ArgumentError`. +Those mean *fix your code*, not *handle at runtime*, and they never appear inside a `Result`. + +## For SDK developers: you rarely construct one + +Only **boundaries** create `StreamException`s. Everything above a boundary propagates `Result`s that +already carry the right type — if you are not writing a boundary, you never pick an exception. + +| Boundary | Produces | +|---|---| +| HTTP error mapper (the only file that reads Dio) | `StreamApiException` from a server error body or bare status; `StreamNetworkException` from timeout / cancel / socket errors | +| Response/event decoding | `StreamClientException` when wire data will not decode, whatever the decoder threw (see the seam rule below) | +| WebSocket engine + auth handler | `StreamNetworkException` for transport failures; `StreamAuthenticationException` when credentials couldn't be sent; server error events become `StreamApiException` — the inner error object is the same as REST, but it arrives in two envelopes (`{"type":"connection.error",...}` from the monolith, bare `{"error":{...}}` from the edge) and the decoder must accept both | +| `TokenManager` | `StreamAuthenticationException` when the `TokenProvider` fails (its error preserved as `cause`), when no user is configured, or when a reset raced the load | +| `runSafely` (the normalization seam) | passes an existing `StreamException` through untouched; wraps any other `Exception` (an app callback no boundary owns) into `StreamClientException`, preserving `cause`. Does **not** catch `Error` — bugs propagate and crash loudly | + +There are two kinds of seams, and they treat `Error` differently: + +- **Propagation seams** (`runSafely`, everything that moves `Result`s around) never catch `Error` — + a `StateError` or `TypeError` there is a bug in the program, and it should crash loudly. +- **Interpretation seams** (decoding a response body, decoding a WS event) catch **everything**, + `Error` included, and wrap it into `StreamClientException`. A `TypeError` thrown while decoding + wire data indicts the data, not the program — a server that renamed a field must surface as a + handleable failure, not a crash. + +A decode failure on a **live event stream** is the one failure with no operation to fail and no +reason to kill a healthy connection: drop the event, log it through the SDK logger, and count it — +never disconnect. This diagnostics path is the only place a failure is deliberately not delivered. + +Two wire facts every WS implementer must know: the server sends the error **text frame first, then +the close frame** — drain pending frames before reacting to a close, or the reason is lost. And the +close code carries almost no signal: auth, token, and permission rejections all close with **1000** +(normal closure); only 1011 (5xx), 1013 (rate limited — reconnect with backoff, no `Retry-After` +exists on WS) and 1012 (server restart) mean anything. Classify from the drained error event's +`code`, never from the close code alone. + +The two rules you actually need: + +1. **Misuse throws `Error`, conditions become `StreamException`.** Ask: "can this happen to a + correct program at runtime?" No → `StateError`/`ArgumentError`, never wrapped. Yes → the + three-question tree above says which exception. +2. **A new exception type needs a new reaction to justify it.** If the catcher of your proposed type + would do the same thing they'd do for an existing category, it is not a new type — it is a field + or a `code`. Context (like "which attachment failed") travels in the data channel + (`(attachmentId, Result)`), never by wrapping one category inside another. When a whole batch + fails before any item starts, every item reports the same failure — per-item results are the + contract, and a pre-flight failure is every item failing the same way. + +Product SDKs (Chat, Video, Feeds) may extend a category — `StreamChatApiException extends +StreamApiException` — but never add a fifth top-level kind and never re-map a core exception into an +unrelated type. + +## Retrying + +The exception carries **facts** (`statusCode`, `code`, `unrecoverable`, `retryAfter`, `isTimeout`, +`closeCode`); whether to retry is **policy** the caller owns. Honor `unrecoverable` first — it is +the server saying retrying will not help — then apply your own rules: + +```dart +abstract interface class RetryPolicy { + bool shouldRetry(StreamException error, int attempt); +} +``` + +Core ships `RetryPolicy.standard()` — honors `unrecoverable`, waits `retryAfter` on rate limits, +exponential backoff with jitter on network failures — so most callers configure, not implement. + +One honesty rule about retrying writes: a `StreamNetworkException` means the outcome is **unknown** +— the server may have performed the operation. Retry a write only through an idempotent path +(product SDKs use client-generated ids for this: re-sending a message with the same id cannot +duplicate it). diff --git a/packages/stream_core/lib/src/errors/client_exception.dart b/packages/stream_core/lib/src/errors/client_exception.dart index a053faea..523d2a7c 100644 --- a/packages/stream_core/lib/src/errors/client_exception.dart +++ b/packages/stream_core/lib/src/errors/client_exception.dart @@ -5,20 +5,17 @@ class ClientException implements Exception { this.message, Object? error, this.stackTrace, - }) { - underlyingError = error; - if (error is StreamApiError) { - apiError = error; - } else { - apiError = null; - } - } + }) : underlyingError = error; final String? message; - late final Object? underlyingError; - late final StreamApiError? apiError; + final Object? underlyingError; final StackTrace? stackTrace; + + StreamApiError? get apiError { + if (underlyingError case final StreamApiError error) return error; + return null; + } } class HttpClientException extends ClientException { From 550f8a28932115412956a5868d41022cd0d55a97 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 12:54:12 +0200 Subject: [PATCH 02/78] wip: error layer implementation against old main (pre-rebase checkpoint) Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 3 +- .../interceptors/api_error_interceptor.dart | 11 +- .../api/interceptors/auth_interceptor.dart | 27 +- .../lib/src/api/stream_core_dio_error.dart | 117 +++++++-- packages/stream_core/lib/src/errors.dart | 2 +- .../lib/src/errors/client_exception.dart | 61 ----- .../lib/src/errors/stream_api_error.dart | 63 +++-- .../lib/src/errors/stream_exception.dart | 248 ++++++++++++++++++ .../lib/src/user/token_manager.dart | 29 +- .../ws/client/engine/web_socket_engine.dart | 25 -- .../ws/client/stream_web_socket_client.dart | 57 +++- .../client/web_socket_connection_state.dart | 67 +++-- 12 files changed, 512 insertions(+), 198 deletions(-) delete mode 100644 packages/stream_core/lib/src/errors/client_exception.dart create mode 100644 packages/stream_core/lib/src/errors/stream_exception.dart diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 3d16f091..57564c22 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -28,7 +28,8 @@ base class StreamApiException extends StreamException { final int code; // Stream's stable error code — branch on this, never on message final String? moreInfo; // docs URL; populated on REST errors, empty on WebSocket errors final bool unrecoverable; // when true, the server says retrying will not help — authoritative. - // Absence means nothing: the backend sets it on only a few paths. + // Absence means nothing: today only Video endpoints set it; Chat and + // Feeds errors never carry it. final Duration? retryAfter; // from the Retry-After header on HTTP 429; absent on WS rate limits bool get isTokenExpired; // code 40 — a fresh token fixes it diff --git a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart index 4b1a53a1..a0037362 100644 --- a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart @@ -2,6 +2,11 @@ import 'package:dio/dio.dart'; import '../stream_core_dio_error.dart'; +/// Interceptor that maps every failed request onto a [StreamDioException] +/// carrying the `StreamException` it represents. +/// +/// Installed last, so every rejection leaving the HTTP client — whatever +/// interceptor or transport produced it — delivers a Stream exception. class ApiErrorInterceptor extends Interceptor { /// Creates a new [ApiErrorInterceptor]. const ApiErrorInterceptor(); @@ -12,14 +17,12 @@ class ApiErrorInterceptor extends Interceptor { ErrorInterceptorHandler handler, ) { if (err is StreamDioException) { - // If the error is already a StreamDioException, - // we can directly pass it to the handler. + // Already carries a StreamException; pass it along unchanged. return super.onError(err, handler); } - // Otherwise, we convert the DioException to a StreamDioException final streamDioException = StreamDioException( - exception: err.toClientException(), + exception: err.toStreamException(), requestOptions: err.requestOptions, response: err.response, type: err.type, diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 664d55ec..e9796950 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -37,17 +37,23 @@ class AuthInterceptor extends Interceptor { options.headers['stream-auth-type'] = token.authType.headerValue; return handler.next(options); - } catch (e, stackTrace) { + } on Exception catch (e, stackTrace) { _logger.w(() => 'no token to sign ${options.uri} with', error: e, stackTrace: stackTrace); - final error = ClientException( - message: 'Failed to load auth token', - stackTrace: stackTrace, - error: e, - ); + // Credentials never went out, so this is an authentication failure — + // unless the token manager already said so, in which case its report + // is kept as is. + final exception = switch (e) { + final StreamException exception => exception, + _ => StreamAuthenticationException( + message: 'Failed to load an auth token', + cause: e, + stackTrace: stackTrace, + ), + }; final dioError = StreamDioException( - exception: error, + exception: exception, requestOptions: options, stackTrace: stackTrace, ); @@ -61,8 +67,11 @@ class AuthInterceptor extends Interceptor { DioException err, ErrorInterceptorHandler handler, ) async { - final error = err.apiError; - if (error == null || !error.isTokenExpiredError) return handler.next(err); + // Only an expired token (code 40) is fixed by loading another one; the + // other token codes are clock or configuration problems a refresh cannot + // help. + final error = err.toStreamException(); + if (error is! StreamApiException || !error.isTokenExpired) return handler.next(err); final options = err.requestOptions; diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index 018fc8db..c4953117 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -1,10 +1,18 @@ import 'dart:convert'; -import '../../stream_core.dart'; +import 'package:dio/dio.dart'; -/// A [DioException] carrying the Stream [ClientException] that caused it. +import '../errors.dart'; +import '../utils/standard.dart'; + +/// A [DioException] carrying the [StreamException] that caused it. +/// +/// Internal plumbing: Dio's interceptor contract requires rejections to be +/// [DioException]s, so the mapped exception rides in [exception] until the +/// call layer unwraps it. Consumers never see this type — they see the +/// [StreamException] it carries. class StreamDioException extends DioException { - /// Creates a [StreamDioException] for [exception]. + /// Creates a [StreamDioException] carrying [exception]. StreamDioException({ required this.exception, required super.requestOptions, @@ -17,36 +25,91 @@ class StreamDioException extends DioException { stackTrace: stackTrace ?? StackTrace.current, ); - final ClientException exception; + /// The Stream exception this Dio exception delivers. + final StreamException exception; } -extension StreamDioExceptionExtension on DioException { - /// The Stream API error this exception's response carried, or `null` when it carried something else. +/// Maps transport failures reported by Dio onto the Stream exception kinds. +/// +/// This is the HTTP error boundary: the only place that reads +/// [DioExceptionType] and response bodies to decide what actually happened. +extension DioExceptionMapping on DioException { + /// This failure as the [StreamException] it represents. /// - /// 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) return null; + /// A response from the server — parseable Stream error payload or bare + /// status — becomes a [StreamApiException]. Everything that ended before a + /// verdict (timeout, cancellation, socket failure) becomes a + /// [StreamNetworkException]. + StreamException toStreamException() { + if (this case StreamDioException(:final exception)) return exception; - return StreamApiError.fromJson(json); - }).getOrNull(); + if (type == DioExceptionType.cancel) { + return StreamNetworkException( + message: 'The request was cancelled', + isCancelled: true, + cause: this, + stackTrace: stackTrace, + ); + } - /// 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 ?? '', - error: apiError ?? this, - statusCode: apiError?.statusCode ?? response?.statusCode, + if (_isTimeout) { + return StreamNetworkException( + message: 'The request timed out before the server answered', + isTimeout: true, + cause: this, + stackTrace: stackTrace, + ); + } + + // A response means the server reached a verdict, even when its body is + // not a Stream error payload — an edge or proxy answering on its own. + if (response case final response?) { + if (_parseApiError(response.data) case final apiError?) { + return StreamApiException.fromApiError( + apiError, + retryAfter: _parseRetryAfter(response), + cause: this, + stackTrace: stackTrace, + ); + } + + return StreamApiException( + message: response.statusMessage ?? message ?? 'The server responded with an error', + statusCode: response.statusCode ?? 0, + retryAfter: _parseRetryAfter(response), + cause: this, + stackTrace: stackTrace, + ); + } + + return StreamNetworkException( + message: message ?? 'The request failed before the server answered', + cause: this, stackTrace: stackTrace, - isRequestCancelledError: type == DioExceptionType.cancel, ); } + + bool get _isTimeout => switch (type) { + DioExceptionType.connectionTimeout || DioExceptionType.sendTimeout || DioExceptionType.receiveTimeout => true, + _ => false, + }; +} + +// An interpretation seam: decoding wire data catches everything, `Error` +// included — a `TypeError` thrown while reading a response body indicts the +// data, not the program. +StreamApiError? _parseApiError(Object? data) { + try { + final json = data is String ? jsonDecode(data) : data; + if (json is! Map) return null; + return StreamApiError.fromJson(json); + } catch (_) { + return null; + } +} + +Duration? _parseRetryAfter(Response response) { + final seconds = response.headers.value('retry-after')?.let(int.tryParse); + if (seconds == null || seconds < 0) return null; + return Duration(seconds: seconds); } diff --git a/packages/stream_core/lib/src/errors.dart b/packages/stream_core/lib/src/errors.dart index e74197a8..d63295f1 100644 --- a/packages/stream_core/lib/src/errors.dart +++ b/packages/stream_core/lib/src/errors.dart @@ -1,2 +1,2 @@ -export 'errors/client_exception.dart'; export 'errors/stream_api_error.dart'; +export 'errors/stream_exception.dart'; diff --git a/packages/stream_core/lib/src/errors/client_exception.dart b/packages/stream_core/lib/src/errors/client_exception.dart deleted file mode 100644 index 523d2a7c..00000000 --- a/packages/stream_core/lib/src/errors/client_exception.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'stream_api_error.dart'; - -class ClientException implements Exception { - ClientException({ - this.message, - Object? error, - this.stackTrace, - }) : underlyingError = error; - - final String? message; - - final Object? underlyingError; - final StackTrace? stackTrace; - - StreamApiError? get apiError { - if (underlyingError case final StreamApiError error) return error; - return null; - } -} - -class HttpClientException extends ClientException { - HttpClientException({ - super.message, - super.error, - super.stackTrace, - required this.statusCode, - required this.isRequestCancelledError, - }); - final int? statusCode; - final bool isRequestCancelledError; -} - -// class WebSocketException extends ClientException { -// WebSocketException(this.serverException, {super.error}) -// : super( -// message: -// (serverException ?? WebSocketEngineException.unknown()).reason, -// ); -// final WebSocketEngineException? serverException; -// } -// -// class WebSocketEngineException extends ClientException { -// WebSocketEngineException({ -// required this.reason, -// required this.code, -// this.engineError, -// }) : super(message: reason); -// -// WebSocketEngineException.unknown() -// : this( -// reason: 'Unknown', -// code: 0, -// engineError: null, -// ); -// -// static const stopErrorCode = 1000; -// -// final String reason; -// final int code; -// final Object? engineError; -// } diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 0c8c2be0..9eff8e2e 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -30,6 +30,12 @@ class StreamApiError extends Equatable { final int code; /// Additional error detail codes providing more context. + /// + /// The backend serializes this field as either a list or an object (a + /// long-lived compatibility quirk), so decoding tolerates both: anything + /// that is not a list of numbers reads as empty rather than failing the + /// whole error. + @JsonKey(fromJson: _detailsFromJson) final List details; /// The processing duration before the error occurred. @@ -69,38 +75,31 @@ class StreamApiError extends Equatable { ]; } -// 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 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 => _clientErrorStatusCodes.contains(statusCode); - - /// Whether this error indicates rate limiting (429 status code). - bool get isRateLimitError => statusCode == 429; +List _detailsFromJson(Object? json) { + if (json is! List) return const []; + return [for (final entry in json.whereType()) entry.toInt()]; } -// Helper function to generate a range of integers from [from] to [to] inclusive. -List _range(int from, int to) { - return List.generate(to - from + 1, (i) => i + from); +/// The token this payload carries has expired (code 40). +/// +/// Same semantics as `StreamApiException.isTokenExpired`, for code that holds +/// the raw payload — an interceptor reading a response body, or a WebSocket +/// error event. +extension StreamApiErrorPredicates on StreamApiError { + /// Whether the token has expired (code 40). A fresh token fixes it. + bool get isTokenExpired => code == 40; + + /// Whether the token is not valid yet (codes 41 and 42) — clock skew that + /// waiting fixes and a fresh token does not. + bool get isTokenNotYetValid => code == 41 || code == 42; + + /// Whether the token's signature cannot be accepted (code 43) — a + /// configuration problem no token or wait fixes. + bool get isTokenSignatureInvalid => code == 43; + + /// Whether the API key cannot be accepted (code 2). + bool get isApiKeyInvalid => code == 2; + + /// Whether the request was rate limited (HTTP 429). + bool get isRateLimited => statusCode == 429; } diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart new file mode 100644 index 00000000..440f4d56 --- /dev/null +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -0,0 +1,248 @@ +import 'package:equatable/equatable.dart'; + +import 'stream_api_error.dart'; + +/// The root of every failure a Stream SDK reports. +/// +/// There are exactly four kinds, named for what the caller should do about +/// them: +/// +/// - [StreamApiException] — the server answered, and the answer was an error. +/// - [StreamNetworkException] — the server was never heard from; the outcome +/// of the request is unknown. +/// - [StreamAuthenticationException] — credentials could not be produced or +/// sent. +/// - [StreamClientException] — the SDK itself failed. +/// +/// The root is sealed, so a `switch` over the four kinds is exhaustive. +/// Product SDKs extend a kind (`StreamChatApiException extends +/// StreamApiException`) rather than adding a fifth. +/// +/// Programmer mistakes are not part of this hierarchy: misusing the SDK — +/// sending before connecting, using a disposed client — throws Dart's own +/// [StateError] or [ArgumentError], which mean *fix your code*, not *handle at +/// runtime*. +sealed class StreamException extends Equatable implements Exception { + /// Creates a [StreamException]. + const StreamException({ + required this.message, + this.cause, + this.stackTrace, + }); + + /// What went wrong. + /// + /// Always present and developer-readable. It is not localized and may + /// contain server-internal detail — for user-facing UI, key your own + /// strings off [StreamApiException.code] instead of displaying it verbatim. + final String message; + + /// The failure underneath this one, when this exception wraps another. + final Object? cause; + + /// Where the failure was raised. + final StackTrace? stackTrace; + + @override + List get props => [message, cause]; + + @override + String toString() { + final buffer = StringBuffer('$runtimeType: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } +} + +/// A request that reached a Stream server and was answered with an error. +/// +/// The server's verdict is final for this attempt: the request was received, +/// understood, and rejected. What to do next is described by the facts +/// carried here — [code], [statusCode], [unrecoverable], [retryAfter] — not +/// by the transport that delivered them: a WebSocket connection refused by +/// the server reports the same exception as a rejected REST call. +base class StreamApiException extends StreamException { + /// Creates a [StreamApiException]. + const StreamApiException({ + required super.message, + required this.statusCode, + this.code, + this.moreInfo, + this.unrecoverable = false, + this.retryAfter, + this.apiError, + super.cause, + super.stackTrace, + }); + + /// Creates a [StreamApiException] from the server's error payload. + StreamApiException.fromApiError( + StreamApiError error, { + Duration? retryAfter, + Object? cause, + StackTrace? stackTrace, + }) : this( + message: error.message, + statusCode: error.statusCode, + code: error.code, + moreInfo: error.moreInfo.isEmpty ? null : error.moreInfo, + unrecoverable: error.unrecoverable ?? false, + retryAfter: retryAfter, + apiError: error, + cause: cause, + stackTrace: stackTrace, + ); + + /// The HTTP status the server answered with. + /// + /// Independent of [code]: the backend maps some codes to more than one + /// status, so never derive one from the other. + final int statusCode; + + /// Stream's stable error code. + /// + /// The machine-readable discriminator — branch on this, never on [message]. + /// + /// `null` when the response carried no Stream error payload — an edge or + /// proxy answering with an error of its own. Deliberately not a sentinel + /// value: the backend's registry includes low and negative codes, so any + /// stand-in number would collide with a real one. + final int? code; + + /// A documentation URL for this error, when the server sent one. + /// + /// Populated on REST errors; WebSocket errors carry none. + final String? moreInfo; + + /// Whether the server declared that retrying will not help. + /// + /// Authoritative when `true`. Absence means nothing: today only Video + /// endpoints set it — Chat and Feeds errors never carry it — so `false` + /// must not be read as "retryable". + final bool unrecoverable; + + /// How long the server asked to wait before retrying, when it said. + /// + /// Parsed from the `Retry-After` header on rate-limited REST calls; absent + /// on WebSocket rate limits, which send no headers. + final Duration? retryAfter; + + /// The server's error payload, when the failure carried a parseable one. + /// + /// `null` when only a bare status was available — an edge or proxy + /// answering with an error of its own. + final StreamApiError? apiError; + + /// Whether the token this request carried has expired (code 40). + /// + /// A freshly issued token fixes it. The SDK refreshes expired tokens + /// automatically, so this surfaces only when a refresh could not help. + bool get isTokenExpired => code == _codeTokenExpired; + + /// Whether the token is not valid yet (codes 41 and 42). + /// + /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes + /// it, a fresh token minted by the same skewed clock does not. + bool get isTokenNotYetValid => code == _codeTokenNotValidYet || code == _codeTokenUsedBeforeIssuedAt; + + /// Whether the token's signature cannot be accepted (code 43). + /// + /// A configuration problem — signed with the wrong secret. Neither waiting + /// nor a fresh token from the same signer fixes it. + bool get isTokenSignatureInvalid => code == _codeTokenSignatureInvalid; + + /// Whether the API key cannot be accepted (code 2). + /// + /// The key is unknown, or the product it addresses is not enabled for the + /// app. A configuration problem no token fixes. + bool get isApiKeyInvalid => code == _codeApiKeyInvalid; + + /// Whether the request was rate limited (HTTP 429). + /// + /// [retryAfter] carries the server's suggested wait when one was sent. + bool get isRateLimited => statusCode == 429; + + static const _codeApiKeyInvalid = 2; + static const _codeTokenExpired = 40; + static const _codeTokenNotValidYet = 41; + static const _codeTokenUsedBeforeIssuedAt = 42; + static const _codeTokenSignatureInvalid = 43; + + @override + List get props => [...super.props, statusCode, code, moreInfo, unrecoverable, retryAfter]; + + @override + String toString() { + final code = this.code?.toString() ?? 'none'; + final buffer = StringBuffer('$runtimeType(code: $code, statusCode: $statusCode): $message'); + if (moreInfo case final moreInfo?) buffer.write('\n more info: $moreInfo'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } +} + +/// A request or connection that never got a verdict from the server. +/// +/// The outcome is **unknown**: the server may have received and performed the +/// operation before the connection failed. Retry a write only through an +/// idempotent path. +base class StreamNetworkException extends StreamException { + /// Creates a [StreamNetworkException]. + const StreamNetworkException({ + required super.message, + this.isCancelled = false, + this.isTimeout = false, + this.closeCode, + super.cause, + super.stackTrace, + }); + + /// Whether the caller cancelled the request. + /// + /// A cancellation is usually the app navigating away — something to ignore, + /// not to surface. + final bool isCancelled; + + /// Whether the request or connection attempt timed out. + final bool isTimeout; + + /// The WebSocket close code, when the failure was a socket closure. + /// + /// Carries little signal on its own — the server closes with 1000 even for + /// refused credentials, and the reason travels in an error event sent + /// before the close. Classify from that event's code where one exists. + final int? closeCode; + + @override + List get props => [...super.props, isCancelled, isTimeout, closeCode]; +} + +/// Credentials that could not be produced or sent. +/// +/// Fires before anything reached the server: the token provider failed (its +/// error is preserved in [cause]), no user is configured, or the WebSocket +/// authentication message could not go out. A server that *rejected* +/// credentials has answered — that is a [StreamApiException], see +/// [StreamApiException.isTokenExpired] and its siblings. +base class StreamAuthenticationException extends StreamException { + /// Creates a [StreamAuthenticationException]. + const StreamAuthenticationException({ + required super.message, + super.cause, + super.stackTrace, + }); +} + +/// A failure inside the SDK itself. +/// +/// Wire data that would not decode, an invariant that did not hold, or an +/// error thrown by app-supplied code the SDK ran on the caller's behalf. Not +/// the end user's problem — report it to a crash tracker. +base class StreamClientException extends StreamException { + /// Creates a [StreamClientException]. + const StreamClientException({ + required super.message, + super.cause, + super.stackTrace, + }); +} diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 93f92c81..c8ef3e8c 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,4 +1,4 @@ -import '../errors/client_exception.dart'; +import '../errors/stream_exception.dart'; import '../utils/in_flight_cache.dart'; import 'token_provider.dart'; import 'user_token.dart'; @@ -142,9 +142,10 @@ class TokenManager { /// one those invalidated, so a provider that never returns cannot hold up a caller for the /// identity that replaced it. /// - /// Fails with a [ClientException] when no identity is configured, or when [reset] runs while the - /// token is loading, and with an [ArgumentError] when the provider returns a token that does not - /// belong to the user it was loading for. + /// Fails with a [StreamAuthenticationException] when no identity is configured, when [reset] runs + /// while the token is loading, or when the [TokenProvider] fails — whatever the provider threw is + /// preserved as the exception's `cause`. Fails with an [ArgumentError] when the provider returns + /// a token that does not belong to the user it was loading for. Future getToken() async { final cached = peekToken(); if (cached != null && !_isSpent(cached)) return cached; @@ -163,12 +164,14 @@ class TokenManager { Future _loadAndNotify() async { final identity = _identity; if (identity == null) { - throw ClientException(message: 'No user is configured, call setTokenProvider before loading a token'); + throw const StreamAuthenticationException( + message: 'No user is configured, call setTokenProvider before loading a token', + ); } final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await identity.provider.loadToken(loadingFor); + final updatedToken = await _loadFrom(identity.provider, loadingFor); // Both built-in providers check this, but a custom one need not: caching another user's token // would authenticate every later request as them. @@ -182,7 +185,7 @@ class TokenManager { // After a `reset` the user is gone, so the token is not returned. After a switch it is: the // caller that started as this user may finish as them. if (_identity == null) { - throw ClientException(message: 'The user was reset while its token was loading'); + throw const StreamAuthenticationException(message: 'The user was reset while its token was loading'); } return updatedToken; @@ -194,6 +197,18 @@ class TokenManager { return updatedToken; } + Future _loadFrom(TokenProvider provider, String userId) async { + try { + return await provider.loadToken(userId); + } on Exception catch (e, stackTrace) { + throw StreamAuthenticationException( + message: 'The token provider failed to load a token for user "$userId"', + cause: e, + stackTrace: stackTrace, + ); + } + } + /// Expires the currently cached token. /// /// Clears the cached token, forcing the next call to [getToken] to diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index aa0742b8..4a8f800c 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -1,8 +1,5 @@ import 'dart:typed_data'; -import 'package:equatable/equatable.dart'; - -import '../../../errors.dart'; import '../../../utils.dart'; import 'web_socket_options.dart'; @@ -181,25 +178,3 @@ extension type const CloseCode(int code) implements int { static const tlsHandshakeFailure = CloseCode(1015); } -class WebSocketEngineException extends Equatable implements Exception { - const WebSocketEngineException({ - String? reason, - int? code = 0, - this.error, - }) : reason = reason ?? 'Unknown', - code = code ?? 0; - - final String reason; - final int code; - final Object? error; - - /// Returns the error as a StreamApiError if it is of that type or - /// null otherwise. - StreamApiError? get apiError { - if (error case final StreamApiError error) return error; - return null; - } - - @override - List get props => [reason, code, error]; -} diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index accc23f8..0eeabfee 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../errors.dart'; import '../../logger.dart'; import '../../utils.dart'; import '../events/ws_event.dart'; @@ -91,7 +92,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, authenticator: onAuthenticate, tag: '$tag:Auth', onFailure: (error) => disconnect( - source: .authenticationFailed(error: error), + source: .authenticationFailed(error: _asAuthenticationFailure(error)), ), ); } @@ -193,12 +194,37 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Handed to `disconnect`, which reports the reason, closes the socket, and records the closure // even when the close fails. Returned, so a caller connecting again is not refused for the race. return result.getOrElse( - (error, _) => disconnect( - source: .serverInitiated(error: .new(error: error)), + (error, stackTrace) => disconnect( + source: .serverInitiated(error: _asOpenFailure(error, stackTrace)), ), ); } + // The engine reports whatever the transport threw; an attempt that never + // became usable is a network failure unless it already speaks for itself. + StreamException _asOpenFailure(Object error, StackTrace? stackTrace) { + return switch (error) { + final StreamException exception => exception, + _ => StreamNetworkException( + message: 'Failed to open the connection', + cause: error, + stackTrace: stackTrace, + ), + }; + } + + // Credentials never went out — an authentication failure, unless the + // authenticator already reported one of our own. + StreamException _asAuthenticationFailure(Object error) { + return switch (error) { + final StreamException exception => exception, + _ => StreamAuthenticationException( + message: 'The connection could not be authenticated', + cause: error, + ), + }; + } + /// Closes the WebSocket connection. /// /// When [closeCode] is provided, uses the specified close code for the disconnection. @@ -252,7 +278,10 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Any active state that wasn’t user/system initiated becomes server initiated. Connecting() || Authenticating() || Connected() => ServerInitiated( - error: WebSocketEngineException(code: closeCode, reason: closeReason), + error: StreamNetworkException( + message: closeReason ?? 'The connection was closed unexpectedly', + closeCode: closeCode, + ), ), // Not meaningful to transition from these. @@ -271,7 +300,11 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, _logger.e(() => 'socket failed', error: error, stackTrace: stackTrace); final source = ServerInitiated( - error: WebSocketEngineException(error: error), + error: StreamNetworkException( + message: 'The connection reported an error', + cause: error, + stackTrace: stackTrace, + ), ); // Update the connection state to 'disconnecting' with the source. @@ -299,10 +332,18 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void _handleErrorEvent(WsEvent event, Object error) { _logger.w(() => 'server sent an error event', error: error); - final source = ServerInitiated( - error: WebSocketEngineException(error: error), - ); + // A server error event is a verdict — the same payload a rejected REST + // call carries, delivered over the socket instead. + final exception = switch (error) { + final StreamException exception => exception, + final StreamApiError apiError => StreamApiException.fromApiError(apiError), + _ => StreamClientException( + message: 'The server reported an error the client could not interpret', + cause: error, + ), + }; + final source = ServerInitiated(error: exception); return unawaited(disconnect(source: source)); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 2ccdc1ce..1d1230da 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -import '../../errors.dart' show StreamApiErrorExtension; +import '../../errors.dart'; import '../../utils.dart'; import '../events/ws_event.dart'; import 'engine/web_socket_engine.dart'; @@ -216,7 +216,7 @@ sealed class DisconnectionSource extends Equatable { /// Indicates that the server closed the connection, optionally with error details. /// Reconnection eligibility depends on the specific error type. const factory DisconnectionSource.serverInitiated({ - WebSocketEngineException? error, + StreamException? error, }) = ServerInitiated; /// Creates a [SystemInitiated] disconnection source. @@ -241,7 +241,7 @@ sealed class DisconnectionSource extends Equatable { /// /// Indicates that the connection opened but could not be authenticated, so it /// was closed without ever being usable. - const factory DisconnectionSource.authenticationFailed({Object? error}) = AuthenticationFailed; + const factory DisconnectionSource.authenticationFailed({StreamException? error}) = AuthenticationFailed; /// A human-readable description of the disconnection source. /// @@ -260,10 +260,11 @@ sealed class DisconnectionSource extends Equatable { /// What closed the connection, or `null` when this source carries no cause. /// - /// For a [ServerInitiated] closure this is the error that was reported, or a - /// [WebSocketEngineException] describing the close when nothing else was. + /// For a [ServerInitiated] closure this is the [StreamException] that was + /// reported; for an [AuthenticationFailed] one, whatever prevented the + /// credentials from going out. Object? get cause => switch (this) { - ServerInitiated(:final error) => error?.error ?? error, + ServerInitiated(:final error) => error, AuthenticationFailed(:final error) => error, UserInitiated() || SystemInitiated() || UnHealthyConnection() || ConnectTimeout() => null, }; @@ -273,10 +274,19 @@ sealed class DisconnectionSource extends Equatable { /// {@template webSocketReconnectionRules} /// - [UserInitiated] — no, the caller asked for the connection to close. /// - [AuthenticationFailed] — no, credentials that never went out will not go out on a retry. - /// - [ServerInitiated] — no for a deliberate close (code 1000), for a token error a fresh token - /// would not fix, and for a client error that is neither a rate limit nor an expired token. Yes - /// otherwise, including an expired token and a rate limit, which a later attempt can get past. /// - [SystemInitiated], [UnHealthyConnection], [ConnectTimeout] — yes. + /// - [ServerInitiated] — decided by the error it carries: + /// - no error — yes, the closure said nothing against trying again. + /// - a server verdict ([StreamApiException]) — no when the server said retrying will not help + /// (`unrecoverable`), when the token's signature or the API key is refused (configuration a + /// retry reproduces), or for any other 4xx. Yes for an expired token (the reconnect + /// authenticates with a fresh one), a token not valid yet (clock skew a later attempt can get + /// past), a rate limit, and 5xx. + /// - a transport failure ([StreamNetworkException]) — yes, except a bare normal closure + /// (code 1000) with no error event before it, which is the server deliberately ending the + /// session. + /// - anything else — yes for an SDK-side failure, no for credentials that could not be sent + /// (a retry changes nothing). /// /// Necessary, but not on its own sufficient. Whether a reconnection is then actually made is /// decided by `ConnectionRecoveryHandler`, which recovers only a connection that was established, @@ -284,17 +294,25 @@ sealed class DisconnectionSource extends Equatable { /// out stays down, where one that times out on the way back does not. /// {@endtemplate} bool get isReconnectable => switch (this) { - ServerInitiated(:final error) when error?.code == CloseCode.normalClosure => false, - ServerInitiated(:final error) => switch (error?.apiError) { - final it? when it.isInvalidTokenError => false, - final it? when it.isClientError && !it.isRateLimitError && !it.isTokenExpiredError => false, - _ => true, // Reconnect on other server initiated disconnections - }, - UnHealthyConnection() => true, - SystemInitiated() => true, - ConnectTimeout() => true, UserInitiated() => false, AuthenticationFailed() => false, + SystemInitiated() => true, + UnHealthyConnection() => true, + ConnectTimeout() => true, + ServerInitiated(:final error) => switch (error) { + null => true, + StreamApiException(unrecoverable: true) => false, + StreamApiException(isTokenSignatureInvalid: true) => false, + StreamApiException(isApiKeyInvalid: true) => false, + StreamApiException(isTokenExpired: true) => true, + StreamApiException(isTokenNotYetValid: true) => true, + StreamApiException(isRateLimited: true) => true, + StreamApiException(:final statusCode) => statusCode < 400 || statusCode >= 500, + StreamNetworkException(closeCode: CloseCode.normalClosure) => false, + StreamNetworkException() => true, + StreamAuthenticationException() => false, + StreamClientException() => true, + }, }; @override @@ -322,10 +340,10 @@ final class ServerInitiated extends DisconnectionSource { /// The error that caused the server to close the connection. /// - /// When present, contains details about the server error that led to - /// disconnection. This can include authentication failures, protocol - /// violations, or other server-side issues. - final WebSocketEngineException? error; + /// A [StreamApiException] when the server reported why — the same payload a + /// rejected REST call carries — and a [StreamNetworkException] describing + /// the closure when it did not. + final StreamException? error; @override List get props => [error]; @@ -370,7 +388,10 @@ final class AuthenticationFailed extends DisconnectionSource { const AuthenticationFailed({this.error}); /// The error that prevented the connection from authenticating. - final Object? error; + /// + /// Usually a [StreamAuthenticationException] whose `cause` is whatever the + /// authenticator threw. + final StreamException? error; @override List get props => [error]; From 27623c15674736d9f1db3a471ebb7342796bdc66 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 13:09:50 +0200 Subject: [PATCH 03/78] feat(llc)!: rework the error layer around a sealed StreamException root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every failure the SDK reports is now one of four kinds, named for what the caller should do about them: StreamApiException (the server answered with an error), StreamNetworkException (no verdict — outcome unknown), StreamAuthenticationException (credentials never went out), and StreamClientException (the SDK itself failed). ClientException, HttpClientException and WebSocketEngineException are gone; the Dio boundary, the token manager and the WebSocket client all produce the new kinds, and Disconnected states carry them. The full contract, including which layer produces what and the reconnection rules, is in ERROR_LAYER.md. Co-Authored-By: Claude Fable 5 --- packages/stream_core/CHANGELOG.md | 15 +- .../api/interceptors/auth_interceptor.dart | 5 +- .../lib/src/errors/stream_api_error.g.dart | 2 +- .../lib/src/errors/stream_exception.dart | 4 + .../ws/client/engine/web_socket_engine.dart | 1 - .../web_socket_authentication_handler.dart | 13 +- .../interceptors/auth_interceptor_test.dart | 6 +- .../test/api/stream_core_dio_error_test.dart | 146 ++++++++++------ .../test/errors/stream_exception_test.dart | 160 ++++++++++++++++++ .../test/helpers/ws_client_tester.dart | 2 +- .../test/user/token_manager_test.dart | 6 +- .../client/engine/web_socket_engine_test.dart | 55 ------ .../client/stream_web_socket_client_test.dart | 46 +++-- ...eb_socket_authentication_handler_test.dart | 14 +- .../web_socket_connection_state_test.dart | 84 ++++++--- 15 files changed, 380 insertions(+), 179 deletions(-) create mode 100644 packages/stream_core/test/errors/stream_exception_test.dart delete mode 100644 packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index d556cfc9..e5628b0d 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -9,11 +9,14 @@ - `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 - `WebSocketOptions.connectTimeout` is now a non-nullable `Duration`, 30 seconds by default, and is honoured: a connection that does not come up is given up on instead of waited on indefinitely. A connection that drops later is retried for you; a `connect` that times out is not, so call it again -- 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` +- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the `StreamApiException` the server closed the previous attempt with, and throws to say the credentials did not go out +- Reworked the error layer around one sealed root. Every failure the SDK reports is a `StreamException`, of exactly four kinds named for what the caller should do: `StreamApiException` (the server answered with an error), `StreamNetworkException` (the server was never heard from — outcome unknown), `StreamAuthenticationException` (credentials could not be produced or sent), and `StreamClientException` (the SDK itself failed). See `ERROR_LAYER.md` for the full contract +- Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and `DioException.toClientException()` is now `toStreamException()` +- `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` rather than an engine exception and `Object?`, so a `Disconnected` state carries the same error kinds a failed request does +- `TokenManager.getToken` fails with a `StreamAuthenticationException` — when no identity is configured, when `reset` races a load, and when the `TokenProvider` fails, in which case whatever it threw is preserved as the exception's `cause` +- Replaced the `StreamApiError` predicates: `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes), and `isRateLimited` reads the HTTP status. The same predicates exist on `StreamApiException` - `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 +- `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token and for one not valid yet, and stays `false` for a refused signature or API key, any other 4xx, and whenever the server marked the error `unrecoverable` - `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 widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag` @@ -30,7 +33,8 @@ - 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 `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision -- Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else +- Added `DioException.toStreamException()`, the failure a Dio error represents: a response — Stream error payload or bare status — reads as a `StreamApiException`, and anything that ended before a verdict as a `StreamNetworkException`, with cancellations and timeouts marked as such +- Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` @@ -39,6 +43,7 @@ ### 🐛 Bug Fixes +- Fixed `StreamApiError` failing to decode when the backend serializes `details` as an object rather than a list, which it does on some errors for compatibility reasons; anything that is not a list of numbers now reads as empty instead of failing the whole error - 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 diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index e9796950..af33d734 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -37,12 +37,13 @@ class AuthInterceptor extends Interceptor { options.headers['stream-auth-type'] = token.authType.headerValue; return handler.next(options); - } on Exception catch (e, stackTrace) { + } catch (e, stackTrace) { _logger.w(() => 'no token to sign ${options.uri} with', error: e, stackTrace: stackTrace); // Credentials never went out, so this is an authentication failure — // unless the token manager already said so, in which case its report - // is kept as is. + // is kept as is. Caught in full: a rejection must deliver a + // StreamException whatever the app's token code threw. final exception = switch (e) { final StreamException exception => exception, _ => StreamAuthenticationException( diff --git a/packages/stream_core/lib/src/errors/stream_api_error.g.dart b/packages/stream_core/lib/src/errors/stream_api_error.g.dart index e3fd6139..49135e26 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.g.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.g.dart @@ -8,7 +8,7 @@ part of 'stream_api_error.dart'; StreamApiError _$StreamApiErrorFromJson(Map json) => StreamApiError( code: (json['code'] as num).toInt(), - details: (json['details'] as List).map((e) => (e as num).toInt()).toList(), + details: _detailsFromJson(json['details']), duration: json['duration'] as String, exceptionFields: (json['exception_fields'] as Map?)?.map( (k, e) => MapEntry(k, e as String), diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 440f4d56..d43744a3 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -48,6 +48,9 @@ sealed class StreamException extends Equatable implements Exception { @override String toString() { + // The runtime type is the point here: it names the category (or the + // product subclass) in logs and crash reports. + // ignore: no_runtimetype_tostring final buffer = StringBuffer('$runtimeType: $message'); if (cause case final cause?) buffer.write('\n caused by: $cause'); return buffer.toString(); @@ -174,6 +177,7 @@ base class StreamApiException extends StreamException { @override String toString() { final code = this.code?.toString() ?? 'none'; + // ignore: no_runtimetype_tostring final buffer = StringBuffer('$runtimeType(code: $code, statusCode: $statusCode): $message'); if (moreInfo case final moreInfo?) buffer.write('\n more info: $moreInfo'); if (cause case final cause?) buffer.write('\n caused by: $cause'); diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index 4a8f800c..2d8a9e90 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -177,4 +177,3 @@ extension type const CloseCode(int code) implements int { /// This **must not** be set explicitly by an endpoint. static const tlsHandshakeFailure = CloseCode(1015); } - diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 6f4270ec..3ff4965f 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -1,4 +1,4 @@ -import '../../errors.dart' show StreamApiError; +import '../../errors.dart' show StreamApiException; import '../../logger.dart'; import '../../utils.dart'; import '../events/ws_request.dart'; @@ -21,7 +21,7 @@ typedef WsRequestSender = Result Function(WsRequest request); /// Throw when the credentials did not go out, whether because sending failed or because this /// function chose not to send them. The connection is then closed with [AuthenticationFailed], and /// is not reconnected. -typedef WebSocketAuthenticator = Future Function(WsRequestSender send, StreamApiError? previousError); +typedef WebSocketAuthenticator = Future Function(WsRequestSender send, StreamApiException? previousError); /// A handler that authenticates newly opened connections and remembers why the server refused the /// last one. @@ -51,8 +51,8 @@ class WebSocketAuthenticationHandler { /// /// Becomes null once the attempt that read it finishes, or once a connection is established. An /// attempt abandoned before it finishes leaves it behind, for the attempt that replaces it. - StreamApiError? get previousError => _previousError; - StreamApiError? _previousError; + StreamApiException? get previousError => _previousError; + StreamApiException? _previousError; /// Takes in a connection state change. /// @@ -67,8 +67,9 @@ class WebSocketAuthenticationHandler { Connected() => null, // The caller took control; what they connect with next may have nothing to do with the refusal. Disconnected(source: UserInitiated()) => null, - // The server closed without sending an error, so the last one still applies. - Disconnected(source: ServerInitiated(:final error)) => error?.apiError ?? _previousError, + // The server closed without sending an error, so the last one still applies. Only a verdict + // counts: a transport failure says nothing about the credentials. + Disconnected(source: ServerInitiated(:final StreamApiException error)) => error, _ => _previousError, }; } diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index f2758bd7..972d91dc 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -230,9 +230,9 @@ void main() { isA().having( (it) => it.error, 'error', - isA() - .having((it) => it.message, 'message', 'Failed to load auth token') - .having((it) => it.underlyingError, 'underlyingError', isStateError), + isA() + .having((it) => it.message, 'message', 'Failed to load an auth token') + .having((it) => it.cause, 'cause', isStateError), ), ), ); diff --git a/packages/stream_core/test/api/stream_core_dio_error_test.dart b/packages/stream_core/test/api/stream_core_dio_error_test.dart index d07fcbd1..012be395 100644 --- a/packages/stream_core/test/api/stream_core_dio_error_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_error_test.dart @@ -4,9 +4,14 @@ import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; /// The body the API returns when it refuses a request. -Map _errorBody({int code = 40, int statusCode = 401, String message = 'token expired'}) => { +Map _errorBody({ + int code = 40, + int statusCode = 401, + String message = 'token expired', + Object? details = const [], +}) => { 'code': code, - 'details': [], + 'details': details, 'duration': '0ms', 'message': message, 'more_info': '', @@ -18,6 +23,7 @@ DioException _failure({ int? statusCode, String? statusMessage, String? message, + Map>? headers, DioExceptionType type = DioExceptionType.badResponse, }) { final options = RequestOptions(path: '/test'); @@ -31,89 +37,129 @@ DioException _failure({ requestOptions: options, statusCode: statusCode, statusMessage: statusMessage, + headers: Headers.fromMap(headers ?? const {}), data: body, ), ); } void main() { - group('DioException.apiError', () { + group('DioException.toStreamException', () { test('reads the Stream error from a decoded body', () { - final error = _failure(body: _errorBody(), statusCode: 401).apiError; - - expect(error?.code, 40); - expect(error?.statusCode, 401); + final exception = _failure(body: _errorBody(), statusCode: 401).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 40) + .having((it) => it.statusCode, 'statusCode', 401) + .having((it) => it.message, 'message', 'token expired') + .having((it) => it.isTokenExpired, 'isTokenExpired', isTrue), + ); }); test('reads the Stream error from a body the server sent as text', () { // Without a JSON content type Dio hands the body over as a string, and the refusal is the // same one either way. - final error = _failure(body: jsonEncode(_errorBody()), statusCode: 401).apiError; + final exception = _failure(body: jsonEncode(_errorBody()), statusCode: 401).toStreamException(); - expect(error?.code, 40); + expect(exception, isA().having((it) => it.code, 'code', 40)); }); - test('reads null from a body that is not a Stream error', () { - // A proxy or gateway can answer with JSON of its own, which must not throw on the way out. - expect(_failure(body: {'error': 'gateway timeout'}, statusCode: 504).apiError, isNull); - expect(_failure(body: 'not json at all', statusCode: 502).apiError, isNull); - expect(_failure(statusCode: 500).apiError, isNull); - expect(_failure().apiError, isNull); - }); - }); - - group('DioException.toClientException', () { - test('takes its message, status code and cause from the Stream error', () { - // The error and the response deliberately disagree, so each assertion says which one won. - // Given the same values, either source would satisfy them. - final exception = _failure( - body: _errorBody(statusCode: 429), - statusCode: 500, - statusMessage: 'Internal Server Error', - ).toClientException(); - - // The API's own account of the failure says more than the transport's, so it wins. - expect(exception.message, 'token expired'); - expect(exception.statusCode, 429); - expect(exception.apiError?.code, 40); + test('survives a body whose details is an object rather than a list', () { + // The backend serializes `details` as either a list or an object; the object variant must + // not fail the whole error on the way out. + final body = _errorBody(details: {'test': true}); + final exception = _failure(body: body, statusCode: 401).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 40) + .having((it) => it.apiError?.details, 'apiError.details', isEmpty), + ); }); - test('falls back to what the transport reported when the response carried no Stream error', () { + test('reports a verdict even when the body is not a Stream error', () { + // A proxy or gateway can answer with an error of its own: still a verdict, just one without + // a Stream code. final dioException = _failure( body: {'error': 'gateway timeout'}, statusCode: 504, statusMessage: 'Gateway Timeout', ); - final exception = dioException.toClientException(); + final exception = dioException.toStreamException(); + + expect( + exception, + isA() + .having((it) => it.message, 'message', 'Gateway Timeout') + .having((it) => it.statusCode, 'statusCode', 504) + .having((it) => it.code, 'code', isNull) + .having((it) => it.apiError, 'apiError', isNull) + // Nothing better to blame, so the transport failure is the cause. + .having((it) => it.cause, 'cause', same(dioException)), + ); + }); - expect(exception.message, 'Gateway Timeout'); - expect(exception.statusCode, 504); - // Nothing better to blame, so the transport failure is the cause. - expect(exception.underlyingError, same(dioException)); - expect(exception.apiError, isNull); + test('reads the Retry-After header on a rate limited response', () { + final rateLimited = _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'Too many requests'), + statusCode: 429, + headers: { + 'retry-after': ['7'], + }, + ).toStreamException(); + + expect( + rateLimited, + isA() + .having((it) => it.isRateLimited, 'isRateLimited', isTrue) + .having((it) => it.retryAfter, 'retryAfter', const Duration(seconds: 7)), + ); }); - test('falls back to the exception message when there is no response at all', () { - final exception = _failure(message: 'connection refused').toClientException(); + test('reports no verdict when there is no response at all', () { + final exception = _failure(message: 'connection refused').toStreamException(); - expect(exception.message, 'connection refused'); - expect(exception.statusCode, isNull); + expect( + exception, + isA() + .having((it) => it.message, 'message', 'connection refused') + .having((it) => it.isCancelled, 'isCancelled', isFalse), + ); }); - test('never leaves the message null, so a caller always has something to show', () { - final exception = _failure().toClientException(); + test('marks a timeout as such', () { + final exception = _failure(type: DioExceptionType.receiveTimeout).toStreamException(); - expect(exception.message, isEmpty); + expect(exception, isA().having((it) => it.isTimeout, 'isTimeout', isTrue)); }); test('marks a request the caller cancelled as such', () { - final cancelled = _failure(type: DioExceptionType.cancel).toClientException(); - final refused = _failure(body: _errorBody(), statusCode: 401).toClientException(); + final cancelled = _failure(type: DioExceptionType.cancel).toStreamException(); + final refused = _failure(body: _errorBody(), statusCode: 401).toStreamException(); // A caller that called the request off should not be shown it as a failure. - expect(cancelled.isRequestCancelledError, isTrue); - expect(refused.isRequestCancelledError, isFalse); + expect(cancelled, isA().having((it) => it.isCancelled, 'isCancelled', isTrue)); + expect(refused, isA()); + }); + + test('never leaves the message empty of meaning, so a caller always has something to show', () { + final exception = _failure().toStreamException(); + + expect(exception.message, isNotEmpty); + }); + + test('passes an already mapped exception through untouched', () { + const mapped = StreamAuthenticationException(message: 'no token'); + final dioException = StreamDioException( + exception: mapped, + requestOptions: RequestOptions(path: '/test'), + ); + + expect(dioException.toStreamException(), same(mapped)); }); }); } diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart new file mode 100644 index 00000000..adc71b63 --- /dev/null +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -0,0 +1,160 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +StreamApiError _apiError({ + int code = 40, + int statusCode = 401, + String message = 'token expired', + String moreInfo = '', + bool? unrecoverable, +}) => StreamApiError( + code: code, + details: const [], + duration: '0ms', + message: message, + moreInfo: moreInfo, + statusCode: statusCode, + unrecoverable: unrecoverable, +); + +void main() { + group('StreamException', () { + test('every kind can be caught as one', () { + const exceptions = [ + StreamApiException(message: 'refused', statusCode: 400, code: 4), + StreamNetworkException(message: 'offline'), + StreamAuthenticationException(message: 'no token'), + StreamClientException(message: 'broken'), + ]; + + for (final exception in exceptions) { + // The point of one root: `on StreamException` always means "a Stream problem". + expect(exception, isA(), reason: '$exception'); + expect(() => throw exception, throwsA(isA()), reason: '$exception'); + } + }); + + test('compares by what it carries', () { + const cause = FormatException('bad json'); + + expect( + const StreamClientException(message: 'broken', cause: cause), + const StreamClientException(message: 'broken', cause: cause), + ); + expect( + const StreamClientException(message: 'broken'), + isNot(const StreamNetworkException(message: 'broken')), + ); + }); + + test('prints its kind, its message and its cause', () { + const exception = StreamClientException( + message: 'the event would not decode', + cause: FormatException('bad json'), + ); + + final printed = exception.toString(); + + // A log line has to say what happened without anyone unwrapping the object. + expect(printed, contains('StreamClientException')); + expect(printed, contains('the event would not decode')); + expect(printed, contains('bad json')); + }); + }); + + group('StreamApiException', () { + test('is built from the server payload, carrying it whole', () { + final apiError = _apiError(moreInfo: 'https://getstream.io/docs'); + final exception = StreamApiException.fromApiError(apiError); + + expect(exception.message, 'token expired'); + expect(exception.code, 40); + expect(exception.statusCode, 401); + expect(exception.moreInfo, 'https://getstream.io/docs'); + expect(exception.apiError, same(apiError)); + }); + + test('reads an empty moreInfo as absent', () { + // WebSocket errors carry an empty string where REST errors carry a URL. + expect(StreamApiException.fromApiError(_apiError()).moreInfo, isNull); + }); + + test('reads an absent unrecoverable as false, never as "retryable"', () { + expect(StreamApiException.fromApiError(_apiError()).unrecoverable, isFalse); + expect(StreamApiException.fromApiError(_apiError(unrecoverable: true)).unrecoverable, isTrue); + }); + + test('tells the token conditions apart, since each has a different fix', () { + // 40 expired: a fresh token fixes it. 41/42 not valid yet: waiting fixes it. 43 wrong + // secret and 2 wrong API key: configuration, nothing at runtime fixes them. + StreamApiException forCode(int code) => StreamApiException.fromApiError(_apiError(code: code)); + + expect(forCode(40).isTokenExpired, isTrue); + expect(forCode(41).isTokenNotYetValid, isTrue); + expect(forCode(42).isTokenNotYetValid, isTrue); + expect(forCode(43).isTokenSignatureInvalid, isTrue); + expect(forCode(2).isApiKeyInvalid, isTrue); + + // Each condition is exactly one of the four. + for (final code in [40, 41, 42, 43, 2]) { + final it = forCode(code); + final holds = [it.isTokenExpired, it.isTokenNotYetValid, it.isTokenSignatureInvalid, it.isApiKeyInvalid]; + expect(holds.where((held) => held), hasLength(1), reason: 'code $code'); + } + }); + + test('reads a rate limit off the status, not the code', () { + expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 429)).isRateLimited, isTrue); + expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 500)).isRateLimited, isFalse); + }); + + test('carries no code for a verdict that was not a Stream error', () { + // An edge or proxy answers with a status and no Stream payload. No sentinel stands in for + // the missing code, because any number would collide with a real one. + const exception = StreamApiException(message: 'Gateway Timeout', statusCode: 504); + + expect(exception.code, isNull); + expect(exception.apiError, isNull); + expect(exception.toString(), contains('code: none')); + }); + + test('prints the facts a support ticket needs', () { + final exception = StreamApiException.fromApiError( + _apiError(moreInfo: 'https://getstream.io/docs/errors'), + ); + + final printed = exception.toString(); + + expect(printed, contains('code: 40')); + expect(printed, contains('statusCode: 401')); + expect(printed, contains('token expired')); + expect(printed, contains('https://getstream.io/docs/errors')); + }); + }); + + group('StreamNetworkException', () { + test('defaults to a plain unexplained failure', () { + const exception = StreamNetworkException(message: 'gone'); + + expect(exception.isCancelled, isFalse); + expect(exception.isTimeout, isFalse); + expect(exception.closeCode, isNull); + }); + + test('a different fact is a different failure', () { + // `props` must see every field, or two failures that behave differently compare equal. + expect( + const StreamNetworkException(message: 'gone', isCancelled: true), + isNot(const StreamNetworkException(message: 'gone')), + ); + expect( + const StreamNetworkException(message: 'gone', isTimeout: true), + isNot(const StreamNetworkException(message: 'gone')), + ); + expect( + const StreamNetworkException(message: 'gone', closeCode: CloseCode.normalClosure), + isNot(const StreamNetworkException(message: 'gone')), + ); + }); + }); +} diff --git a/packages/stream_core/test/helpers/ws_client_tester.dart b/packages/stream_core/test/helpers/ws_client_tester.dart index 19fb2641..99579604 100644 --- a/packages/stream_core/test/helpers/ws_client_tester.dart +++ b/packages/stream_core/test/helpers/ws_client_tester.dart @@ -284,7 +284,7 @@ WsClientTester buildTester({ WebSocketAuthenticator _authenticatorFor(TokenManager tokens) { return (send, previousError) async { // The refusal another token repairs. Left cached, the same token would be offered again. - if (previousError?.isTokenExpiredError ?? false) tokens.expireToken(); + if (previousError?.isTokenExpired ?? false) tokens.expireToken(); final token = await tokens.getToken(); send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 35f011d9..2097be3c 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -353,7 +353,7 @@ void main() { expect(manager.userId, isNull); expect(manager.peekToken(), isNull); expect(manager.usesStaticProvider, isFalse); - await expectLater(manager.getToken(), throwsA(isA())); + await expectLater(manager.getToken(), throwsA(isA())); }); test('loads once an identity is supplied', () async { @@ -380,7 +380,7 @@ void main() { expect(manager.userId, isNull); expect(manager.peekToken(), isNull); - await expectLater(manager.getToken(), throwsA(isA())); + await expectLater(manager.getToken(), throwsA(isA())); }); test('leaves the manager reusable for another user', () async { @@ -403,7 +403,7 @@ void main() { completer.complete(generateTestUserToken('user-1')); // A reset is a logout, so the token is neither cached nor handed to the caller. - await expectLater(inFlight, throwsA(isA())); + await expectLater(inFlight, throwsA(isA())); expect(manager.peekToken(), isNull); }); }); diff --git a/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart deleted file mode 100644 index f6ee932a..00000000 --- a/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -StreamApiError _apiError({required int code}) => StreamApiError( - code: code, - details: const [], - duration: '0ms', - message: 'error $code', - moreInfo: '', - statusCode: 401, -); - -void main() { - group('WebSocketEngineException', () { - test('reads the API error out of a closure the server explained', () { - final apiError = _apiError(code: 40); - - // This is what decides whether a closure is reconnected, so it has to survive being wrapped. - expect(WebSocketEngineException(error: apiError).apiError, apiError); - }); - - test('has no API error for a closure that carried something else', () { - // A socket that gave out carries the failure it hit, which says nothing about credentials. - expect(WebSocketEngineException(error: StateError('socket died')).apiError, isNull); - }); - - test('stands in for a code and reason it was not given', () { - const exception = WebSocketEngineException(); - - // A closure with no code is not the same as one closed normally, which is never reconnected. - expect(exception.code, 0); - expect(exception.code, isNot(CloseCode.normalClosure)); - expect(exception.reason, 'Unknown'); - }); - - test('stands in for a code given as null', () { - // The engine reads this off a socket, which reports null for a closure it never saw. Unlike - // the reason, the code has a non-null default, so passing null has to be handled too. - expect(const WebSocketEngineException(code: null).code, 0); - }); - - test('compares by what it carries', () { - final apiError = _apiError(code: 40); - - expect( - WebSocketEngineException(code: 1000, reason: 'bye', error: apiError), - WebSocketEngineException(code: 1000, reason: 'bye', error: apiError), - ); - expect( - const WebSocketEngineException(code: 1000), - isNot(const WebSocketEngineException(code: 1011)), - ); - }); - }); -} diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 100893f9..2de2a27b 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -40,7 +40,7 @@ void main() { isA().having( (it) => it.source, 'source', - isA().having((it) => it.error?.error, 'error', isNotNull), + isA().having((it) => it.error, 'error', isA()), ), ); expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); @@ -256,8 +256,8 @@ void main() { group('the refusal handed to the next attempt', () { /// Records what each attempt was told about the previous one. - ({WebSocketAuthenticator authenticator, List seen}) watching() { - final seen = []; + ({WebSocketAuthenticator authenticator, List seen}) watching() { + final seen = []; return ( authenticator: (send, previousError) async { seen.add(previousError); @@ -279,7 +279,7 @@ void main() { await tester.pumpEventQueue(); // The second attempt is told why the first ended, so it can present something else. - expect(seen, [null, isA().having((it) => it.code, 'code', 40)]); + expect(seen, [null, isA().having((it) => it.code, 'code', 40)]); }); test('is handed on even when the closure is not one to reconnect from', () async { @@ -298,7 +298,7 @@ void main() { // A caller who connects again is presenting credentials of their own, and needs to be told // what the last ones were refused for however the closure was classified. - expect(seen, [null, isA().having((it) => it.code, 'code', 43)]); + expect(seen, [null, isA().having((it) => it.code, 'code', 43)]); }); test('is absent once a connection has been established', () async { @@ -318,11 +318,11 @@ void main() { await tester.client.connect(); await tester.pumpEventQueue(); - expect(seen, [null, isA(), null]); + expect(seen, [null, isA(), null]); }); test('is absent after a closure the server did not cause', () async { - final seen = []; + final seen = []; final tester = buildTester( // Declines, the way an authenticator with nothing left to offer does, which closes the // connection as `AuthenticationFailed`. @@ -349,7 +349,7 @@ void main() { await tester.client.connect(); await tester.pumpEventQueue(); - expect(seen, [null, isA(), null]); + expect(seen, [null, isA(), null]); }); }); @@ -367,7 +367,13 @@ void main() { isA().having( (it) => it.source, 'source', - isA().having((it) => it.error, 'error', isStateError), + isA().having( + (it) => it.error, + 'error', + // Whatever the authenticator threw arrives as an authentication + // failure, with the original error preserved as its cause. + isA().having((it) => it.cause, 'cause', isStateError), + ), ), ); }, @@ -410,9 +416,9 @@ void main() { (it) => it.source, 'source', isA().having( - (it) => it.error?.apiError?.code, - 'apiError.code', - 43, + (it) => it.error, + 'error', + isA().having((it) => it.code, 'code', 43), ), ), ); @@ -785,7 +791,11 @@ void main() { isA().having( (it) => it.source, 'source', - isA().having((it) => it.error?.error, 'error.error', isStateError), + isA().having( + (it) => it.error, + 'error', + isA().having((it) => it.cause, 'cause', isStateError), + ), ), ); }, @@ -1087,10 +1097,12 @@ void main() { // rather than presenting the same token again. WebSocketAuthenticator authenticatorFor(TokenManager tokens) { return (send, previousError) async { - if (previousError?.isTokenExpiredError ?? false) { + if (previousError?.isTokenExpired ?? false) { tokens.expireToken(); if (tokens.usesStaticProvider) { - throw ClientException(message: 'The token was refused and the provider has no other to give'); + throw const StreamAuthenticationException( + message: 'The token was refused and the provider has no other to give', + ); } } @@ -1101,7 +1113,7 @@ void main() { test('is still answered for by the attempt after it, when the user has not changed', () { fakeAsync((async) { - final asked = []; + final asked = []; final tokens = TokenManager( userId: 'user-1', tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), @@ -1128,7 +1140,7 @@ void main() { // Nothing replaced the credentials, so the refusal still describes what this attempt holds // and forgetting it would leave the same token offered again. - expect(asked, [null, isA().having((it) => it.code, 'code', 40)]); + expect(asked, [null, isA().having((it) => it.code, 'code', 40)]); }); }); diff --git a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart index 39dfa8bd..653c4688 100644 --- a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart @@ -26,20 +26,20 @@ StreamApiError _apiError({ statusCode: statusCode, ); -final _expiredToken = _apiError(code: 40); +final _expiredToken = StreamApiException.fromApiError(_apiError(code: 40)); -Disconnected _serverClosure(StreamApiError? apiError) => Disconnected( - source: ServerInitiated(error: WebSocketEngineException(error: apiError)), +Disconnected _serverClosure(StreamApiException? error) => Disconnected( + source: ServerInitiated(error: error), ); /// Builds a handler, along with the errors it handed the authenticator and the failures it reported. ({ WebSocketAuthenticationHandler authentication, - List asked, + List asked, List failures, }) _subject({WebSocketAuthenticator? authenticator}) { - final asked = []; + final asked = []; final failures = []; final authentication = WebSocketAuthenticationHandler( @@ -102,7 +102,7 @@ void main() { final running = authentication.authenticate(); // The server refuses again while this attempt is still awaiting its credentials. - authentication.onConnectionStateChanged(_serverClosure(_apiError(code: 40))); + authentication.onConnectionStateChanged(_serverClosure(StreamApiException.fromApiError(_apiError(code: 40)))); held.complete(); await running; @@ -325,7 +325,7 @@ void main() { test('leaves the refusal for the attempt that replaces it', () async { final loaded = Completer(); - final asked = []; + final asked = []; var calls = 0; final authentication = WebSocketAuthenticationHandler( diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 04554c3f..2fda9a0b 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -11,9 +11,7 @@ StreamApiError _apiError(int code, {int statusCode = 401}) => StreamApiError( ); Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( - source: ServerInitiated( - error: WebSocketEngineException(reason: apiError.message, code: 4001, error: apiError), - ), + source: ServerInitiated(error: StreamApiException.fromApiError(apiError)), ); const _healthCheck = HealthCheckInfo(connectionId: 'connection-id'); @@ -32,17 +30,24 @@ Iterable _everyStateBut(WebSocketConnectionState excep void main() { test('automatic reconnection is enabled when the token has expired, since the next attempt loads another', () { - // 40 is an expired token, the one token error that fixes itself, because a fresh token is + // 40 is an expired token, the token error that fixes itself, because a fresh token is // loaded before the next attempt authenticates. final state = _serverDisconnect(_apiError(40)); expect(state.isAutomaticReconnectionEnabled, isTrue); }); - test('automatic reconnection is disabled when another token would be refused too', () { - // 41 not valid yet, 42 used before issued, 43 wrong secret, 2 wrong API key. A fresh token - // repairs none of them. - for (final code in [41, 42, 43, 2]) { + test('automatic reconnection is enabled when the token is not valid yet, since waiting is the fix', () { + // 41 not valid yet and 42 used before issued are clock-skew conditions: the token becomes + // valid on its own, so a later attempt can get past them where a fresh token cannot. + for (final code in [41, 42]) { + expect(_serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, isTrue, reason: 'code $code'); + } + }); + + test('automatic reconnection is disabled when another attempt would be refused too', () { + // 43 wrong secret, 2 wrong API key: configuration problems every retry reproduces. + for (final code in [43, 2]) { expect(_serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, isFalse, reason: 'code $code'); } }); @@ -61,6 +66,25 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isFalse); }); + test('automatic reconnection is disabled when the server said retrying will not help', () { + const unrecoverable = StreamApiError( + code: 17, + details: [], + duration: '0ms', + message: 'not allowed', + moreInfo: '', + statusCode: 500, + unrecoverable: true, + ); + + // A 500 would otherwise reconnect; the server's own verdict overrides the status. + final state = Disconnected( + source: ServerInitiated(error: StreamApiException.fromApiError(unrecoverable)), + ); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + test('automatic reconnection is enabled for a server-side failure', () { // Stream error codes never fall in 400..499, so this is classified by the status code alone. final state = _serverDisconnect(_apiError(9, statusCode: 500)); @@ -70,12 +94,24 @@ void main() { test('automatic reconnection is disabled when the socket was closed deliberately', () { const state = Disconnected( - source: ServerInitiated(error: WebSocketEngineException(code: CloseCode.normalClosure)), + source: ServerInitiated( + error: StreamNetworkException(message: 'bye', closeCode: CloseCode.normalClosure), + ), ); expect(state.isAutomaticReconnectionEnabled, isFalse); }); + test('automatic reconnection is enabled when the socket was lost without a verdict', () { + const state = Disconnected( + source: ServerInitiated( + error: StreamNetworkException(message: 'gone', closeCode: CloseCode.abnormalClosure), + ), + ); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + test('automatic reconnection is enabled when the server closed without saying why', () { const state = Disconnected(source: ServerInitiated()); @@ -84,7 +120,9 @@ void main() { test('automatic reconnection is disabled when a connection could not be authenticated, since the ' 'same credentials would fail again', () { - const state = Disconnected(source: AuthenticationFailed(error: 'no token')); + const state = Disconnected( + source: AuthenticationFailed(error: StreamAuthenticationException(message: 'no token')), + ); expect(state.isAutomaticReconnectionEnabled, isFalse); }); @@ -102,7 +140,7 @@ void main() { SystemInitiated(), UnHealthyConnection(), ConnectTimeout(), - AuthenticationFailed(error: 'no token'), + AuthenticationFailed(error: StreamAuthenticationException(message: 'no token')), ]; final reasons = sources.map((it) => it.closeReason).toSet(); @@ -129,22 +167,12 @@ void main() { }); group('cause', () { - test('is what the socket failed with, not the exception carrying it', () { - final apiError = _apiError(40); - final source = ServerInitiated( - error: WebSocketEngineException(reason: apiError.message, code: 4001, error: apiError), - ); - - // Reported unwrapped so a caller can match on the error itself. Handed the exception, a - // caller checking for a `StreamApiError` would find none and report the closure as unexplained. - expect(source.cause, same(apiError)); - }); - - test('is the exception itself when it carries nothing but a close code', () { - const exception = WebSocketEngineException(reason: 'gone', code: 4001); - const source = ServerInitiated(error: exception); + test('is the exception the server closed the connection with', () { + final exception = StreamApiException.fromApiError(_apiError(40)); + final source = ServerInitiated(error: exception); - // The close code is the only account of the closure there is, so it stands in. + // The exception is the caller-facing account of the closure; a caller matches on the + // exception kind, and reads the raw payload off `apiError` when they need it. expect(source.cause, same(exception)); }); @@ -153,8 +181,8 @@ void main() { }); test('is what authentication failed with', () { - final error = Exception('the token was refused'); - final source = AuthenticationFailed(error: error); + const error = StreamAuthenticationException(message: 'the token was refused'); + const source = AuthenticationFailed(error: error); expect(source.cause, same(error)); }); From 160f82df9d9ea2cab1c2fa4a89b8b23663eb403f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 13:14:31 +0200 Subject: [PATCH 04/78] docs(llc): state the real reason details tolerates non-numeric shapes A moderation rejection (code 73) carries a list of objects in details on a live v2 path, so the tolerance is not a legacy-compat concern. Co-Authored-By: Claude Fable 5 --- packages/stream_core/lib/src/errors/stream_api_error.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 9eff8e2e..d4ca643c 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -31,10 +31,10 @@ class StreamApiError extends Equatable { /// Additional error detail codes providing more context. /// - /// The backend serializes this field as either a list or an object (a - /// long-lived compatibility quirk), so decoding tolerates both: anything - /// that is not a list of numbers reads as empty rather than failing the - /// whole error. + /// The wire field is not always a list of codes: a moderation rejection + /// (code 73) carries a list of objects here, and the backend can serialize + /// the field as an object outright. Decoding tolerates every shape — what + /// is not a number reads as absent rather than failing the whole error. @JsonKey(fromJson: _detailsFromJson) final List details; From ef5b43a4be822ff8477bee9fc5348ccd2a3008a7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 13:18:32 +0200 Subject: [PATCH 05/78] docs(llc): keep server-side specifics out of the public dartdoc Public docs now say what a caller can rely on; the backend rationale (code registries, which endpoints set what, wire-path specifics) stays in ERROR_LAYER.md and private comments. Co-Authored-By: Claude Fable 5 --- .../lib/src/api/stream_core_dio_error.dart | 11 ++----- .../lib/src/errors/stream_api_error.dart | 18 ++++++----- .../lib/src/errors/stream_exception.dart | 32 +++++++++---------- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart index c4953117..84b18d6d 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -7,10 +7,8 @@ import '../utils/standard.dart'; /// A [DioException] carrying the [StreamException] that caused it. /// -/// Internal plumbing: Dio's interceptor contract requires rejections to be -/// [DioException]s, so the mapped exception rides in [exception] until the -/// call layer unwraps it. Consumers never see this type — they see the -/// [StreamException] it carries. +/// Dio requires rejections to be [DioException]s, so the mapped exception +/// rides in [exception] until the call layer unwraps it. class StreamDioException extends DioException { /// Creates a [StreamDioException] carrying [exception]. StreamDioException({ @@ -29,10 +27,7 @@ class StreamDioException extends DioException { final StreamException exception; } -/// Maps transport failures reported by Dio onto the Stream exception kinds. -/// -/// This is the HTTP error boundary: the only place that reads -/// [DioExceptionType] and response bodies to decide what actually happened. +/// Maps failures reported by Dio onto the Stream exception kinds. extension DioExceptionMapping on DioException { /// This failure as the [StreamException] it represents. /// diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index d4ca643c..50f10ab7 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -31,10 +31,8 @@ class StreamApiError extends Equatable { /// Additional error detail codes providing more context. /// - /// The wire field is not always a list of codes: a moderation rejection - /// (code 73) carries a list of objects here, and the backend can serialize - /// the field as an object outright. Decoding tolerates every shape — what - /// is not a number reads as absent rather than failing the whole error. + /// Not every error carries numeric detail codes; anything else in the wire + /// value reads as absent rather than failing the whole error. @JsonKey(fromJson: _detailsFromJson) final List details; @@ -75,16 +73,20 @@ class StreamApiError extends Equatable { ]; } +// The wire value is not guaranteed to be a list of ints: a moderation +// rejection (code 73) sends a list of objects, and the field can arrive as an +// object outright. Tolerating every shape keeps error decoding from failing +// exactly when an app needs the error. List _detailsFromJson(Object? json) { if (json is! List) return const []; return [for (final entry in json.whereType()) entry.toInt()]; } -/// The token this payload carries has expired (code 40). +/// Convenience predicates over the payload's [StreamApiError.code] and +/// [StreamApiError.statusCode]. /// -/// Same semantics as `StreamApiException.isTokenExpired`, for code that holds -/// the raw payload — an interceptor reading a response body, or a WebSocket -/// error event. +/// Same semantics as the `StreamApiException` getters of the same names, for +/// code that holds the raw payload rather than the exception. extension StreamApiErrorPredicates on StreamApiError { /// Whether the token has expired (code 40). A fresh token fixes it. bool get isTokenExpired => code == 40; diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index d43744a3..b8017c86 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -98,18 +98,16 @@ base class StreamApiException extends StreamException { /// The HTTP status the server answered with. /// - /// Independent of [code]: the backend maps some codes to more than one - /// status, so never derive one from the other. + /// Independent of [code]: the same code can arrive with different statuses, + /// so never derive one from the other. final int statusCode; /// Stream's stable error code. /// /// The machine-readable discriminator — branch on this, never on [message]. /// - /// `null` when the response carried no Stream error payload — an edge or - /// proxy answering with an error of its own. Deliberately not a sentinel - /// value: the backend's registry includes low and negative codes, so any - /// stand-in number would collide with a real one. + /// `null` when the response carried no Stream error payload, as when an + /// intermediary answered with an error of its own. final int? code; /// A documentation URL for this error, when the server sent one. @@ -119,21 +117,20 @@ base class StreamApiException extends StreamException { /// Whether the server declared that retrying will not help. /// - /// Authoritative when `true`. Absence means nothing: today only Video - /// endpoints set it — Chat and Feeds errors never carry it — so `false` - /// must not be read as "retryable". + /// Authoritative when `true`. `false` only means the server said nothing — + /// it must not be read as "retrying will help". final bool unrecoverable; - /// How long the server asked to wait before retrying, when it said. + /// How long the server asked to wait before retrying, when it named a wait. /// - /// Parsed from the `Retry-After` header on rate-limited REST calls; absent - /// on WebSocket rate limits, which send no headers. + /// Rate-limited requests can carry one; errors reported over a WebSocket + /// never do. final Duration? retryAfter; /// The server's error payload, when the failure carried a parseable one. /// - /// `null` when only a bare status was available — an edge or proxy - /// answering with an error of its own. + /// `null` when only a bare status was available, as when an intermediary + /// answered with an error of its own. final StreamApiError? apiError; /// Whether the token this request carried has expired (code 40). @@ -212,9 +209,10 @@ base class StreamNetworkException extends StreamException { /// The WebSocket close code, when the failure was a socket closure. /// - /// Carries little signal on its own — the server closes with 1000 even for - /// refused credentials, and the reason travels in an error event sent - /// before the close. Classify from that event's code where one exists. + /// Carries little signal on its own: a connection can close with a normal + /// code even when something went wrong, with the reason reported separately + /// as a [StreamApiException]. Classify by the exception kind and its code + /// rather than by the close code. final int? closeCode; @override From 8919826c7fec629fd5abd944dd608b9b0b5699e2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 13:19:56 +0200 Subject: [PATCH 06/78] docs(llc): split a two-sentence first paragraph per Effective Dart Co-Authored-By: Claude Fable 5 --- packages/stream_core/lib/src/errors/stream_api_error.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 50f10ab7..db7062df 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -88,7 +88,9 @@ List _detailsFromJson(Object? json) { /// Same semantics as the `StreamApiException` getters of the same names, for /// code that holds the raw payload rather than the exception. extension StreamApiErrorPredicates on StreamApiError { - /// Whether the token has expired (code 40). A fresh token fixes it. + /// Whether the token has expired (code 40). + /// + /// A fresh token fixes it. bool get isTokenExpired => code == 40; /// Whether the token is not valid yet (codes 41 and 42) — clock skew that From df819dbac2f101093f31ae9b7b1183aadd24403b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 13:21:58 +0200 Subject: [PATCH 07/78] docs(llc): describe the auth exception's condition in one tense Co-Authored-By: Claude Fable 5 --- packages/stream_core/lib/src/errors/stream_exception.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index b8017c86..f7cf1312 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -221,7 +221,7 @@ base class StreamNetworkException extends StreamException { /// Credentials that could not be produced or sent. /// -/// Fires before anything reached the server: the token provider failed (its +/// Raised before anything reaches the server: the token provider failed (its /// error is preserved in [cause]), no user is configured, or the WebSocket /// authentication message could not go out. A server that *rejected* /// credentials has answered — that is a [StreamApiException], see From af006354b8e94c06a4ab6d4ad6df5471e45ee75d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 13:29:00 +0200 Subject: [PATCH 08/78] style(llc): conform the error layer to the repo style guide Passive 'Consider' phrasing instead of imperatives and 'your', square brackets for in-scope identifiers with backticks reserved for out-of-scope names, static constants ordered before read-only properties, and the changelog's Upcoming section moved to the current 'Breaking / Removals' label. Co-Authored-By: Claude Fable 5 --- packages/stream_core/CHANGELOG.md | 4 +- .../lib/src/errors/stream_exception.dart | 39 ++++++++++--------- .../lib/src/user/token_manager.dart | 4 +- .../client/web_socket_connection_state.dart | 6 +-- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index e5628b0d..a7218216 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,6 +1,6 @@ ## Upcoming -### 💥 BREAKING CHANGES +### 🛑 Breaking / Removals - Raised the minimum Dart SDK to `^3.12.0` - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` @@ -43,7 +43,7 @@ ### 🐛 Bug Fixes -- Fixed `StreamApiError` failing to decode when the backend serializes `details` as an object rather than a list, which it does on some errors for compatibility reasons; anything that is not a list of numbers now reads as empty instead of failing the whole error +- Fixed `StreamApiError` failing to decode when `details` carries anything other than a list of numbers, as a moderation rejection's does; such values now read as empty instead of failing the whole error - 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 diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index f7cf1312..2602274a 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -20,8 +20,8 @@ import 'stream_api_error.dart'; /// /// Programmer mistakes are not part of this hierarchy: misusing the SDK — /// sending before connecting, using a disposed client — throws Dart's own -/// [StateError] or [ArgumentError], which mean *fix your code*, not *handle at -/// runtime*. +/// [StateError] or [ArgumentError], which signal a programming error rather +/// than a condition to handle at runtime. sealed class StreamException extends Equatable implements Exception { /// Creates a [StreamException]. const StreamException({ @@ -32,9 +32,10 @@ sealed class StreamException extends Equatable implements Exception { /// What went wrong. /// - /// Always present and developer-readable. It is not localized and may - /// contain server-internal detail — for user-facing UI, key your own - /// strings off [StreamApiException.code] instead of displaying it verbatim. + /// Always present and developer-readable, but not localized and possibly + /// carrying server-internal detail. For user-facing UI, consider keying + /// localized strings off [StreamApiException.code] rather than displaying + /// this verbatim. final String message; /// The failure underneath this one, when this exception wraps another. @@ -99,12 +100,13 @@ base class StreamApiException extends StreamException { /// The HTTP status the server answered with. /// /// Independent of [code]: the same code can arrive with different statuses, - /// so never derive one from the other. + /// so neither can be derived from the other. final int statusCode; /// Stream's stable error code. /// - /// The machine-readable discriminator — branch on this, never on [message]. + /// The machine-readable discriminator — the value to branch on, where + /// [message] is not stable. /// /// `null` when the response carried no Stream error payload, as when an /// intermediary answered with an error of its own. @@ -133,6 +135,12 @@ base class StreamApiException extends StreamException { /// answered with an error of its own. final StreamApiError? apiError; + static const _codeApiKeyInvalid = 2; + static const _codeTokenExpired = 40; + static const _codeTokenNotValidYet = 41; + static const _codeTokenUsedBeforeIssuedAt = 42; + static const _codeTokenSignatureInvalid = 43; + /// Whether the token this request carried has expired (code 40). /// /// A freshly issued token fixes it. The SDK refreshes expired tokens @@ -162,12 +170,6 @@ base class StreamApiException extends StreamException { /// [retryAfter] carries the server's suggested wait when one was sent. bool get isRateLimited => statusCode == 429; - static const _codeApiKeyInvalid = 2; - static const _codeTokenExpired = 40; - static const _codeTokenNotValidYet = 41; - static const _codeTokenUsedBeforeIssuedAt = 42; - static const _codeTokenSignatureInvalid = 43; - @override List get props => [...super.props, statusCode, code, moreInfo, unrecoverable, retryAfter]; @@ -185,8 +187,8 @@ base class StreamApiException extends StreamException { /// A request or connection that never got a verdict from the server. /// /// The outcome is **unknown**: the server may have received and performed the -/// operation before the connection failed. Retry a write only through an -/// idempotent path. +/// operation before the connection failed. Consider retrying a write only +/// through an idempotent path. base class StreamNetworkException extends StreamException { /// Creates a [StreamNetworkException]. const StreamNetworkException({ @@ -211,8 +213,8 @@ base class StreamNetworkException extends StreamException { /// /// Carries little signal on its own: a connection can close with a normal /// code even when something went wrong, with the reason reported separately - /// as a [StreamApiException]. Classify by the exception kind and its code - /// rather than by the close code. + /// as a [StreamApiException]. The exception kind and its code are the + /// reliable classifiers; the close code is not. final int? closeCode; @override @@ -239,7 +241,8 @@ base class StreamAuthenticationException extends StreamException { /// /// Wire data that would not decode, an invariant that did not hold, or an /// error thrown by app-supplied code the SDK ran on the caller's behalf. Not -/// the end user's problem — report it to a crash tracker. +/// the end user's problem — worth reporting to a crash tracker rather than +/// showing in UI. base class StreamClientException extends StreamException { /// Creates a [StreamClientException]. const StreamClientException({ diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index c8ef3e8c..77e2cb0c 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -144,8 +144,8 @@ class TokenManager { /// /// Fails with a [StreamAuthenticationException] when no identity is configured, when [reset] runs /// while the token is loading, or when the [TokenProvider] fails — whatever the provider threw is - /// preserved as the exception's `cause`. Fails with an [ArgumentError] when the provider returns - /// a token that does not belong to the user it was loading for. + /// preserved as the exception's [StreamException.cause]. Fails with an [ArgumentError] when the + /// provider returns a token that does not belong to the user it was loading for. Future getToken() async { final cached = peekToken(); if (cached != null && !_isSpent(cached)) return cached; diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 1d1230da..8296f55b 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -278,7 +278,7 @@ sealed class DisconnectionSource extends Equatable { /// - [ServerInitiated] — decided by the error it carries: /// - no error — yes, the closure said nothing against trying again. /// - a server verdict ([StreamApiException]) — no when the server said retrying will not help - /// (`unrecoverable`), when the token's signature or the API key is refused (configuration a + /// ([StreamApiException.unrecoverable]), when the token's signature or the API key is refused (configuration a /// retry reproduces), or for any other 4xx. Yes for an expired token (the reconnect /// authenticates with a fresh one), a token not valid yet (clock skew a later attempt can get /// past), a rate limit, and 5xx. @@ -389,8 +389,8 @@ final class AuthenticationFailed extends DisconnectionSource { /// The error that prevented the connection from authenticating. /// - /// Usually a [StreamAuthenticationException] whose `cause` is whatever the - /// authenticator threw. + /// Usually a [StreamAuthenticationException] whose [StreamException.cause] + /// is whatever the authenticator threw. final StreamException? error; @override From 740314431a1ff4f8f382abcd28c0ab49b0d1ee33 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 13:34:40 +0200 Subject: [PATCH 09/78] docs(repo): vendor Effective Dart's documentation guide and point the rulebooks at it A local copy at EFFECTIVE_DART_DOCUMENTATION.md (CC BY 4.0, canonical version on dart.dev) so contributors and coding agents can read the dartdoc rules offline; STYLE_GUIDE.md and CLAUDE.md now direct readers there before any dartdoc is written, with the style guide winning where the two disagree. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- EFFECTIVE_DART_DOCUMENTATION.md | 627 ++++++++++++++++++++++++++++++++ STYLE_GUIDE.md | 8 +- 3 files changed, 634 insertions(+), 3 deletions(-) create mode 100644 EFFECTIVE_DART_DOCUMENTATION.md diff --git a/CLAUDE.md b/CLAUDE.md index c254a056..0c1705fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> **Before writing or reviewing code, read [`STYLE_GUIDE.md`](STYLE_GUIDE.md).** It is the source of truth for coding conventions, the barrel contract, theming, testing, and changelog policy. See [`TESTING.md`](TESTING.md) for guidance on writing effective tests. This file is a repo overview; the style guide is the rulebook. +> **Before writing or reviewing code, read [`STYLE_GUIDE.md`](STYLE_GUIDE.md).** It is the source of truth for coding conventions, the barrel contract, theming, testing, and changelog policy. See [`TESTING.md`](TESTING.md) for guidance on writing effective tests, and [`EFFECTIVE_DART_DOCUMENTATION.md`](EFFECTIVE_DART_DOCUMENTATION.md) — a vendored copy of Effective Dart's documentation guide — before writing any dartdoc; the style guide wins where they disagree. This file is a repo overview; the style guide is the rulebook. ## Project Overview diff --git a/EFFECTIVE_DART_DOCUMENTATION.md b/EFFECTIVE_DART_DOCUMENTATION.md new file mode 100644 index 00000000..f2f08d93 --- /dev/null +++ b/EFFECTIVE_DART_DOCUMENTATION.md @@ -0,0 +1,627 @@ +# Effective Dart: Documentation + +> Vendored from [dart.dev/effective-dart/documentation](https://dart.dev/effective-dart/documentation) +> ([source](https://github.com/dart-lang/site-www/blob/main/src/content/effective-dart/documentation.md), +> CC BY 4.0) so it is available offline to contributors and coding agents. The canonical version on +> dart.dev wins where the two differ. Where [`STYLE_GUIDE.md`](STYLE_GUIDE.md) contradicts this +> document, the style guide wins. + +It's easy to think your code is obvious today without realizing how much you +rely on context already in your head. People new to your code, and +even your forgetful future self won't have that context. A concise, accurate +comment only takes a few seconds to write but can save one of those people +hours of time. + +We all know code should be self-documenting and not all comments are helpful. +But the reality is that most of us don't write as many comments as we should. +It's like exercise: you technically *can* do too much, but it's a lot more +likely that you're doing too little. Try to step it up. + +## Comments + +The following tips apply to comments that you don't want included in the +generated documentation. + +### DO format comments like sentences + +**Good:** + +```dart +// Not if anything comes before it. +if (_chunks.isNotEmpty) return false; +``` + +Capitalize the first word unless it's a case-sensitive identifier. End it with a +period (or "!" or "?", I suppose). This is true for all comments: doc comments, +inline stuff, even TODOs. Even if it's a sentence fragment. + +### DON'T use block comments for documentation + +**Good:** + +```dart +void greet(String name) { + // Assume we have a valid name. + print('Hi, $name!'); +} +``` + +**Bad:** + +```dart +void greet(String name) { + /* Assume we have a valid name. */ + print('Hi, $name!'); +} +``` + +You can use a block comment (`/* ... */`) to temporarily comment out a section +of code, but all other comments should use `//`. + +## Doc comments + +Doc comments are especially handy because [`dart doc`][] parses them +and generates [beautiful doc pages][docs] from them. +A doc comment is any comment that appears before a declaration +and uses the special `///` syntax that `dart doc` looks for. + +[`dart doc`]: /tools/dart-doc +[docs]: https://api.dart.dev + +### DO use `///` doc comments to document members and types + + +Using a doc comment instead of a regular comment enables +[`dart doc`][] to find it +and generate documentation for it. + +**Good:** + +```dart +/// The number of characters in this chunk when unsplit. +int get length => ... +``` + +**Bad:** + +```dart +// The number of characters in this chunk when unsplit. +int get length => ... +``` + +For historical reasons, `dart doc` supports two syntaxes of doc comments: `///` +("C# style") and `/** ... */` ("JavaDoc style"). We prefer `///` because it's +more compact. `/**` and `*/` add two content-free lines to a multiline doc +comment. The `///` syntax is also easier to read in some situations, such as +when a doc comment contains a bulleted list that uses `*` to mark list items. + +If you stumble onto code that still uses the JavaDoc style, consider cleaning it +up. + +### PREFER writing doc comments for public APIs + + +You don't have to document every single library, top-level variable, type, and +member, but you should document most of them. + +### CONSIDER writing a library-level doc comment + +Unlike languages like Java where the class is the only unit of program +organization, in Dart, a library is itself an entity that users work with +directly, import, and think about. That makes the `library` directive a great +place for documentation that introduces the reader to the main concepts and +functionality provided within. Consider including: + +* A single-sentence summary of what the library is for. +* Explanations of terminology used throughout the library. +* A couple of complete code samples that walk through using the API. +* Links to the most important or most commonly used classes and functions. +* Links to external references on the domain the library is concerned with. + +To document a library, place a doc comment before +the `library` directive and any annotations that might be attached +at the start of the file. + +**Good:** + +```dart +/// A really great test library. +@TestOn('browser') +library; +``` + +### CONSIDER writing doc comments for private APIs + +Doc comments aren't just for external consumers of your library's public API. +They can also be helpful for understanding private members that are called from +other parts of the library. + +### DO start doc comments with a single-sentence summary + +Start your doc comment with a brief, user-centric description ending with a +period. A sentence fragment is often sufficient. Provide just enough context for +the reader to orient themselves and decide if they should keep reading or look +elsewhere for the solution to their problem. + +**Good:** + +```dart +/// Deletes the file at [path] from the file system. +void delete(String path) { + ... +} +``` + +**Bad:** + +```dart +/// Depending on the state of the file system and the user's permissions, +/// certain operations may or may not be possible. If there is no file at +/// [path] or it can't be accessed, this function throws either [IOError] +/// or [PermissionError], respectively. Otherwise, this deletes the file. +void delete(String path) { + ... +} +``` + +### DO separate the first sentence of a doc comment into its own paragraph + +Add a blank line after the first sentence to split it out into its own +paragraph. If more than a single sentence of explanation is useful, put the +rest in later paragraphs. + +This helps you write a tight first sentence that summarizes the documentation. +Also, tools like `dart doc` use the first paragraph as a short summary in places +like lists of classes and members. + +**Good:** + +```dart +/// Deletes the file at [path]. +/// +/// Throws an [IOError] if the file could not be found. Throws a +/// [PermissionError] if the file is present but could not be deleted. +void delete(String path) { + ... +} +``` + +**Bad:** + +```dart +/// Deletes the file at [path]. Throws an [IOError] if the file could not +/// be found. Throws a [PermissionError] if the file is present but could +/// not be deleted. +void delete(String path) { + ... +} +``` + +### AVOID redundancy with the surrounding context + +The reader of a class's doc comment can clearly see the name of the class, what +interfaces it implements, etc. When reading docs for a member, the signature is +right there, and the enclosing class is obvious. None of that needs to be +spelled out in the doc comment. Instead, focus on explaining what the reader +*doesn't* already know. + +**Good:** + +```dart +class RadioButtonWidget extends Widget { + /// Sets the tooltip to [lines]. + /// + /// The lines should be word wrapped using the current font. + void tooltip(List lines) { + ... + } +} +``` + +**Bad:** + +```dart +class RadioButtonWidget extends Widget { + /// Sets the tooltip for this radio button widget to the list of strings in + /// [lines]. + void tooltip(List lines) { + ... + } +} +``` + +Only add doc comments when providing context, caveats, or usage details +not immediately obvious from the surrounding context. + + +### PREFER starting comments of a function or method with third-person verbs if its main purpose is a side effect + +The doc comment should focus on what the code *does*. + +**Good:** + +```dart +/// Connects to the server and fetches the query results. +Stream fetchResults(Query query) => ... + +/// Starts the stopwatch if not already running. +void start() => ... +``` + +### PREFER starting a non-boolean variable or property comment with a noun phrase + +The doc comment should stress what the property *is*. This is true even for +getters which may do calculation or other work. What the caller cares about is +the *result* of that work, not the work itself. + +**Good:** + +```dart +/// The current day of the week, where `0` is Sunday. +int weekday; + +/// The number of checked buttons on the page. +int get checkedCount => ... +``` + +### PREFER starting a boolean variable or property comment with "Whether" followed by a noun or gerund phrase + +The doc comment should clarify the states this variable represents. +This is true even for getters which may do calculation or other work. +What the caller cares about is the *result* of that work, not the work itself. + +**Good:** + +```dart +/// Whether the modal is currently displayed to the user. +bool isVisible; + +/// Whether the modal should confirm the user's intent on navigation. +bool get shouldConfirm => ... + +/// Whether resizing the current browser window will also resize the modal. +bool get canResize => ... +``` + +> This guideline intentionally doesn't include using "Whether or not". In many +> cases, usage of "or not" with "whether" is superfluous and can be omitted, +> especially when used in this context. + +### PREFER a noun phrase or non-imperative verb phrase for a function or method if returning a value is its primary purpose + +If a method is *syntactically* a method, but *conceptually* it is a property, +and is therefore [named with a noun phrase or non-imperative verb phrase][parameterized_property_name], +it should also be documented as such. +Use a noun-phrase for such non-boolean functions, and +a phrase starting with "Whether" for such boolean functions, +just as for a syntactic property or variable. + +**Good:** + +```dart +/// The [index]th element of this iterable in iteration order. +E elementAt(int index); + +/// Whether this iterable contains an element equal to [element]. +bool contains(Object? element); +``` + +> This guideline should be applied based on whether the declaration is +> conceptually seen as a property. +> +> Sometimes a method has no side effects, and might +> conceptually be seen as a property, but is still +> simpler to name with a verb phrase like `list.take()`. +> Then a noun phrase should still be used to document it. +> _For example `Iterable.take` can be described as +> "The first \[count\] elements of ..."._ + +[parameterized_property_name]: design#prefer-a-noun-phrase-or-non-imperative-verb-phrase-for-a-function-or-method-if-returning-a-value-is-its-primary-purpose + +### DON'T write documentation for both the getter and setter of a property + +If a property has both a getter and a setter, then create a doc comment for +only one of them. `dart doc` treats the getter and setter like a single field, +and if both the getter and the setter have doc comments, then +`dart doc` discards the setter's doc comment. + +**Good:** + +```dart +/// The pH level of the water in the pool. +/// +/// Ranges from 0-14, representing acidic to basic, with 7 being neutral. +int get phLevel => ... +set phLevel(int level) => ... +``` + +**Bad:** + +```dart +/// The depth of the water in the pool, in meters. +int get waterDepth => ... + +/// Updates the water depth to a total of [meters] in height. +set waterDepth(int meters) => ... +``` + +### PREFER starting library or type comments with noun phrases + +Doc comments for classes are often the most important documentation in your +program. They describe the type's invariants, establish the terminology it uses, +and provide context to the other doc comments for the class's members. A little +extra effort here can make all of the other members simpler to document. + +The documentation should describe an *instance* of the type. + +**Good:** + +```dart +/// A chunk of non-breaking output text terminated by a hard or soft newline. +/// +/// ... +class Chunk { + ... +} +``` + +### CONSIDER including code samples in doc comments + +**Good:** + +````dart +/// The lesser of two numbers. +/// +/// ```dart +/// min(5, 3) == 3 +/// ``` +num min(num a, num b) => ... +```` + +Humans are great at generalizing from examples, so even a single code sample +makes an API easier to learn. + +### DO use square brackets in doc comments to refer to in-scope identifiers + + +If you surround things like variable, method, or type names in square brackets, +then `dart doc` looks up the name and links to the relevant API docs. +Parentheses are optional but can +clarify you're referring to a function or constructor. +The following partial doc comments illustrate a few cases +where these comment references can be helpful: + +**Good:** + +```dart +/// Throws a [StateError] if ... +/// +/// Similar to [anotherMethod()], but ... +``` + +To link to a member of a specific class, use the class name and member name, +separated by a dot: + +**Good:** + +```dart +/// Similar to [Duration.inDays], but handles fractional days. +``` + +The dot syntax can also be used to refer to named constructors. For the unnamed +constructor, use `.new` after the class name: + +**Good:** + +```dart +/// To create a point, call [Point.new] or use [Point.polar] to ... +``` + +To learn more about the references that +the analyzer and `dart doc` support in doc comments, +check out [Documentation comment references][]. + +[Documentation comment references]: /tools/doc-comments/references + +### DO use prose to explain parameters, return values, and exceptions + +Other languages use verbose tags and sections to describe what the parameters +and returns of a method are. + +**Bad:** + +```dart +/// Defines a flag with the given name and abbreviation. +/// +/// @param name The name of the flag. +/// @param abbr The abbreviation for the flag. +/// @returns The new flag. +/// @throws ArgumentError If there is already an option with +/// the given name or abbreviation. +Flag addFlag(String name, String abbreviation) => ... +``` + +The convention in Dart is to integrate that into the description of the method +and highlight parameters using square brackets. + +Consider having sections starting with "The \[parameter\]" to describe +parameters, with "Returns" for the returned value and "Throws" for exceptions. +Errors can be documented the same way as exceptions, +or just as requirements that must be satisfied, without documenting the +precise error which will be thrown. + +**Good:** + +```dart +/// Defines a flag with the given [name] and [abbreviation]. +/// +/// The [name] and [abbreviation] strings must not be empty. +/// +/// Returns a new flag. +/// +/// Throws a [DuplicateFlagException] if there is already an option named +/// [name] or there is already an option using the [abbreviation]. +Flag addFlag(String name, String abbreviation) => ... +``` + +### DO put doc comments before metadata annotations + +**Good:** + +```dart +/// A button that can be flipped on and off. +@Component(selector: 'toggle') +class ToggleComponent {} +``` + +**Bad:** + +```dart +@Component(selector: 'toggle') +/// A button that can be flipped on and off. +class ToggleComponent {} +``` + +## Markdown + +You are allowed to use most [markdown][] formatting in your doc comments and +`dart doc` will process it accordingly using the [markdown package.][] + +[markdown]: https://daringfireball.net/projects/markdown/ +[markdown package.]: https://pub.dev/packages/markdown + +There are tons of guides out there already to introduce you to Markdown. Its +universal popularity is why we chose it. Here's just a quick example to give you +a flavor of what's supported: + +````dart +/// This is a paragraph of regular text. +/// +/// This sentence has *two* _emphasized_ words (italics) and **two** +/// __strong__ ones (bold). +/// +/// A blank line creates a separate paragraph. It has some `inline code` +/// delimited using backticks. +/// +/// * Unordered lists. +/// * Look like ASCII bullet lists. +/// * You can also use `-` or `+`. +/// +/// 1. Numbered lists. +/// 2. Are, well, numbered. +/// 1. But the values don't matter. +/// +/// * You can nest lists too. +/// * They must be indented at least 4 spaces. +/// * (Well, 5 including the space after `///`.) +/// +/// Code blocks are fenced in triple backticks: +/// +/// ```dart +/// this.code +/// .will +/// .retain(its, formatting); +/// ``` +/// +/// The code language (for syntax highlighting) defaults to Dart. You can +/// specify it by putting the name of the language after the opening backticks: +/// +/// ```html +///

HTML is magical!

+/// ``` +/// +/// Links can be: +/// +/// * https://www.just-a-bare-url.com +/// * [with the URL inline](https://google.com) +/// * [or separated out][ref link] +/// +/// [ref link]: https://google.com +/// +/// # A Header +/// +/// ## A subheader +/// +/// ### A subsubheader +/// +/// #### If you need this many levels of headers, you're doing it wrong +```` + +### AVOID using markdown excessively + +When in doubt, format less. Formatting exists to illuminate your content, not +replace it. Words are what matter. + +### AVOID using HTML for formatting + +It *may* be useful to use it in rare cases for things like tables, but in almost +all cases, if it's too complex to express in Markdown, you're better off not +expressing it. + +### PREFER backtick fences for code blocks + +Markdown has two ways to indicate a block of code: indenting the code four +spaces on each line, or surrounding it in a pair of triple-backtick "fence" +lines. The former syntax is brittle when used inside things like Markdown lists +where indentation is already meaningful or when the code block itself contains +indented code. + +The backtick syntax avoids those indentation woes, lets you indicate the code's +language, and is consistent with using backticks for inline code. + +**Good:** + +```dart +/// You can use [CodeBlockExample] like this: +/// +/// ```dart +/// var example = CodeBlockExample(); +/// print(example.isItGreat); // "Yes." +/// ``` +``` + +**Bad:** + +```dart +/// You can use [CodeBlockExample] like this: +/// +/// var example = CodeBlockExample(); +/// print(example.isItGreat); // "Yes." +``` + +## Writing + +We think of ourselves as programmers, but most of the characters in a source +file are intended primarily for humans to read. English is the language we code +in to modify the brains of our coworkers. As for any programming language, it's +worth putting effort into improving your proficiency. + +This section lists a few guidelines for our docs. You can learn more about +best practices for technical writing, in general, from articles such as +[Technical writing style](https://en.wikiversity.org/wiki/Technical_writing_style). + +### PREFER brevity + +Be clear and precise, but also terse. + +### AVOID abbreviations and acronyms unless they are obvious + +Many people don't know what "i.e.", "e.g." and "et al." mean. That acronym +that you're sure everyone in your field knows may not be as widely known as you +think. + +### PREFER using "this" instead of "the" to refer to a member's instance + +When documenting a member for a class, you often need to refer back to the +object the member is being called on. Using "the" can be ambiguous. +Prefer having some qualifier after "this", a sole "this" can be ambiguous too. + +```dart +class Box { + /// The value this box wraps. + Object? _value; + + /// Whether this box contains a value. + bool get hasValue => _value != null; +} +``` diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 04191471..91e1664e 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -373,8 +373,12 @@ Public dartdocs are encouraged but currently **not lint-enforced** (`public_member_api_docs` is disabled in `analysis_options.yaml`; this is temporary while the repo catches up). New public code should still ship with dartdocs. -In general, follow the [Effective Dart documentation guide](https://dart.dev/effective-dart/documentation) -except where this page contradicts it. +In general, follow the Effective Dart documentation guide — vendored in this repo as +[`EFFECTIVE_DART_DOCUMENTATION.md`](EFFECTIVE_DART_DOCUMENTATION.md) so it is readable offline +(canonical version at [dart.dev](https://dart.dev/effective-dart/documentation)) — except where +this page contradicts it. Read it before writing or reviewing dartdoc: the rules most often +missed are single-sentence first paragraphs, "Whether…" for booleans, noun phrases for +properties, square brackets for in-scope identifiers, and throws documented in prose. ### Answer your own questions straight away From 749f2e65686975721c51da46fd5a65e9a0433638 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:05:55 +0200 Subject: [PATCH 10/78] feat(llc): name the API's error-code registry as StreamErrorCode An extension type over int with a named constant per known code, shared by every product because the backend's registry is one shared space. StreamApiException.code is typed with it; unknown codes still carry their number, so a registry addition is never a breaking change. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 6 +- packages/stream_core/CHANGELOG.md | 1 + packages/stream_core/lib/src/errors.dart | 1 + .../lib/src/errors/stream_api_error.dart | 25 +-- .../lib/src/errors/stream_error_code.dart | 145 ++++++++++++++++++ .../lib/src/errors/stream_exception.dart | 34 ++-- .../test/errors/stream_exception_test.dart | 11 +- 7 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 packages/stream_core/lib/src/errors/stream_error_code.dart diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 57564c22..a47f3546 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -85,8 +85,10 @@ your provider is static, or the fresh token was refused too. `message` always describes the failure, but it is developer-facing English straight from the server (REST errors even carry an internal controller-name prefix) — never show it verbatim as product UI. -For localized, user-worthy text, key your own strings off `code`; product SDKs ship a typed code -enum on top of the raw `int`. `statusCode` and `code` are independent facts: the backend maps some +For localized, user-worthy text, key your own strings off `code`. Core owns the code registry as +`StreamErrorCode` — an extension type over `int` with a named constant per known code, shared by +every product because the backend's registry is one shared space; a code the SDK does not know yet +still carries its number. `statusCode` and `code` are independent facts: the backend maps some codes to more than one status, so never infer one from the other. Failures arrive on two channels, carrying the same four types: diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index a7218216..fdefd9ae 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -35,6 +35,7 @@ - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision - Added `DioException.toStreamException()`, the failure a Dio error represents: a response — Stream error payload or bare status — reads as a `StreamApiException`, and anything that ended before a verdict as a `StreamNetworkException`, with cancellations and timeouts marked as such - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses +- Added `StreamErrorCode`, the API's error-code registry as named constants over `int` — one shared vocabulary for every Stream product, tolerant of codes the SDK does not know yet. `StreamApiException.code` is typed with it - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` diff --git a/packages/stream_core/lib/src/errors.dart b/packages/stream_core/lib/src/errors.dart index d63295f1..63a5dc0e 100644 --- a/packages/stream_core/lib/src/errors.dart +++ b/packages/stream_core/lib/src/errors.dart @@ -1,2 +1,3 @@ export 'errors/stream_api_error.dart'; +export 'errors/stream_error_code.dart'; export 'errors/stream_exception.dart'; diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index db7062df..90e4c5fa 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'stream_error_code.dart'; + part 'stream_api_error.g.dart'; /// An API error response from the Stream API. @@ -88,21 +90,24 @@ List _detailsFromJson(Object? json) { /// Same semantics as the `StreamApiException` getters of the same names, for /// code that holds the raw payload rather than the exception. extension StreamApiErrorPredicates on StreamApiError { - /// Whether the token has expired (code 40). + /// Whether the token has expired ([StreamErrorCode.tokenExpired]). /// /// A fresh token fixes it. - bool get isTokenExpired => code == 40; + bool get isTokenExpired => code == StreamErrorCode.tokenExpired; - /// Whether the token is not valid yet (codes 41 and 42) — clock skew that - /// waiting fixes and a fresh token does not. - bool get isTokenNotYetValid => code == 41 || code == 42; + /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] + /// and [StreamErrorCode.tokenUsedBeforeIssuedAt]) — clock skew that waiting + /// fixes and a fresh token does not. + bool get isTokenNotYetValid => + code == StreamErrorCode.tokenNotValidYet || code == StreamErrorCode.tokenUsedBeforeIssuedAt; - /// Whether the token's signature cannot be accepted (code 43) — a - /// configuration problem no token or wait fixes. - bool get isTokenSignatureInvalid => code == 43; + /// Whether the token's signature cannot be accepted + /// ([StreamErrorCode.tokenSignatureInvalid]) — a configuration problem no + /// token or wait fixes. + bool get isTokenSignatureInvalid => code == StreamErrorCode.tokenSignatureInvalid; - /// Whether the API key cannot be accepted (code 2). - bool get isApiKeyInvalid => code == 2; + /// Whether the API key cannot be accepted ([StreamErrorCode.apiKeyInvalid]). + bool get isApiKeyInvalid => code == StreamErrorCode.apiKeyInvalid; /// Whether the request was rate limited (HTTP 429). bool get isRateLimited => statusCode == 429; diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart new file mode 100644 index 00000000..91b277d2 --- /dev/null +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -0,0 +1,145 @@ +/// A strongly-typed wrapper around a Stream API error code. +/// +/// The named constants cover the codes the API is known to send, across every +/// Stream product — the registry is one shared space, so a chat and a video +/// error draw from the same numbers. The type behaves like an `int` at +/// runtime, so a code the SDK does not know yet still compares and prints as +/// its number rather than failing to decode. +/// +/// A code identifies the *condition*; the HTTP status it arrives with can +/// vary, so neither is derivable from the other. +extension type const StreamErrorCode(int code) implements int { + /// `-1` – An unexpected server-side failure. + static const internalError = StreamErrorCode(-1); + + /// `2` – The API key cannot be accepted: unknown, or the product it + /// addresses is not enabled for the app. + static const apiKeyInvalid = StreamErrorCode(2); + + /// `4` – The request input failed validation. + static const inputError = StreamErrorCode(4); + + /// `5` – Authentication failed for a reason other than the token codes + /// below. + static const authenticationFailed = StreamErrorCode(5); + + /// `6` – The username is already taken. + static const duplicateUsername = StreamErrorCode(6); + + /// `9` – The request was rate limited. + static const rateLimited = StreamErrorCode(9); + + /// `16` – The requested resource does not exist. + static const notFound = StreamErrorCode(16); + + /// `17` – The caller lacks permission for this operation. + static const notAllowed = StreamErrorCode(17); + + /// `18` – The event type is not supported. + static const eventNotSupported = StreamErrorCode(18); + + /// `19` – The channel does not support this feature. + static const channelFeatureNotSupported = StreamErrorCode(19); + + /// `20` – The message is longer than the allowed maximum. + static const messageTooLong = StreamErrorCode(20); + + /// `21` – Threads cannot be nested further. + static const multipleNestingLevel = StreamErrorCode(21); + + /// `22` – The request payload is too big. + static const payloadTooBig = StreamErrorCode(22); + + /// `40` – The token has expired. A fresh token fixes it. + static const tokenExpired = StreamErrorCode(40); + + /// `41` – The token is not valid yet (its `nbf` claim is in the future). + /// Waiting fixes it. + static const tokenNotValidYet = StreamErrorCode(41); + + /// `42` – The token was used before it was issued (its `iat` claim is in + /// the future). Waiting fixes it. + static const tokenUsedBeforeIssuedAt = StreamErrorCode(42); + + /// `43` – The token's signature cannot be accepted. A configuration + /// problem no token or wait fixes. + static const tokenSignatureInvalid = StreamErrorCode(43); + + /// `44` – The custom command has no endpoint configured. + static const customCommandEndpointMissing = StreamErrorCode(44); + + /// `45` – Calling the custom command endpoint failed. + static const customCommandEndpointCallError = StreamErrorCode(45); + + /// `46` – The connection id is not known to the server. + static const connectionIdNotFound = StreamErrorCode(46); + + /// `48` – The server timed the request out. + static const requestTimeout = StreamErrorCode(48); + + /// `60` – The user is in a cooldown period. + static const cooldown = StreamErrorCode(60); + + /// `70` – The channel query's permission filters do not match. + static const queryChannelPermissionsMismatch = StreamErrorCode(70); + + /// `71` – The client has too many concurrent connections. + static const tooManyConnections = StreamErrorCode(71); + + /// `72` – The operation is not supported in push v1. + static const notSupportedInPushV1 = StreamErrorCode(72); + + /// `73` – Message moderation failed, or the moderation provider failed. + static const moderationFailed = StreamErrorCode(73); + + /// `80` – No video provider is configured. + static const videoProviderNotConfigured = StreamErrorCode(80); + + /// `81` – The call id is not valid. + static const videoInvalidCallId = StreamErrorCode(81); + + /// `82` – Creating the call failed. + static const videoCreateCallFailed = StreamErrorCode(82); + + /// `99` – The app is suspended. + static const appSuspended = StreamErrorCode(99); + + /// `100` – No video datacenters are available. + static const videoNoDatacentersAvailable = StreamErrorCode(100); + + /// `101` – Joining the call failed. + static const videoJoinCallFailure = StreamErrorCode(101); + + /// `102` – The call query's permission filters do not match. + static const queryCallsPermissionsMismatch = StreamErrorCode(102); + + /// `103` – The call being accepted or rejected is gone. + static const acceptRejectCallIsGone = StreamErrorCode(103); + + /// `104` – The call-stats query's permission filters do not match. + static const queryCallStatsPermissionsMismatch = StreamErrorCode(104); + + /// `105` – The operation is supported only in push v3. + static const supportedInPushV3 = StreamErrorCode(105); + + /// `106` – The call is restricted in the caller's region. + static const videoRestrictedRegion = StreamErrorCode(106); + + /// `107` – The product is suspended for the app. + static const productSuspended = StreamErrorCode(107); + + /// `108` – The caller cancelled the request before the server finished. + static const requestCancelled = StreamErrorCode(108); + + /// `109` – Joining this call requires requesting end-to-end encryption. + static const videoJoinMustRequestE2ee = StreamErrorCode(109); + + /// `110` – End-to-end encryption is not available for this call. + static const videoJoinE2eeNotAvailable = StreamErrorCode(110); + + /// `111` – The moderation service is overloaded. + static const moderationOverloaded = StreamErrorCode(111); + + /// `112` – Feeds storage is unavailable. + static const feedsStorageUnavailable = StreamErrorCode(112); +} diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 2602274a..d0b3132e 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'stream_api_error.dart'; +import 'stream_error_code.dart'; /// The root of every failure a Stream SDK reports. /// @@ -88,7 +89,7 @@ base class StreamApiException extends StreamException { }) : this( message: error.message, statusCode: error.statusCode, - code: error.code, + code: StreamErrorCode(error.code), moreInfo: error.moreInfo.isEmpty ? null : error.moreInfo, unrecoverable: error.unrecoverable ?? false, retryAfter: retryAfter, @@ -106,11 +107,12 @@ base class StreamApiException extends StreamException { /// Stream's stable error code. /// /// The machine-readable discriminator — the value to branch on, where - /// [message] is not stable. + /// [message] is not stable. [StreamErrorCode] names the known values; + /// a code without a named constant still carries its number. /// /// `null` when the response carried no Stream error payload, as when an /// intermediary answered with an error of its own. - final int? code; + final StreamErrorCode? code; /// A documentation URL for this error, when the server sent one. /// @@ -135,35 +137,33 @@ base class StreamApiException extends StreamException { /// answered with an error of its own. final StreamApiError? apiError; - static const _codeApiKeyInvalid = 2; - static const _codeTokenExpired = 40; - static const _codeTokenNotValidYet = 41; - static const _codeTokenUsedBeforeIssuedAt = 42; - static const _codeTokenSignatureInvalid = 43; - - /// Whether the token this request carried has expired (code 40). + /// Whether the token this request carried has expired + /// ([StreamErrorCode.tokenExpired]). /// /// A freshly issued token fixes it. The SDK refreshes expired tokens /// automatically, so this surfaces only when a refresh could not help. - bool get isTokenExpired => code == _codeTokenExpired; + bool get isTokenExpired => code == StreamErrorCode.tokenExpired; - /// Whether the token is not valid yet (codes 41 and 42). + /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] + /// and [StreamErrorCode.tokenUsedBeforeIssuedAt]). /// /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes /// it, a fresh token minted by the same skewed clock does not. - bool get isTokenNotYetValid => code == _codeTokenNotValidYet || code == _codeTokenUsedBeforeIssuedAt; + bool get isTokenNotYetValid => + code == StreamErrorCode.tokenNotValidYet || code == StreamErrorCode.tokenUsedBeforeIssuedAt; - /// Whether the token's signature cannot be accepted (code 43). + /// Whether the token's signature cannot be accepted + /// ([StreamErrorCode.tokenSignatureInvalid]). /// /// A configuration problem — signed with the wrong secret. Neither waiting /// nor a fresh token from the same signer fixes it. - bool get isTokenSignatureInvalid => code == _codeTokenSignatureInvalid; + bool get isTokenSignatureInvalid => code == StreamErrorCode.tokenSignatureInvalid; - /// Whether the API key cannot be accepted (code 2). + /// Whether the API key cannot be accepted ([StreamErrorCode.apiKeyInvalid]). /// /// The key is unknown, or the product it addresses is not enabled for the /// app. A configuration problem no token fixes. - bool get isApiKeyInvalid => code == _codeApiKeyInvalid; + bool get isApiKeyInvalid => code == StreamErrorCode.apiKeyInvalid; /// Whether the request was rate limited (HTTP 429). /// diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index adc71b63..b26afbdb 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -21,7 +21,7 @@ void main() { group('StreamException', () { test('every kind can be caught as one', () { const exceptions = [ - StreamApiException(message: 'refused', statusCode: 400, code: 4), + StreamApiException(message: 'refused', statusCode: 400, code: StreamErrorCode.inputError), StreamNetworkException(message: 'offline'), StreamAuthenticationException(message: 'no token'), StreamClientException(message: 'broken'), @@ -103,6 +103,15 @@ void main() { } }); + test('carries a code the SDK does not know as its number', () { + // The registry grows server-side; an unnamed code must survive decoding + // and compare as a plain number. + final exception = StreamApiException.fromApiError(_apiError(code: 999)); + + expect(exception.code, 999); + expect(exception.isTokenExpired, isFalse); + }); + test('reads a rate limit off the status, not the code', () { expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 429)).isRateLimited, isTrue); expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 500)).isRateLimited, isFalse); From d67b2610cc32c5ad519c7cf5f2c55c8b029cb621 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:08:13 +0200 Subject: [PATCH 11/78] docs(llc): sharpen three error-code descriptions against their real conditions Cooldown is the channel's slow mode, and the permissions-mismatch codes mean results were withheld for lack of access, verified against the backend's constructors. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_error_code.dart | 12 ++++++++---- .../stream_core/lib/src/errors/stream_exception.dart | 9 ++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart index 91b277d2..d0b53248 100644 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -77,10 +77,12 @@ extension type const StreamErrorCode(int code) implements int { /// `48` – The server timed the request out. static const requestTimeout = StreamErrorCode(48); - /// `60` – The user is in a cooldown period. + /// `60` – The user must wait out the channel's cooldown before sending + /// another message. static const cooldown = StreamErrorCode(60); - /// `70` – The channel query's permission filters do not match. + /// `70` – Channels matching the query were withheld because the user lacks + /// access to them. static const queryChannelPermissionsMismatch = StreamErrorCode(70); /// `71` – The client has too many concurrent connections. @@ -110,13 +112,15 @@ extension type const StreamErrorCode(int code) implements int { /// `101` – Joining the call failed. static const videoJoinCallFailure = StreamErrorCode(101); - /// `102` – The call query's permission filters do not match. + /// `102` – Calls matching the query were withheld because the user lacks + /// access to them. static const queryCallsPermissionsMismatch = StreamErrorCode(102); /// `103` – The call being accepted or rejected is gone. static const acceptRejectCallIsGone = StreamErrorCode(103); - /// `104` – The call-stats query's permission filters do not match. + /// `104` – Call stats matching the query were withheld because the user + /// lacks access to them. static const queryCallStatsPermissionsMismatch = StreamErrorCode(104); /// `105` – The operation is supported only in push v3. diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index d0b3132e..bfafbc9d 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -142,28 +142,27 @@ base class StreamApiException extends StreamException { /// /// A freshly issued token fixes it. The SDK refreshes expired tokens /// automatically, so this surfaces only when a refresh could not help. - bool get isTokenExpired => code == StreamErrorCode.tokenExpired; + bool get isTokenExpired => code == .tokenExpired; /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] /// and [StreamErrorCode.tokenUsedBeforeIssuedAt]). /// /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes /// it, a fresh token minted by the same skewed clock does not. - bool get isTokenNotYetValid => - code == StreamErrorCode.tokenNotValidYet || code == StreamErrorCode.tokenUsedBeforeIssuedAt; + bool get isTokenNotYetValid => code == .tokenNotValidYet || code == .tokenUsedBeforeIssuedAt; /// Whether the token's signature cannot be accepted /// ([StreamErrorCode.tokenSignatureInvalid]). /// /// A configuration problem — signed with the wrong secret. Neither waiting /// nor a fresh token from the same signer fixes it. - bool get isTokenSignatureInvalid => code == StreamErrorCode.tokenSignatureInvalid; + bool get isTokenSignatureInvalid => code == .tokenSignatureInvalid; /// Whether the API key cannot be accepted ([StreamErrorCode.apiKeyInvalid]). /// /// The key is unknown, or the product it addresses is not enabled for the /// app. A configuration problem no token fixes. - bool get isApiKeyInvalid => code == StreamErrorCode.apiKeyInvalid; + bool get isApiKeyInvalid => code == .apiKeyInvalid; /// Whether the request was rate limited (HTTP 429). /// From b599be4eab6563781a1f00c60b0b56739a4a367b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:10:17 +0200 Subject: [PATCH 12/78] feat(llc): type StreamApiError.code as StreamErrorCode The wire model speaks the registry directly instead of a raw int; a code without a named constant still decodes and compares as its number. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_api_error.dart | 14 ++++++++------ .../lib/src/errors/stream_api_error.g.dart | 2 +- .../lib/src/errors/stream_exception.dart | 2 +- .../test/errors/stream_exception_test.dart | 2 +- .../web_socket_authentication_handler_test.dart | 2 +- .../client/web_socket_connection_state_test.dart | 4 ++-- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 90e4c5fa..0c034ba3 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -29,7 +29,10 @@ class StreamApiError extends Equatable { }); /// The specific error code identifying the type of error. - final int code; + /// + /// [StreamErrorCode] names the known values; a code without a named + /// constant still carries its number. + final StreamErrorCode code; /// Additional error detail codes providing more context. /// @@ -93,21 +96,20 @@ extension StreamApiErrorPredicates on StreamApiError { /// Whether the token has expired ([StreamErrorCode.tokenExpired]). /// /// A fresh token fixes it. - bool get isTokenExpired => code == StreamErrorCode.tokenExpired; + bool get isTokenExpired => code == .tokenExpired; /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] /// and [StreamErrorCode.tokenUsedBeforeIssuedAt]) — clock skew that waiting /// fixes and a fresh token does not. - bool get isTokenNotYetValid => - code == StreamErrorCode.tokenNotValidYet || code == StreamErrorCode.tokenUsedBeforeIssuedAt; + bool get isTokenNotYetValid => code == .tokenNotValidYet || code == .tokenUsedBeforeIssuedAt; /// Whether the token's signature cannot be accepted /// ([StreamErrorCode.tokenSignatureInvalid]) — a configuration problem no /// token or wait fixes. - bool get isTokenSignatureInvalid => code == StreamErrorCode.tokenSignatureInvalid; + bool get isTokenSignatureInvalid => code == .tokenSignatureInvalid; /// Whether the API key cannot be accepted ([StreamErrorCode.apiKeyInvalid]). - bool get isApiKeyInvalid => code == StreamErrorCode.apiKeyInvalid; + bool get isApiKeyInvalid => code == .apiKeyInvalid; /// Whether the request was rate limited (HTTP 429). bool get isRateLimited => statusCode == 429; diff --git a/packages/stream_core/lib/src/errors/stream_api_error.g.dart b/packages/stream_core/lib/src/errors/stream_api_error.g.dart index 49135e26..3745f035 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.g.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.g.dart @@ -7,7 +7,7 @@ part of 'stream_api_error.dart'; // ************************************************************************** StreamApiError _$StreamApiErrorFromJson(Map json) => StreamApiError( - code: (json['code'] as num).toInt(), + code: json['code'] as StreamErrorCode, details: _detailsFromJson(json['details']), duration: json['duration'] as String, exceptionFields: (json['exception_fields'] as Map?)?.map( diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index bfafbc9d..1aa77cca 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -89,7 +89,7 @@ base class StreamApiException extends StreamException { }) : this( message: error.message, statusCode: error.statusCode, - code: StreamErrorCode(error.code), + code: error.code, moreInfo: error.moreInfo.isEmpty ? null : error.moreInfo, unrecoverable: error.unrecoverable ?? false, retryAfter: retryAfter, diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index b26afbdb..867ac842 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -8,7 +8,7 @@ StreamApiError _apiError({ String moreInfo = '', bool? unrecoverable, }) => StreamApiError( - code: code, + code: StreamErrorCode(code), details: const [], duration: '0ms', message: message, diff --git a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart index 653c4688..850f1d69 100644 --- a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart @@ -18,7 +18,7 @@ StreamApiError _apiError({ required int code, int statusCode = 401, }) => StreamApiError( - code: code, + code: StreamErrorCode(code), details: const [], duration: '0ms', message: 'error $code', diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 2fda9a0b..a9e17d7f 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -2,7 +2,7 @@ import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; StreamApiError _apiError(int code, {int statusCode = 401}) => StreamApiError( - code: code, + code: StreamErrorCode(code), details: const [], duration: '0ms', message: 'error $code', @@ -68,7 +68,7 @@ void main() { test('automatic reconnection is disabled when the server said retrying will not help', () { const unrecoverable = StreamApiError( - code: 17, + code: StreamErrorCode.notAllowed, details: [], duration: '0ms', message: 'not allowed', From eedb70489dfed45990e580c2390abf8d72b4c77a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:14:34 +0200 Subject: [PATCH 13/78] refactor(llc): put the code predicates on StreamErrorCode and adopt chat's json pattern The token and API-key predicates live once, on the code itself, with StreamApiException delegating; the payload extension keeps only the status-based rate-limit check. StreamErrorCode carries its own fromJson/toJson the way chat's extension types do, decoding via num so an integral double reads as its number. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_api_error.dart | 25 ++------------ .../lib/src/errors/stream_api_error.g.dart | 4 +-- .../lib/src/errors/stream_error_code.dart | 34 +++++++++++++++++++ .../lib/src/errors/stream_exception.dart | 18 +++++----- 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 0c034ba3..c766f120 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -87,30 +87,11 @@ List _detailsFromJson(Object? json) { return [for (final entry in json.whereType()) entry.toInt()]; } -/// Convenience predicates over the payload's [StreamApiError.code] and -/// [StreamApiError.statusCode]. +/// Convenience predicates over the payload's [StreamApiError.statusCode]. /// -/// Same semantics as the `StreamApiException` getters of the same names, for -/// code that holds the raw payload rather than the exception. +/// The code-based predicates live on [StreamErrorCode] itself — consider +/// `error.code.isTokenExpired` and its siblings. extension StreamApiErrorPredicates on StreamApiError { - /// Whether the token has expired ([StreamErrorCode.tokenExpired]). - /// - /// A fresh token fixes it. - bool get isTokenExpired => code == .tokenExpired; - - /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] - /// and [StreamErrorCode.tokenUsedBeforeIssuedAt]) — clock skew that waiting - /// fixes and a fresh token does not. - bool get isTokenNotYetValid => code == .tokenNotValidYet || code == .tokenUsedBeforeIssuedAt; - - /// Whether the token's signature cannot be accepted - /// ([StreamErrorCode.tokenSignatureInvalid]) — a configuration problem no - /// token or wait fixes. - bool get isTokenSignatureInvalid => code == .tokenSignatureInvalid; - - /// Whether the API key cannot be accepted ([StreamErrorCode.apiKeyInvalid]). - bool get isApiKeyInvalid => code == .apiKeyInvalid; - /// Whether the request was rate limited (HTTP 429). bool get isRateLimited => statusCode == 429; } diff --git a/packages/stream_core/lib/src/errors/stream_api_error.g.dart b/packages/stream_core/lib/src/errors/stream_api_error.g.dart index 3745f035..a0da6ef9 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.g.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.g.dart @@ -7,7 +7,7 @@ part of 'stream_api_error.dart'; // ************************************************************************** StreamApiError _$StreamApiErrorFromJson(Map json) => StreamApiError( - code: json['code'] as StreamErrorCode, + code: StreamErrorCode.fromJson(json['code'] as num), details: _detailsFromJson(json['details']), duration: json['duration'] as String, exceptionFields: (json['exception_fields'] as Map?)?.map( @@ -20,7 +20,7 @@ StreamApiError _$StreamApiErrorFromJson(Map json) => StreamApiE ); Map _$StreamApiErrorToJson(StreamApiError instance) => { - 'code': instance.code, + 'code': instance.code.toJson(), 'details': instance.details, 'duration': instance.duration, 'exception_fields': instance.exceptionFields, diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart index d0b53248..652d71ec 100644 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -9,6 +9,15 @@ /// A code identifies the *condition*; the HTTP status it arrives with can /// vary, so neither is derivable from the other. extension type const StreamErrorCode(int code) implements int { + /// Creates a [StreamErrorCode] from a JSON number. + /// + /// Accepts any [num] the way every int field does, so an integral double + /// reads as its number instead of failing the whole error. + factory StreamErrorCode.fromJson(num json) => StreamErrorCode(json.toInt()); + + /// This code as a JSON number. + int toJson() => this; + /// `-1` – An unexpected server-side failure. static const internalError = StreamErrorCode(-1); @@ -146,4 +155,29 @@ extension type const StreamErrorCode(int code) implements int { /// `112` – Feeds storage is unavailable. static const feedsStorageUnavailable = StreamErrorCode(112); + + /// Whether this code says the token has expired ([tokenExpired]). + /// + /// A fresh token fixes it. + bool get isTokenExpired => this == tokenExpired; + + /// Whether this code says the token is not valid yet ([tokenNotValidYet] + /// and [tokenUsedBeforeIssuedAt]). + /// + /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes + /// it, a fresh token minted by the same skewed clock does not. + bool get isTokenNotYetValid => this == tokenNotValidYet || this == tokenUsedBeforeIssuedAt; + + /// Whether this code says the token's signature cannot be accepted + /// ([tokenSignatureInvalid]). + /// + /// A configuration problem — signed with the wrong secret. Neither waiting + /// nor a fresh token from the same signer fixes it. + bool get isTokenSignatureInvalid => this == tokenSignatureInvalid; + + /// Whether this code says the API key cannot be accepted ([apiKeyInvalid]). + /// + /// The key is unknown, or the product it addresses is not enabled for the + /// app. A configuration problem no token fixes. + bool get isApiKeyInvalid => this == apiKeyInvalid; } diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 1aa77cca..aec84efc 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -138,31 +138,31 @@ base class StreamApiException extends StreamException { final StreamApiError? apiError; /// Whether the token this request carried has expired - /// ([StreamErrorCode.tokenExpired]). + /// ([StreamErrorCode.isTokenExpired]). /// /// A freshly issued token fixes it. The SDK refreshes expired tokens /// automatically, so this surfaces only when a refresh could not help. - bool get isTokenExpired => code == .tokenExpired; + bool get isTokenExpired => code?.isTokenExpired ?? false; - /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] - /// and [StreamErrorCode.tokenUsedBeforeIssuedAt]). + /// Whether the token is not valid yet ([StreamErrorCode.isTokenNotYetValid]). /// /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes /// it, a fresh token minted by the same skewed clock does not. - bool get isTokenNotYetValid => code == .tokenNotValidYet || code == .tokenUsedBeforeIssuedAt; + bool get isTokenNotYetValid => code?.isTokenNotYetValid ?? false; /// Whether the token's signature cannot be accepted - /// ([StreamErrorCode.tokenSignatureInvalid]). + /// ([StreamErrorCode.isTokenSignatureInvalid]). /// /// A configuration problem — signed with the wrong secret. Neither waiting /// nor a fresh token from the same signer fixes it. - bool get isTokenSignatureInvalid => code == .tokenSignatureInvalid; + bool get isTokenSignatureInvalid => code?.isTokenSignatureInvalid ?? false; - /// Whether the API key cannot be accepted ([StreamErrorCode.apiKeyInvalid]). + /// Whether the API key cannot be accepted + /// ([StreamErrorCode.isApiKeyInvalid]). /// /// The key is unknown, or the product it addresses is not enabled for the /// app. A configuration problem no token fixes. - bool get isApiKeyInvalid => code == .apiKeyInvalid; + bool get isApiKeyInvalid => code?.isApiKeyInvalid ?? false; /// Whether the request was rate limited (HTTP 429). /// From 1cb0f4aaf0963eca6c51326a560819243d334bfd Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:16:51 +0200 Subject: [PATCH 14/78] refactor(llc): follow chat's hand-written extension-type conventions Static fromJson/toJson wired through JsonKey the way message.dart does, and the code predicates in an extension rather than the type body. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_api_error.dart | 1 + .../lib/src/errors/stream_api_error.g.dart | 2 +- .../lib/src/errors/stream_error_code.dart | 32 +++++++++++-------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index c766f120..0e21896d 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -32,6 +32,7 @@ class StreamApiError extends Equatable { /// /// [StreamErrorCode] names the known values; a code without a named /// constant still carries its number. + @JsonKey(fromJson: StreamErrorCode.fromJson, toJson: StreamErrorCode.toJson) final StreamErrorCode code; /// Additional error detail codes providing more context. diff --git a/packages/stream_core/lib/src/errors/stream_api_error.g.dart b/packages/stream_core/lib/src/errors/stream_api_error.g.dart index a0da6ef9..e6c4dbe2 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.g.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.g.dart @@ -20,7 +20,7 @@ StreamApiError _$StreamApiErrorFromJson(Map json) => StreamApiE ); Map _$StreamApiErrorToJson(StreamApiError instance) => { - 'code': instance.code.toJson(), + 'code': StreamErrorCode.toJson(instance.code), 'details': instance.details, 'duration': instance.duration, 'exception_fields': instance.exceptionFields, diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart index 652d71ec..92f24040 100644 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -9,14 +9,14 @@ /// A code identifies the *condition*; the HTTP status it arrives with can /// vary, so neither is derivable from the other. extension type const StreamErrorCode(int code) implements int { - /// Creates a [StreamErrorCode] from a JSON number. + /// Create a new instance from a json number. /// /// Accepts any [num] the way every int field does, so an integral double /// reads as its number instead of failing the whole error. - factory StreamErrorCode.fromJson(num json) => StreamErrorCode(json.toInt()); + static StreamErrorCode fromJson(num code) => StreamErrorCode(code.toInt()); - /// This code as a JSON number. - int toJson() => this; + /// Serialize to json number. + static int toJson(StreamErrorCode code) => code; /// `-1` – An unexpected server-side failure. static const internalError = StreamErrorCode(-1); @@ -155,29 +155,35 @@ extension type const StreamErrorCode(int code) implements int { /// `112` – Feeds storage is unavailable. static const feedsStorageUnavailable = StreamErrorCode(112); +} - /// Whether this code says the token has expired ([tokenExpired]). +/// Convenience predicates grouping the codes that share a remedy. +extension StreamErrorCodePredicates on StreamErrorCode { + /// Whether this code says the token has expired + /// ([StreamErrorCode.tokenExpired]). /// /// A fresh token fixes it. - bool get isTokenExpired => this == tokenExpired; + bool get isTokenExpired => this == .tokenExpired; - /// Whether this code says the token is not valid yet ([tokenNotValidYet] - /// and [tokenUsedBeforeIssuedAt]). + /// Whether this code says the token is not valid yet + /// ([StreamErrorCode.tokenNotValidYet] and + /// [StreamErrorCode.tokenUsedBeforeIssuedAt]). /// /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes /// it, a fresh token minted by the same skewed clock does not. - bool get isTokenNotYetValid => this == tokenNotValidYet || this == tokenUsedBeforeIssuedAt; + bool get isTokenNotYetValid => this == .tokenNotValidYet || this == .tokenUsedBeforeIssuedAt; /// Whether this code says the token's signature cannot be accepted - /// ([tokenSignatureInvalid]). + /// ([StreamErrorCode.tokenSignatureInvalid]). /// /// A configuration problem — signed with the wrong secret. Neither waiting /// nor a fresh token from the same signer fixes it. - bool get isTokenSignatureInvalid => this == tokenSignatureInvalid; + bool get isTokenSignatureInvalid => this == .tokenSignatureInvalid; - /// Whether this code says the API key cannot be accepted ([apiKeyInvalid]). + /// Whether this code says the API key cannot be accepted + /// ([StreamErrorCode.apiKeyInvalid]). /// /// The key is unknown, or the product it addresses is not enabled for the /// app. A configuration problem no token fixes. - bool get isApiKeyInvalid => this == apiKeyInvalid; + bool get isApiKeyInvalid => this == .apiKeyInvalid; } From c5cdb0c9265032c9fa3b073acb46fa187bf9f9cf Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:17:18 +0200 Subject: [PATCH 15/78] docs(llc): repoint the exception's predicate references at the code constants Co-Authored-By: Claude Fable 5 --- packages/stream_core/lib/src/errors/stream_exception.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index aec84efc..06f6eb4f 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -138,27 +138,27 @@ base class StreamApiException extends StreamException { final StreamApiError? apiError; /// Whether the token this request carried has expired - /// ([StreamErrorCode.isTokenExpired]). + /// ([StreamErrorCode.tokenExpired]). /// /// A freshly issued token fixes it. The SDK refreshes expired tokens /// automatically, so this surfaces only when a refresh could not help. bool get isTokenExpired => code?.isTokenExpired ?? false; - /// Whether the token is not valid yet ([StreamErrorCode.isTokenNotYetValid]). + /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] and [StreamErrorCode.tokenUsedBeforeIssuedAt]). /// /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes /// it, a fresh token minted by the same skewed clock does not. bool get isTokenNotYetValid => code?.isTokenNotYetValid ?? false; /// Whether the token's signature cannot be accepted - /// ([StreamErrorCode.isTokenSignatureInvalid]). + /// ([StreamErrorCode.tokenSignatureInvalid]). /// /// A configuration problem — signed with the wrong secret. Neither waiting /// nor a fresh token from the same signer fixes it. bool get isTokenSignatureInvalid => code?.isTokenSignatureInvalid ?? false; /// Whether the API key cannot be accepted - /// ([StreamErrorCode.isApiKeyInvalid]). + /// ([StreamErrorCode.apiKeyInvalid]). /// /// The key is unknown, or the product it addresses is not enabled for the /// app. A configuration problem no token fixes. From 4466ff18f76a419243d763e636bca5b5efca33af Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:19:13 +0200 Subject: [PATCH 16/78] refactor(llc): drop the runtimeType ignores for Flutter's assert-gated pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toString names the exact runtime type in debug mode and a per-kind fallback in release mode, the way Flutter's objectRuntimeType does — the lint permits runtimeType inside asserts, so no ignore is needed. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_exception.dart | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 06f6eb4f..504a1ae8 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -49,16 +49,29 @@ sealed class StreamException extends Equatable implements Exception { List get props => [message, cause]; @override - String toString() { - // The runtime type is the point here: it names the category (or the - // product subclass) in logs and crash reports. - // ignore: no_runtimetype_tostring - final buffer = StringBuffer('$runtimeType: $message'); + String toString() => _toString('StreamException'); + + // Builds the log line, headed by the exact runtime type in debug mode and + // by [fallbackName] in release mode, where type names may be minified. + String _toString(String fallbackName) { + final buffer = StringBuffer('${_typeName(this, fallbackName)}: $message'); if (cause case final cause?) buffer.write('\n caused by: $cause'); return buffer.toString(); } } +// The pattern behind Flutter's `objectRuntimeType`: asserts run only in debug +// mode, so release builds pay nothing and print [fallbackName] instead of a +// possibly minified type name. +String _typeName(Object object, String fallbackName) { + var name = fallbackName; + assert(() { + name = object.runtimeType.toString(); + return true; + }()); + return name; +} + /// A request that reached a Stream server and was answered with an error. /// /// The server's verdict is final for this attempt: the request was received, @@ -144,7 +157,8 @@ base class StreamApiException extends StreamException { /// automatically, so this surfaces only when a refresh could not help. bool get isTokenExpired => code?.isTokenExpired ?? false; - /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] and [StreamErrorCode.tokenUsedBeforeIssuedAt]). + /// Whether the token is not valid yet ([StreamErrorCode.tokenNotValidYet] and + /// [StreamErrorCode.tokenUsedBeforeIssuedAt]). /// /// A clock-skew condition on the token's `nbf`/`iat` claims: waiting fixes /// it, a fresh token minted by the same skewed clock does not. @@ -175,8 +189,8 @@ base class StreamApiException extends StreamException { @override String toString() { final code = this.code?.toString() ?? 'none'; - // ignore: no_runtimetype_tostring - final buffer = StringBuffer('$runtimeType(code: $code, statusCode: $statusCode): $message'); + final name = _typeName(this, 'StreamApiException'); + final buffer = StringBuffer('$name(code: $code, statusCode: $statusCode): $message'); if (moreInfo case final moreInfo?) buffer.write('\n more info: $moreInfo'); if (cause case final cause?) buffer.write('\n caused by: $cause'); return buffer.toString(); @@ -218,6 +232,9 @@ base class StreamNetworkException extends StreamException { @override List get props => [...super.props, isCancelled, isTimeout, closeCode]; + + @override + String toString() => _toString('StreamNetworkException'); } /// Credentials that could not be produced or sent. @@ -234,6 +251,9 @@ base class StreamAuthenticationException extends StreamException { super.cause, super.stackTrace, }); + + @override + String toString() => _toString('StreamAuthenticationException'); } /// A failure inside the SDK itself. @@ -249,4 +269,7 @@ base class StreamClientException extends StreamException { super.cause, super.stackTrace, }); + + @override + String toString() => _toString('StreamClientException'); } From 8d7a513a745f4a5337ca021ed82d4b67e9d02410 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:23:08 +0200 Subject: [PATCH 17/78] refactor(llc): print every fact that changes what a caller does, and build fromApiError as a factory The api line now carries unrecoverable and retryAfter and drops the 'code: none' filler; a socket closure prints its close code. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_exception.dart | 48 +++++++++++++------ .../test/errors/stream_exception_test.dart | 28 ++++++++++- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 504a1ae8..bffb883a 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -94,22 +94,24 @@ base class StreamApiException extends StreamException { }); /// Creates a [StreamApiException] from the server's error payload. - StreamApiException.fromApiError( + factory StreamApiException.fromApiError( StreamApiError error, { Duration? retryAfter, Object? cause, StackTrace? stackTrace, - }) : this( - message: error.message, - statusCode: error.statusCode, - code: error.code, - moreInfo: error.moreInfo.isEmpty ? null : error.moreInfo, - unrecoverable: error.unrecoverable ?? false, - retryAfter: retryAfter, - apiError: error, - cause: cause, - stackTrace: stackTrace, - ); + }) { + return StreamApiException( + message: error.message, + statusCode: error.statusCode, + code: error.code, + moreInfo: error.moreInfo.isEmpty ? null : error.moreInfo, + unrecoverable: error.unrecoverable ?? false, + retryAfter: retryAfter, + apiError: error, + cause: cause, + stackTrace: stackTrace, + ); + } /// The HTTP status the server answered with. /// @@ -188,9 +190,15 @@ base class StreamApiException extends StreamException { @override String toString() { - final code = this.code?.toString() ?? 'none'; + final facts = [ + if (code case final code?) 'code: $code', + 'statusCode: $statusCode', + if (unrecoverable) 'unrecoverable', + if (retryAfter case final retryAfter?) 'retryAfter: ${retryAfter.inSeconds}s', + ]; + final name = _typeName(this, 'StreamApiException'); - final buffer = StringBuffer('$name(code: $code, statusCode: $statusCode): $message'); + final buffer = StringBuffer('$name(${facts.join(', ')}): $message'); if (moreInfo case final moreInfo?) buffer.write('\n more info: $moreInfo'); if (cause case final cause?) buffer.write('\n caused by: $cause'); return buffer.toString(); @@ -234,7 +242,17 @@ base class StreamNetworkException extends StreamException { List get props => [...super.props, isCancelled, isTimeout, closeCode]; @override - String toString() => _toString('StreamNetworkException'); + String toString() { + final name = _typeName(this, 'StreamNetworkException'); + final closure = switch (closeCode) { + final closeCode? => '(closeCode: $closeCode)', + _ => '', + }; + + final buffer = StringBuffer('$name$closure: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } } /// Credentials that could not be produced or sent. diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index 867ac842..0c293759 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -124,7 +124,23 @@ void main() { expect(exception.code, isNull); expect(exception.apiError, isNull); - expect(exception.toString(), contains('code: none')); + expect(exception.toString(), isNot(contains('code:'))); + expect(exception.toString(), contains('(statusCode: 504)')); + }); + + test('prints the facts that change what a caller does next', () { + const exception = StreamApiException( + message: 'Too many requests', + statusCode: 429, + code: StreamErrorCode.rateLimited, + unrecoverable: true, + retryAfter: Duration(seconds: 7), + ); + + final printed = exception.toString(); + + expect(printed, contains('unrecoverable')); + expect(printed, contains('retryAfter: 7s')); }); test('prints the facts a support ticket needs', () { @@ -150,6 +166,16 @@ void main() { expect(exception.closeCode, isNull); }); + test('prints the close code when the failure was a socket closure', () { + const closed = StreamNetworkException( + message: 'The connection was closed unexpectedly', + closeCode: CloseCode.abnormalClosure, + ); + + expect(closed.toString(), contains('(closeCode: 1006)')); + expect(const StreamNetworkException(message: 'offline').toString(), isNot(contains('closeCode'))); + }); + test('a different fact is a different failure', () { // `props` must see every field, or two failures that behave differently compare equal. expect( From 2daafb820a354cc889b90f379a79ee379b53e0ad Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:27:09 +0200 Subject: [PATCH 18/78] feat(llc): ship objectRuntimeType as a core utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Flutter's helper for pure-Dart code, with the runtimeType lint disabled in that one file — the sanctioned home for the pattern. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_exception.dart | 19 ++++-------------- packages/stream_core/lib/src/utils.dart | 1 + .../stream_core/lib/src/utils/object.dart | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 packages/stream_core/lib/src/utils/object.dart diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index bffb883a..371d2214 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; +import '../utils/object.dart'; import 'stream_api_error.dart'; import 'stream_error_code.dart'; @@ -54,24 +55,12 @@ sealed class StreamException extends Equatable implements Exception { // Builds the log line, headed by the exact runtime type in debug mode and // by [fallbackName] in release mode, where type names may be minified. String _toString(String fallbackName) { - final buffer = StringBuffer('${_typeName(this, fallbackName)}: $message'); + final buffer = StringBuffer('${objectRuntimeType(this, fallbackName)}: $message'); if (cause case final cause?) buffer.write('\n caused by: $cause'); return buffer.toString(); } } -// The pattern behind Flutter's `objectRuntimeType`: asserts run only in debug -// mode, so release builds pay nothing and print [fallbackName] instead of a -// possibly minified type name. -String _typeName(Object object, String fallbackName) { - var name = fallbackName; - assert(() { - name = object.runtimeType.toString(); - return true; - }()); - return name; -} - /// A request that reached a Stream server and was answered with an error. /// /// The server's verdict is final for this attempt: the request was received, @@ -197,7 +186,7 @@ base class StreamApiException extends StreamException { if (retryAfter case final retryAfter?) 'retryAfter: ${retryAfter.inSeconds}s', ]; - final name = _typeName(this, 'StreamApiException'); + final name = objectRuntimeType(this, 'StreamApiException'); final buffer = StringBuffer('$name(${facts.join(', ')}): $message'); if (moreInfo case final moreInfo?) buffer.write('\n more info: $moreInfo'); if (cause case final cause?) buffer.write('\n caused by: $cause'); @@ -243,7 +232,7 @@ base class StreamNetworkException extends StreamException { @override String toString() { - final name = _typeName(this, 'StreamNetworkException'); + final name = objectRuntimeType(this, 'StreamNetworkException'); final closure = switch (closeCode) { final closeCode? => '(closeCode: $closeCode)', _ => '', diff --git a/packages/stream_core/lib/src/utils.dart b/packages/stream_core/lib/src/utils.dart index eb10d51f..c7933448 100644 --- a/packages/stream_core/lib/src/utils.dart +++ b/packages/stream_core/lib/src/utils.dart @@ -6,6 +6,7 @@ export 'utils/in_flight_cache.dart'; export 'utils/lifecycle_state_provider.dart'; export 'utils/list_extensions.dart'; export 'utils/network_state_provider.dart'; +export 'utils/object.dart'; export 'utils/result.dart'; export 'utils/shared_emitter.dart'; export 'utils/standard.dart'; diff --git a/packages/stream_core/lib/src/utils/object.dart b/packages/stream_core/lib/src/utils/object.dart new file mode 100644 index 00000000..b7b5d77a --- /dev/null +++ b/packages/stream_core/lib/src/utils/object.dart @@ -0,0 +1,20 @@ +// The one sanctioned home for runtimeType-to-string: everywhere else the lint +// stands, and this helper is the alternative it pushes toward. +// ignore_for_file: no_runtimetype_tostring + +/// A [Object.runtimeType] that is constant in release mode. +/// +/// Returns the runtime type of [object] in debug mode, and [optimizedValue] +/// in release mode, where type names may be minified and reading them +/// prevents the compiler from discarding type information. +/// +/// Mirrors Flutter's `objectRuntimeType`, so `toString` implementations can +/// name their exact type where it helps — a debug log — and a stable name +/// where it would not. +String objectRuntimeType(Object? object, String optimizedValue) { + assert(() { + optimizedValue = object.runtimeType.toString(); + return true; + }()); + return optimizedValue; +} From 4b1f6c71cd3303bca748268ea2a455760ff8062c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:27:35 +0200 Subject: [PATCH 19/78] style(llc): adapt objectRuntimeType to this repo's stricter lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local instead of Flutter's parameter reassignment, and no file-level ignore — the analyzer confirms the assert-gated pattern never trips no_runtimetype_tostring. Co-Authored-By: Claude Fable 5 --- packages/stream_core/lib/src/utils/object.dart | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/lib/src/utils/object.dart b/packages/stream_core/lib/src/utils/object.dart index b7b5d77a..b9668ac2 100644 --- a/packages/stream_core/lib/src/utils/object.dart +++ b/packages/stream_core/lib/src/utils/object.dart @@ -1,7 +1,3 @@ -// The one sanctioned home for runtimeType-to-string: everywhere else the lint -// stands, and this helper is the alternative it pushes toward. -// ignore_for_file: no_runtimetype_tostring - /// A [Object.runtimeType] that is constant in release mode. /// /// Returns the runtime type of [object] in debug mode, and [optimizedValue] @@ -12,9 +8,10 @@ /// name their exact type where it helps — a debug log — and a stable name /// where it would not. String objectRuntimeType(Object? object, String optimizedValue) { + var value = optimizedValue; assert(() { - optimizedValue = object.runtimeType.toString(); + value = object.runtimeType.toString(); return true; }()); - return optimizedValue; + return value; } From c5425282b9db22410f249372d8da9c1138a5c760 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:28:50 +0200 Subject: [PATCH 20/78] refactor(llc): give every exception kind its own independent toString Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_exception.dart | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 371d2214..cb09a84c 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -50,12 +50,9 @@ sealed class StreamException extends Equatable implements Exception { List get props => [message, cause]; @override - String toString() => _toString('StreamException'); - - // Builds the log line, headed by the exact runtime type in debug mode and - // by [fallbackName] in release mode, where type names may be minified. - String _toString(String fallbackName) { - final buffer = StringBuffer('${objectRuntimeType(this, fallbackName)}: $message'); + String toString() { + final name = objectRuntimeType(this, 'StreamException'); + final buffer = StringBuffer('$name: $message'); if (cause case final cause?) buffer.write('\n caused by: $cause'); return buffer.toString(); } @@ -260,7 +257,12 @@ base class StreamAuthenticationException extends StreamException { }); @override - String toString() => _toString('StreamAuthenticationException'); + String toString() { + final name = objectRuntimeType(this, 'StreamAuthenticationException'); + final buffer = StringBuffer('$name: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } } /// A failure inside the SDK itself. @@ -278,5 +280,10 @@ base class StreamClientException extends StreamException { }); @override - String toString() => _toString('StreamClientException'); + String toString() { + final name = objectRuntimeType(this, 'StreamClientException'); + final buffer = StringBuffer('$name: $message'); + if (cause case final cause?) buffer.write('\n caused by: $cause'); + return buffer.toString(); + } } From 244cb0e981d13bf4cbcf71653e462cbc1ea6743d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:33:34 +0200 Subject: [PATCH 21/78] docs: state precisely who sets unrecoverable Video sets it deliberately; the shared permission-denied path can put it on a chat error too, so 'never' was too strong. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index a47f3546..0f2bf5b3 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -28,8 +28,8 @@ base class StreamApiException extends StreamException { final int code; // Stream's stable error code — branch on this, never on message final String? moreInfo; // docs URL; populated on REST errors, empty on WebSocket errors final bool unrecoverable; // when true, the server says retrying will not help — authoritative. - // Absence means nothing: today only Video endpoints set it; Chat and - // Feeds errors never carry it. + // Absence means nothing: only Video sets it deliberately (plus the + // shared permission-denied path); most errors never carry it. final Duration? retryAfter; // from the Retry-After header on HTTP 429; absent on WS rate limits bool get isTokenExpired; // code 40 — a fresh token fixes it From fe4422852cc619c9fe04297443161decee6b8665 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:34:20 +0200 Subject: [PATCH 22/78] docs: write the retry decision procedure into the error layer doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retryability as a function of the failure, the operation's idempotency, and the attempt budget — with the per-kind table and where the two already-implemented instances live. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 0f2bf5b3..2fbb2ca5 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -189,8 +189,35 @@ unrelated type. ## Retrying The exception carries **facts** (`statusCode`, `code`, `unrecoverable`, `retryAfter`, `isTimeout`, -`closeCode`); whether to retry is **policy** the caller owns. Honor `unrecoverable` first — it is -the server saying retrying will not help — then apply your own rules: +`closeCode`); whether to retry is **policy** the caller owns. Retryability is a function of three +inputs — what happened (the exception), what the caller was doing (idempotent or not), and how many +attempts have been spent — which is why no `isRetryable` lives on the exception: it only knows the +first input. + +The decision runs in order: + +1. **Honor the server's explicit verdicts.** `unrecoverable: true` → never retry. `retryAfter` → + retry, but only after that wait. +2. **Decide by kind and facts.** Retry what is about *the moment*; never what is about *the request + or the setup*: + + | Failure | Retry? | + |---|---| + | `StreamNetworkException(isCancelled: true)` | No — the caller stopped it. | + | `StreamNetworkException` otherwise | Yes for reads; writes only through an idempotent path. Prefer a connectivity signal over blind backoff. | + | `StreamApiException(isRateLimited: true)` | Yes, after `retryAfter` (backoff when absent). | + | `StreamApiException`, 5xx | Yes, with backoff. | + | `StreamApiException(isTokenExpired: true)` | No — the SDK already refreshed and retried once; seeing it means refresh could not help. | + | `StreamApiException(isTokenNotYetValid: true)` | Yes, after waiting — clock skew heals, bounded. | + | `StreamApiException`, any other 4xx | No — the same request gets the same verdict. | + | `StreamAuthenticationException` | No — fix credentials first, then re-attempt the operation. | + | `StreamClientException` | No — a bug does not heal on resend; report it. | + +3. **Apply the budget**: max attempts, exponential backoff with jitter, a delay cap. + +`DisconnectionSource.isReconnectable` is this procedure specialized for the connection (reconnecting +is inherently idempotent), and the interceptor's one-shot token refresh is the code-40 row. What +remains for callers is operation retry, expressed as a policy: ```dart abstract interface class RetryPolicy { From 0650fab4be2252c458a80561acaf950b42c12845 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:35:45 +0200 Subject: [PATCH 23/78] docs: correct the retry table against the backend's raise sites 408 (code 48) is a server-side processing timeout and retryable; code 40 also covers revoked tokens, which a fresh token equally fixes; a cooldown clears on its own but names no machine-readable wait. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 3 ++- packages/stream_core/lib/src/errors/stream_error_code.dart | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 2fbb2ca5..ac99e37f 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -209,7 +209,8 @@ The decision runs in order: | `StreamApiException`, 5xx | Yes, with backoff. | | `StreamApiException(isTokenExpired: true)` | No — the SDK already refreshed and retried once; seeing it means refresh could not help. | | `StreamApiException(isTokenNotYetValid: true)` | Yes, after waiting — clock skew heals, bounded. | - | `StreamApiException`, any other 4xx | No — the same request gets the same verdict. | + | `StreamApiException`, 408 (code 48) | Yes, with backoff — a server-side processing timeout, not a verdict on the request. | + | `StreamApiException`, any other 4xx | No — the same request gets the same verdict. (A channel cooldown, code 60, does clear on its own, but its wait is not machine-readable — surface it rather than auto-retry.) | | `StreamAuthenticationException` | No — fix credentials first, then re-attempt the operation. | | `StreamClientException` | No — a bug does not heal on resend; report it. | diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart index 92f24040..5587c16f 100644 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -59,7 +59,8 @@ extension type const StreamErrorCode(int code) implements int { /// `22` – The request payload is too big. static const payloadTooBig = StreamErrorCode(22); - /// `40` – The token has expired. A fresh token fixes it. + /// `40` – The token has expired, or has been revoked. Either way a fresh + /// token fixes it. static const tokenExpired = StreamErrorCode(40); /// `41` – The token is not valid yet (its `nbf` claim is in the future). From 4aef0037ef3a34117cf2a0fbf4673188c67284c2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:36:56 +0200 Subject: [PATCH 24/78] docs(repo): follow the vendored guide's rename in its references Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- EFFECTIVE_DART_DOCUMENTATION.md => EFFECTIVE_DART_DOC.md | 0 STYLE_GUIDE.md | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename EFFECTIVE_DART_DOCUMENTATION.md => EFFECTIVE_DART_DOC.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 0c1705fd..19ff8729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> **Before writing or reviewing code, read [`STYLE_GUIDE.md`](STYLE_GUIDE.md).** It is the source of truth for coding conventions, the barrel contract, theming, testing, and changelog policy. See [`TESTING.md`](TESTING.md) for guidance on writing effective tests, and [`EFFECTIVE_DART_DOCUMENTATION.md`](EFFECTIVE_DART_DOCUMENTATION.md) — a vendored copy of Effective Dart's documentation guide — before writing any dartdoc; the style guide wins where they disagree. This file is a repo overview; the style guide is the rulebook. +> **Before writing or reviewing code, read [`STYLE_GUIDE.md`](STYLE_GUIDE.md).** It is the source of truth for coding conventions, the barrel contract, theming, testing, and changelog policy. See [`TESTING.md`](TESTING.md) for guidance on writing effective tests, and [`EFFECTIVE_DART_DOC.md`](EFFECTIVE_DART_DOC.md) — a vendored copy of Effective Dart's documentation guide — before writing any dartdoc; the style guide wins where they disagree. This file is a repo overview; the style guide is the rulebook. ## Project Overview diff --git a/EFFECTIVE_DART_DOCUMENTATION.md b/EFFECTIVE_DART_DOC.md similarity index 100% rename from EFFECTIVE_DART_DOCUMENTATION.md rename to EFFECTIVE_DART_DOC.md diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 91e1664e..0ef6fccd 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -374,7 +374,7 @@ Public dartdocs are encouraged but currently **not lint-enforced** while the repo catches up). New public code should still ship with dartdocs. In general, follow the Effective Dart documentation guide — vendored in this repo as -[`EFFECTIVE_DART_DOCUMENTATION.md`](EFFECTIVE_DART_DOCUMENTATION.md) so it is readable offline +[`EFFECTIVE_DART_DOC.md`](EFFECTIVE_DART_DOC.md) so it is readable offline (canonical version at [dart.dev](https://dart.dev/effective-dart/documentation)) — except where this page contradicts it. Read it before writing or reviewing dartdoc: the rules most often missed are single-sentence first paragraphs, "Whether…" for booleans, noun phrases for From c8f20ab4ff122e4dd4c12af6110e0f8ea8982196 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:37:42 +0200 Subject: [PATCH 25/78] docs(llc): scope unrecoverable to Video, the one product that sets it deliberately Co-Authored-By: Claude Fable 5 --- packages/stream_core/lib/src/errors/stream_api_error.dart | 5 +++++ packages/stream_core/lib/src/errors/stream_exception.dart | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index 0e21896d..f34ecc3a 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -59,6 +59,11 @@ class StreamApiError extends Equatable { final int statusCode; /// Whether this error is unrecoverable and should not be retried. + /// + /// Only Video sets this as a deliberate retry signal, so it is only worth + /// consulting there. Absence means nothing anywhere: most errors never + /// carry it, and `null` or `false` must not be read as "retrying will + /// help". final bool? unrecoverable; Map toJson() => _$StreamApiErrorToJson(this); diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index cb09a84c..ed85e1a8 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -122,8 +122,9 @@ base class StreamApiException extends StreamException { /// Whether the server declared that retrying will not help. /// - /// Authoritative when `true`. `false` only means the server said nothing — - /// it must not be read as "retrying will help". + /// Only Video sets this as a deliberate retry signal, so it is only worth + /// consulting there. Authoritative when `true`; `false` only means the + /// server said nothing — it must not be read as "retrying will help". final bool unrecoverable; /// How long the server asked to wait before retrying, when it named a wait. From c409dd8fd8d98c401ee71f106b3d3a77428fad7d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:41:49 +0200 Subject: [PATCH 26/78] =?UTF-8?q?feat(llc):=20ship=20the=20retry=20helpers?= =?UTF-8?q?=20=E2=80=94=20isRetriable=20and=20RetryPolicy.standard()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fact-level judgment lives on the exception as isRetriable, documented as necessary but not sufficient; RetryPolicy.standard() composes it with an attempt budget. One test per row of the backend-verified table. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 7 +- packages/stream_core/CHANGELOG.md | 1 + packages/stream_core/lib/src/errors.dart | 1 + .../lib/src/errors/retry_policy.dart | 72 ++++++++++++++ .../test/errors/retry_policy_test.dart | 97 +++++++++++++++++++ 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 packages/stream_core/lib/src/errors/retry_policy.dart create mode 100644 packages/stream_core/test/errors/retry_policy_test.dart diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index ac99e37f..0a4657dd 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -226,8 +226,11 @@ abstract interface class RetryPolicy { } ``` -Core ships `RetryPolicy.standard()` — honors `unrecoverable`, waits `retryAfter` on rate limits, -exponential backoff with jitter on network failures — so most callers configure, not implement. +Core ships two pieces of this: `StreamException.isRetriable`, the fact-level judgment (steps 1–2's +error-only rows — necessary, but not sufficient, since it cannot know the operation's idempotency), +and `RetryPolicy.standard()`, which composes it with an attempt budget. The policy answers +*whether*; *when* comes from `retryAfter` where the server named a wait, and from the caller's +backoff otherwise. One honesty rule about retrying writes: a `StreamNetworkException` means the outcome is **unknown** — the server may have performed the operation. Retry a write only through an idempotent path diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index fdefd9ae..a69f892b 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -36,6 +36,7 @@ - Added `DioException.toStreamException()`, the failure a Dio error represents: a response — Stream error payload or bare status — reads as a `StreamApiException`, and anything that ended before a verdict as a `StreamNetworkException`, with cancellations and timeouts marked as such - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses - Added `StreamErrorCode`, the API's error-code registry as named constants over `int` — one shared vocabulary for every Stream product, tolerant of codes the SDK does not know yet. `StreamApiException.code` is typed with it +- Added `StreamException.isRetriable`, whether a failure is about the moment rather than the request — necessary but not sufficient, since re-sending safely also depends on the operation — and `RetryPolicy`, with `RetryPolicy.standard()` composing that judgment with an attempt budget - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` diff --git a/packages/stream_core/lib/src/errors.dart b/packages/stream_core/lib/src/errors.dart index 63a5dc0e..53bdfb04 100644 --- a/packages/stream_core/lib/src/errors.dart +++ b/packages/stream_core/lib/src/errors.dart @@ -1,3 +1,4 @@ +export 'errors/retry_policy.dart'; export 'errors/stream_api_error.dart'; export 'errors/stream_error_code.dart'; export 'errors/stream_exception.dart'; diff --git a/packages/stream_core/lib/src/errors/retry_policy.dart b/packages/stream_core/lib/src/errors/retry_policy.dart new file mode 100644 index 00000000..3615e819 --- /dev/null +++ b/packages/stream_core/lib/src/errors/retry_policy.dart @@ -0,0 +1,72 @@ +import 'stream_error_code.dart'; +import 'stream_exception.dart'; + +/// Decides whether a failed operation is worth attempting again. +/// +/// A retry decision has three inputs: what happened (the exception), what the +/// caller was doing, and how many attempts have been spent. The exception +/// knows only the first, which is why this is a policy the caller owns rather +/// than a property of the error. +/// +/// [RetryPolicy.standard] answers for the common case; products with their +/// own rules implement this interface. +abstract interface class RetryPolicy { + /// The default policy: retries what [StreamExceptionRetry.isRetriable] + /// allows, up to [StandardRetryPolicy.maxAttempts] attempts. + /// + /// Safe only for operations that can be re-sent without side effects — a + /// read, or a write carrying an idempotency key. A non-idempotent write + /// that fails without a verdict may already have been performed. + const factory RetryPolicy.standard({int maxAttempts}) = StandardRetryPolicy; + + /// Whether the operation that failed with [error] should be attempted + /// again. + /// + /// The [attempt] is the number of the attempt that just failed, starting + /// at 1. When the answer is yes, [StreamApiException.retryAfter] names the + /// wait when the server sent one. + bool shouldRetry(StreamException error, int attempt); +} + +/// The [RetryPolicy] used when a caller does not bring their own. +final class StandardRetryPolicy implements RetryPolicy { + /// Creates a [StandardRetryPolicy] allowing [maxAttempts] attempts. + const StandardRetryPolicy({this.maxAttempts = 3}); + + /// How many attempts an operation is given in total, the first included. + final int maxAttempts; + + @override + bool shouldRetry(StreamException error, int attempt) { + return attempt < maxAttempts && error.isRetriable; + } +} + +/// The retry judgment that can be made from a failure alone. +extension StreamExceptionRetry on StreamException { + /// Whether this failure is about the moment rather than about the request + /// or the setup, so a later attempt can end differently. + /// + /// True for rate limits, server-side faults and timeouts, tokens not valid + /// yet, and transport failures that were not cancelled. False for every + /// verdict a resend reproduces — validation, permissions, refused + /// signatures and keys — for credentials that never went out, and for + /// failures inside the SDK. + /// + /// Necessary, but not on its own sufficient: whether re-sending is *safe* + /// depends on the operation. A transport failure leaves the outcome + /// unknown, so a write is worth re-sending only through an idempotent + /// path. That knowledge is the caller's, which is why this getter feeds a + /// [RetryPolicy] rather than replacing one. + bool get isRetriable => switch (this) { + StreamApiException(unrecoverable: true) => false, + StreamApiException(isRateLimited: true) => true, + StreamApiException(isTokenNotYetValid: true) => true, + StreamApiException(code: StreamErrorCode.requestTimeout) => true, + StreamApiException(:final statusCode) => statusCode >= 500, + StreamNetworkException(isCancelled: true) => false, + StreamNetworkException() => true, + StreamAuthenticationException() => false, + StreamClientException() => false, + }; +} diff --git a/packages/stream_core/test/errors/retry_policy_test.dart b/packages/stream_core/test/errors/retry_policy_test.dart new file mode 100644 index 00000000..2ba182e8 --- /dev/null +++ b/packages/stream_core/test/errors/retry_policy_test.dart @@ -0,0 +1,97 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +StreamApiException _api( + int code, { + int statusCode = 400, + bool unrecoverable = false, +}) => StreamApiException( + message: 'error $code', + statusCode: statusCode, + code: StreamErrorCode(code), + unrecoverable: unrecoverable, +); + +void main() { + group('isRetriable', () { + test('never retries what the server declared unrecoverable', () { + // A 500 would otherwise retry; the server's own verdict overrides it. + expect(_api(17, statusCode: 500, unrecoverable: true).isRetriable, isFalse); + }); + + test('retries a rate limit, which clears on its own', () { + expect(_api(9, statusCode: 429).isRetriable, isTrue); + }); + + test('retries a token that is not valid yet, since waiting is the fix', () { + for (final code in [41, 42]) { + expect(_api(code, statusCode: 401).isRetriable, isTrue, reason: 'code $code'); + } + }); + + test('retries a server-side request timeout, which is not a verdict on the request', () { + expect(_api(48, statusCode: 408).isRetriable, isTrue); + }); + + test('retries a server-side failure', () { + expect(_api(-1, statusCode: 500).isRetriable, isTrue); + expect(_api(112, statusCode: 503).isRetriable, isTrue); + }); + + test('does not retry an expired token, since the refresh already happened', () { + // The SDK refreshes and retries code 40 once on its own; one that still + // surfaced means a fresh token could not help. + expect(_api(40, statusCode: 401).isRetriable, isFalse); + }); + + test('does not retry a verdict a resend reproduces', () { + expect(_api(4).isRetriable, isFalse, reason: 'validation'); + expect(_api(17, statusCode: 403).isRetriable, isFalse, reason: 'permission'); + expect(_api(43, statusCode: 401).isRetriable, isFalse, reason: 'signature'); + expect(_api(2, statusCode: 401).isRetriable, isFalse, reason: 'api key'); + }); + + test('does not retry a request the caller cancelled', () { + const cancelled = StreamNetworkException(message: 'cancelled', isCancelled: true); + + expect(cancelled.isRetriable, isFalse); + }); + + test('retries a transport failure, whose outcome a later attempt can settle', () { + const timeout = StreamNetworkException(message: 'timed out', isTimeout: true); + const dropped = StreamNetworkException(message: 'gone', closeCode: CloseCode.abnormalClosure); + + expect(timeout.isRetriable, isTrue); + expect(dropped.isRetriable, isTrue); + }); + + test('does not retry credentials that never went out', () { + const auth = StreamAuthenticationException(message: 'no token'); + + expect(auth.isRetriable, isFalse); + }); + + test('does not retry a failure inside the SDK', () { + const client = StreamClientException(message: 'undecodable'); + + expect(client.isRetriable, isFalse); + }); + }); + + group('RetryPolicy.standard', () { + test('retries a retriable failure until the attempts run out', () { + const policy = RetryPolicy.standard(); + final rateLimited = _api(9, statusCode: 429); + + expect(policy.shouldRetry(rateLimited, 1), isTrue); + expect(policy.shouldRetry(rateLimited, 2), isTrue); + expect(policy.shouldRetry(rateLimited, 3), isFalse); + }); + + test('never retries a verdict, however many attempts remain', () { + const policy = RetryPolicy.standard(); + + expect(policy.shouldRetry(_api(17, statusCode: 403), 1), isFalse); + }); + }); +} From deeb26dc447c5b8c2a1f2c149c87dae07db39370 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 14:55:46 +0200 Subject: [PATCH 27/78] refactor(llc): route the provider load through Result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runSafely guards the app-supplied provider the way the WS authenticator already does, so whatever the token code threw — Error included — arrives as a StreamAuthenticationException with the cause preserved, consistent across all three auth boundaries. Co-Authored-By: Claude Fable 5 --- packages/stream_core/lib/src/user/token_manager.dart | 11 ++++++----- .../src/ws/client/web_socket_connection_state.dart | 2 +- .../test/api/interceptors/auth_interceptor_test.dart | 6 +++--- .../stream_core/test/user/token_manager_test.dart | 9 +++++++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 77e2cb0c..c3cb55af 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,5 +1,6 @@ import '../errors/stream_exception.dart'; import '../utils/in_flight_cache.dart'; +import '../utils/result.dart'; import 'token_provider.dart'; import 'user_token.dart'; @@ -198,15 +199,15 @@ class TokenManager { } Future _loadFrom(TokenProvider provider, String userId) async { - try { - return await provider.loadToken(userId); - } on Exception catch (e, stackTrace) { + final result = await runSafely(() => provider.loadToken(userId)); + + return result.getOrElse((error, stackTrace) { throw StreamAuthenticationException( message: 'The token provider failed to load a token for user "$userId"', - cause: e, + cause: error, stackTrace: stackTrace, ); - } + }); } /// Expires the currently cached token. diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 8296f55b..20b98cb9 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -300,7 +300,6 @@ sealed class DisconnectionSource extends Equatable { UnHealthyConnection() => true, ConnectTimeout() => true, ServerInitiated(:final error) => switch (error) { - null => true, StreamApiException(unrecoverable: true) => false, StreamApiException(isTokenSignatureInvalid: true) => false, StreamApiException(isApiKeyInvalid: true) => false, @@ -312,6 +311,7 @@ sealed class DisconnectionSource extends Equatable { StreamNetworkException() => true, StreamAuthenticationException() => false, StreamClientException() => true, + _ => true, }, }; diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 972d91dc..ebfa94f9 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -230,9 +230,9 @@ void main() { isA().having( (it) => it.error, 'error', - isA() - .having((it) => it.message, 'message', 'Failed to load an auth token') - .having((it) => it.cause, 'cause', isStateError), + // The token manager reports the load failure itself; the + // interceptor passes its report through untouched. + isA().having((it) => it.cause, 'cause', isStateError), ), ), ); diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 2097be3c..f1db66b5 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -177,7 +177,7 @@ void main() { // A provider refusing one caller refuses all five, so asking it five times over would // hammer a token endpoint that has already said no. for (final future in futures) { - await expectLater(future, throwsStateError); + await expectLater(future, throwsA(isA())); } expect(provider.loadCount, 1); @@ -212,7 +212,12 @@ void main() { }); final manager = TokenManager(userId: 'user-1', tokenProvider: provider); - await expectLater(manager.getToken(), throwsStateError); + // Whatever the provider threw arrives as an authentication failure, + // with the original error preserved as its cause. + await expectLater( + manager.getToken(), + throwsA(isA().having((it) => it.cause, 'cause', isStateError)), + ); expect(manager.peekToken(), isNull); final token = await manager.getToken(); From 60a7f63328f7d47a01f5044178a1900768d410cb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 15:00:05 +0200 Subject: [PATCH 28/78] docs: teach the errors-vs-exceptions split where contributors look A style-guide section on which to raise when and what the suffixes signal, a quick-rules pointer, and the naming line in the error layer contract. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 4 ++++ STYLE_GUIDE.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 0a4657dd..ca4978c9 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -137,6 +137,10 @@ Stream. disposed client, passing another user's token — throws Dart's own `StateError`/`ArgumentError`. Those mean *fix your code*, not *handle at runtime*, and they never appear inside a `Result`. +The naming follows the same line, and it is a signal to the catcher: a `…Exception` is a condition +that catching is the right response to; the `Error` suffix is reserved for Dart's bug hierarchy and +for non-throwable data models (`StreamApiError` is the server's wire payload, not a throwable). + ## For SDK developers: you rarely construct one Only **boundaries** create `StreamException`s. Everything above a boundary propagates `Result`s that diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 0ef6fccd..d5d54415 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -71,6 +71,9 @@ document; the section link is provided. - File names are `snake_case.dart` (`file_names`). Imports follow the standard order: `dart:` → `package:` → relative — one blank line between groups (`directives_ordering`). +- Misuse throws `StateError`/`ArgumentError`; runtime conditions raise a + `StreamException` kind, and throwable names end in `…Exception`. → + [Errors vs exceptions](#errors-vs-exceptions) **Design system** @@ -571,6 +574,44 @@ assert(size > 0); assert(!_disposed, 'StreamAvatarController used after dispose()'); ``` +### Errors vs exceptions + +Dart splits the two words by who is at fault and what should happen next, and this +repo follows the split strictly: + +- An **`Exception`** is a runtime condition a correct program can encounter — the + network dropped, the server said no, a token expired. Exceptions are part of the + API contract: callers are expected to catch and handle them. +- An **`Error`** is a programmer mistake — `StateError`, `ArgumentError`, + `TypeError`. Errors are meant to fail fast and loud, not be caught: handling one + papers over a bug. + +When raising a failure, ask one question: *can this happen to a correct program at +runtime?* + +| Answer | Raise | Examples | +|---|---|---| +| No — the caller misused the API | `StateError` / `ArgumentError`, never wrapped, never inside a `Result` | `connect()` on a disposed client, a token issued for another user | +| Yes — it is a condition to handle | the fitting `StreamException` kind | a refused request, a dropped socket, a failed token load | + +Which of the four `StreamException` kinds fits — and which layer produces which — is +the subject of [`ERROR_LAYER.md`](ERROR_LAYER.md); the three-question tree there +gives every failure exactly one home. In `stream_core_flutter`, prefer catching the +exception kinds over `StreamException` itself so the reaction can differ per kind. + +Naming follows the same line: public throwable types end in `…Exception`; the +`Error` suffix is reserved for Dart's bug hierarchy and for non-throwable data +models (`StreamApiError` is the server's wire payload, not a throwable). "Error" +remains fine as a domain word in prose, fields, and codes (`StreamErrorCode`, +`errorBuilder`). + +Two seams deliberately cross the don't-catch-`Error` line, each with a stated +reason: decoding wire data catches everything, because a `TypeError` there indicts +the data rather than the program; and the auth boundaries catch everything thrown +by app-supplied token code, because a rejection must always deliver a +`StreamException` (the original error stays visible in `cause`). Everywhere else, +an `Error` propagates to the crash reporter where it belongs. + ### Prefer specialized functions, methods, and constructors Use the most relevant constructor when there are multiple options. From b5c3c84d341dd9988368ab0c7cf9937b3e207e87 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 15:08:11 +0200 Subject: [PATCH 29/78] fix(llc)!: raise conditions as exceptions everywhere the audit found errors A provider returning another user's token, a send racing a dropped connection, and an abandoned attempt's credentials are runtime conditions, so they arrive as StreamException kinds rather than ArgumentError/StateError. AttachmentUploadException is removed: upload reports its own failure unwrapped and uploadBatch pairs each outcome with its attachment id. Follows the renamed stream_core_dio_exception file through its references. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 2 +- STYLE_GUIDE.md | 2 +- packages/stream_core/CHANGELOG.md | 2 + packages/stream_core/lib/src/api.dart | 2 +- .../interceptors/api_error_interceptor.dart | 2 +- .../api/interceptors/auth_interceptor.dart | 2 +- ...or.dart => stream_core_dio_exception.dart} | 0 .../uploader/attachment_uploader.dart | 82 +++++++------------ .../lib/src/user/token_manager.dart | 12 ++- .../engine/stream_web_socket_engine.dart | 5 +- .../web_socket_authentication_handler.dart | 8 +- ...rt => stream_core_dio_exception_test.dart} | 0 .../test/user/token_manager_test.dart | 2 +- 13 files changed, 54 insertions(+), 67 deletions(-) rename packages/stream_core/lib/src/api/{stream_core_dio_error.dart => stream_core_dio_exception.dart} (100%) rename packages/stream_core/test/api/{stream_core_dio_error_test.dart => stream_core_dio_exception_test.dart} (100%) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index ca4978c9..de0879a4 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -134,7 +134,7 @@ branch, `error.message` is always displayable and `on StreamException` always ca Stream. **Bugs are not in this hierarchy.** Misusing the SDK — calling `send()` before `connect()`, using a -disposed client, passing another user's token — throws Dart's own `StateError`/`ArgumentError`. +disposed client — throws Dart's own `StateError`/`ArgumentError`. Those mean *fix your code*, not *handle at runtime*, and they never appear inside a `Result`. The naming follows the same line, and it is a signal to the catcher: a `…Exception` is a condition diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index d5d54415..e7a764a4 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -591,7 +591,7 @@ runtime?* | Answer | Raise | Examples | |---|---|---| -| No — the caller misused the API | `StateError` / `ArgumentError`, never wrapped, never inside a `Result` | `connect()` on a disposed client, a token issued for another user | +| No — the caller misused the API | `StateError` / `ArgumentError`, never wrapped, never inside a `Result` | `connect()` on a disposed client, a negative replay count | | Yes — it is a condition to handle | the fitting `StreamException` kind | a refused request, a dropped socket, a failed token load | Which of the four `StreamException` kinds fits — and which layer produces which — is diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index a69f892b..dd33967a 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,6 +15,8 @@ - `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` rather than an engine exception and `Object?`, so a `Disconnected` state carries the same error kinds a failed request does - `TokenManager.getToken` fails with a `StreamAuthenticationException` — when no identity is configured, when `reset` races a load, and when the `TokenProvider` fails, in which case whatever it threw is preserved as the exception's `cause` - Replaced the `StreamApiError` predicates: `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes), and `isRateLimited` reads the HTTP status. The same predicates exist on `StreamApiException` +- Removed `AttachmentUploadException`: `StreamAttachmentUploader.upload` reports the upload's own failure unwrapped, and `uploadBatch` emits `(attachmentId, result)` records so which attachment failed travels beside the outcome rather than inside it +- A send on a connection that is not open, and credentials whose connection attempt was abandoned, fail with a `StreamNetworkException` inside the `Result` rather than a `StateError` — a correct caller can race a dropped connection. A token provider that fails, or returns a token for another user, fails `getToken` with a `StreamAuthenticationException` rather than a raw error - `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 for one not valid yet, and stays `false` for a refused signature or API key, any other 4xx, and whenever the server marked the error `unrecoverable` - `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 widened = intResult`) or use `fold` diff --git a/packages/stream_core/lib/src/api.dart b/packages/stream_core/lib/src/api.dart index 8e26ae17..2754140a 100644 --- a/packages/stream_core/lib/src/api.dart +++ b/packages/stream_core/lib/src/api.dart @@ -4,7 +4,7 @@ export 'api/interceptors/auth_interceptor.dart'; export 'api/interceptors/connection_id_interceptor.dart'; export 'api/interceptors/headers_interceptor.dart'; export 'api/interceptors/logging_interceptor.dart'; -export 'api/stream_core_dio_error.dart'; +export 'api/stream_core_dio_exception.dart'; export 'api/stream_core_http_client.dart'; export 'api/stream_datetime_converter.dart'; export 'api/system_environment.dart'; diff --git a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart index a0037362..fade32d3 100644 --- a/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart @@ -1,6 +1,6 @@ import 'package:dio/dio.dart'; -import '../stream_core_dio_error.dart'; +import '../stream_core_dio_exception.dart'; /// Interceptor that maps every failed request onto a [StreamDioException] /// carrying the `StreamException` it represents. diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index af33d734..da24ee15 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -3,7 +3,7 @@ import 'package:dio/dio.dart'; import '../../errors.dart'; import '../../logger.dart'; import '../../user.dart'; -import '../stream_core_dio_error.dart'; +import '../stream_core_dio_exception.dart'; /// Interceptor that signs every request with the caller's token. /// diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart similarity index 100% rename from packages/stream_core/lib/src/api/stream_core_dio_error.dart rename to packages/stream_core/lib/src/api/stream_core_dio_exception.dart diff --git a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart index dc4724e9..8f8a994c 100644 --- a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart +++ b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart @@ -13,26 +13,14 @@ import 'uploaded_attachment.dart'; /// Receives the upload [progress] as a value between 0.0 and 1.0. typedef OnUploadProgress = void Function(double progress); -/// Exception thrown when an attachment upload fails. +/// The outcome of one attachment's upload within a batch, paired with the +/// attachment it belongs to. /// -/// Provides context about which specific attachment failed and the underlying -/// cause for debugging upload issues. -class AttachmentUploadException implements Exception { - /// Creates an [AttachmentUploadException] with the specified [id] and [cause]. - const AttachmentUploadException({ - required this.id, - required this.cause, - }); - - /// The ID of the attachment that failed to upload. - final String id; - - /// The underlying cause of the upload failure. - final Object cause; - - @override - String toString() => 'AttachmentUploadException(id: $id, cause: $cause)'; -} +/// The failure inside the result is the upload's own error, unwrapped — an +/// upload refused by the server reads as the same exception kind a refused +/// request does. Which attachment it concerns travels here, beside the +/// outcome, rather than inside it. +typedef AttachmentUploadResult = ({String attachmentId, Result result}); /// Uploads [StreamAttachment] objects to remote storage. /// @@ -61,8 +49,8 @@ class StreamAttachmentUploader { /// Uploads a single attachment to remote storage. /// - /// Returns a [Result] containing the [UploadedAttachment] on success or - /// an [AttachmentUploadException] on failure. Progress updates are provided + /// Returns a [Result] containing the [UploadedAttachment] on success, or + /// the upload's own failure otherwise. Progress updates are provided /// through the optional [onProgress] callback. Future> upload( StreamAttachment attachment, { @@ -84,26 +72,14 @@ class StreamAttachmentUploader { ), ); - return result.fold( - onSuccess: (data) { - final uploaded = UploadedAttachment( - id: attachment.id, - type: attachment.type, - custom: attachment.custom, - remoteUrl: data.fileUrl, - thumbnailUrl: data.thumbUrl, - ); - - return Result.success(uploaded); - }, - onFailure: (cause, stackTrace) { - final ex = AttachmentUploadException( - id: attachment.id, - cause: cause, - ); - - return Result.failure(ex, stackTrace); - }, + return result.map( + (data) => UploadedAttachment( + id: attachment.id, + type: attachment.type, + custom: attachment.custom, + remoteUrl: data.fileUrl, + thumbnailUrl: data.thumbUrl, + ), ); } } @@ -120,18 +96,18 @@ typedef OnBatchUploadProgress = void Function(String attachmentId, double progre /// as individual uploads complete, enabling immediate UI updates and partial /// success handling. extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { - /// Uploads multiple attachments as a stream of results. + /// Uploads multiple attachments as a stream of per-attachment outcomes. /// /// Processes [attachments] concurrently with [maxConcurrent] limit, emitting - /// [Result] objects as each upload completes. Progress updates are provided - /// through the optional [onProgress] callback. + /// an [AttachmentUploadResult] as each upload completes. Progress updates + /// are provided through the optional [onProgress] callback. /// /// When [eagerError] is true, the stream throws an exception and closes /// immediately on the first upload failure. When false (default), failed - /// uploads are emitted as [Result.failure] and processing continues. + /// uploads are emitted as failures and processing continues. /// - /// Returns a [Stream] of [Result] objects in completion order, not input order. - Stream> uploadBatch( + /// Returns a [Stream] of outcomes in completion order, not input order. + Stream uploadBatch( Iterable attachments, { OnBatchUploadProgress? onProgress, int maxConcurrent = 5, @@ -150,19 +126,19 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { (f) => (progress) => f(attachment.id, progress), ), - ), + ).then((result) => (attachmentId: attachment.id, result: result)), ), ); - // Yield results as they complete - await for (final result in uploadStream) { + // Yield outcomes as they complete + await for (final outcome in uploadStream) { // If eagerError is enabled, throw on first failure - if (result.exceptionOrNull() case final error? when eagerError) { - final stackTrace = result.stackTraceOrNull(); + if (outcome.result.exceptionOrNull() case final error? when eagerError) { + final stackTrace = outcome.result.stackTraceOrNull(); Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); } - yield result; + yield outcome; } } } diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index c3cb55af..386235da 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -144,9 +144,9 @@ class TokenManager { /// identity that replaced it. /// /// Fails with a [StreamAuthenticationException] when no identity is configured, when [reset] runs - /// while the token is loading, or when the [TokenProvider] fails — whatever the provider threw is - /// preserved as the exception's [StreamException.cause]. Fails with an [ArgumentError] when the - /// provider returns a token that does not belong to the user it was loading for. + /// while the token is loading, and when the [TokenProvider] fails — whatever the provider threw is + /// preserved as the exception's [StreamException.cause] — or returns a token that does not belong + /// to the user it was loading for. Future getToken() async { final cached = peekToken(); if (cached != null && !_isSpent(cached)) return cached; @@ -177,7 +177,11 @@ class TokenManager { // Both built-in providers check this, but a custom one need not: caching another user's token // would authenticate every later request as them. if (updatedToken.userId != loadingFor) { - throw ArgumentError('User ID mismatch: expected "$loadingFor", got "${updatedToken.userId}"'); + throw StreamAuthenticationException( + message: + 'The token provider returned a token for user "${updatedToken.userId}" ' + 'while loading one for user "$loadingFor"', + ); } // `setTokenProvider` or `expireToken` may have run while this loaded, in which case the token diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index 74bb429f..0101f7a4 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../../errors.dart' show StreamNetworkException; import '../../../logger.dart'; import '../../../utils.dart'; import 'web_socket_engine.dart'; @@ -130,7 +131,9 @@ class StreamWebSocketEngine implements WebSocketEngine { return runSafelySync(() { final ws = _ws; if (ws == null) { - throw StateError('WebSocket is not open. Call open() first.'); + // A condition, not misuse: a correct caller can race a connection + // that dropped between deciding to send and sending. + throw const StreamNetworkException(message: 'The connection is not open, so nothing was sent'); } final data = _messageCodec.encode(message); diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 3ff4965f..76267c27 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -1,4 +1,4 @@ -import '../../errors.dart' show StreamApiException; +import '../../errors.dart' show StreamApiException, StreamNetworkException; import '../../logger.dart'; import '../../utils.dart'; import '../events/ws_request.dart'; @@ -112,7 +112,9 @@ class WebSocketAuthenticationHandler { WsRequestSender _senderFor(int attempt) => (request) { if (attempt == _attempt) return _send(request); - final error = StateError('Connection attempt was abandoned before its credentials were sent'); - return Result.failure(error); + const error = StreamNetworkException( + message: 'The connection attempt was abandoned before its credentials were sent', + ); + return const Result.failure(error); }; } diff --git a/packages/stream_core/test/api/stream_core_dio_error_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart similarity index 100% rename from packages/stream_core/test/api/stream_core_dio_error_test.dart rename to packages/stream_core/test/api/stream_core_dio_exception_test.dart diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index f1db66b5..64b88f18 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -453,7 +453,7 @@ void main() { tokenProvider: _CountingProvider((_) async => generateTestUserToken('someone-else')), ); - await expectLater(manager.getToken(), throwsArgumentError); + await expectLater(manager.getToken(), throwsA(isA())); expect(manager.peekToken(), isNull); }); }); From aa727962e025fc097fdacb932db10b2e823929df Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 15:16:35 +0200 Subject: [PATCH 30/78] fix(llc): let Dio supply the missing stack trace Dio falls back to the stack captured at the request's call site, which the eager StackTrace.current here was shadowing with interceptor frames. Co-Authored-By: Claude Fable 5 --- .../lib/src/api/stream_core_dio_exception.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart index 84b18d6d..c5d9000d 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart @@ -11,17 +11,18 @@ import '../utils/standard.dart'; /// rides in [exception] until the call layer unwraps it. class StreamDioException extends DioException { /// Creates a [StreamDioException] carrying [exception]. + /// + /// A null [stackTrace] is left for Dio to fill in, which substitutes the + /// stack captured where the request was made — more useful than one + /// captured here. StreamDioException({ required this.exception, required super.requestOptions, super.response, super.type, - StackTrace? stackTrace, + super.stackTrace, super.message, - }) : super( - error: exception, - stackTrace: stackTrace ?? StackTrace.current, - ); + }) : super(error: exception); /// The Stream exception this Dio exception delivers. final StreamException exception; From ed35176c46f80220a763c8529f0f72256a482563 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 15:17:45 +0200 Subject: [PATCH 31/78] fix(llc): keep the classification of a Stream exception Dio wrapped An exception thrown loose inside the interceptor chain arrives in a plain DioException; the mapper now recovers it instead of re-diagnosing an authentication failure as a network one. Co-Authored-By: Claude Fable 5 --- .../lib/src/api/stream_core_dio_exception.dart | 5 +++++ .../test/api/stream_core_dio_exception_test.dart | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart index c5d9000d..b5729268 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart @@ -39,6 +39,11 @@ extension DioExceptionMapping on DioException { StreamException toStreamException() { if (this case StreamDioException(:final exception)) return exception; + // A Stream exception thrown loose inside the interceptor chain arrives + // wrapped in a plain DioException; its classification is kept rather + // than re-diagnosed from a wrapper that has no response to read. + if (error case final StreamException exception) return exception; + if (type == DioExceptionType.cancel) { return StreamNetworkException( message: 'The request was cancelled', diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart index 012be395..3961d952 100644 --- a/packages/stream_core/test/api/stream_core_dio_exception_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -161,5 +161,18 @@ void main() { expect(dioException.toStreamException(), same(mapped)); }); + + test('keeps the classification of a Stream exception thrown loose in the chain', () { + // An exception thrown inside an interceptor arrives wrapped in a plain + // DioException; re-diagnosing it from the wrapper would read an + // authentication failure as a network one. + const loose = StreamAuthenticationException(message: 'no token'); + final wrapped = DioException( + requestOptions: RequestOptions(path: '/test'), + error: loose, + ); + + expect(wrapped.toStreamException(), same(loose)); + }); }); } From d720cf8190b3a324c660721c0f494945b6f6a1ca Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 15:22:37 +0200 Subject: [PATCH 32/78] feat(llc): add runApiSafely, the call seam that delivers StreamExceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now nothing unwrapped StreamDioException into the Result channel, so a failed call surfaced the Dio shuttle itself. Every failure crossing this seam is a StreamException: transport failures mapped, Stream exceptions kept, and anything else — an undecodable response included — wrapped with its cause. Co-Authored-By: Claude Fable 5 --- packages/stream_core/CHANGELOG.md | 1 + .../src/api/stream_core_dio_exception.dart | 38 +++++++++++++++++- .../api/stream_core_dio_exception_test.dart | 40 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index dd33967a..71cbc94a 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -39,6 +39,7 @@ - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses - Added `StreamErrorCode`, the API's error-code registry as named constants over `int` — one shared vocabulary for every Stream product, tolerant of codes the SDK does not know yet. `StreamApiException.code` is typed with it - Added `StreamException.isRetriable`, whether a failure is about the moment rather than the request — necessary but not sufficient, since re-sending safely also depends on the operation — and `RetryPolicy`, with `RetryPolicy.standard()` composing that judgment with an attempt budget +- Added `runApiSafely`, the seam an API call crosses on its way to a caller: every failure it reports is a `StreamException` — transport failures mapped, a response that would not decode included - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` diff --git a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart index b5729268..d2637d71 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart @@ -3,12 +3,13 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import '../errors.dart'; +import '../utils/result.dart'; import '../utils/standard.dart'; /// A [DioException] carrying the [StreamException] that caused it. /// /// Dio requires rejections to be [DioException]s, so the mapped exception -/// rides in [exception] until the call layer unwraps it. +/// rides in [exception] until [runApiSafely] unwraps it at the call seam. class StreamDioException extends DioException { /// Creates a [StreamDioException] carrying [exception]. /// @@ -114,3 +115,38 @@ Duration? _parseRetryAfter(Response response) { if (seconds == null || seconds < 0) return null; return Duration(seconds: seconds); } + +/// Runs an API [call] and returns its outcome, every failure a +/// [StreamException]. +/// +/// The seam an API call crosses on its way to a caller: transport failures +/// are unwrapped or mapped through [DioExceptionMapping.toStreamException], +/// and anything else the call throws — a response body that would not decode +/// included — becomes a [StreamClientException] with the original error +/// preserved as its cause. +/// +/// ```dart +/// Future> queryChannel(String cid) { +/// return runApiSafely(() async { +/// final response = await _client.get('/channels/$cid'); +/// return Channel.fromJson(response.data); +/// }); +/// } +/// ``` +Future> runApiSafely(Future Function() call) async { + try { + return Result.success(await call()); + } on DioException catch (e, stackTrace) { + return Result.failure(e.toStreamException(), stackTrace); + } on StreamException catch (e, stackTrace) { + return Result.failure(e, stackTrace); + } catch (e, stackTrace) { + // An interpretation seam: the call closure decodes wire data, where a + // thrown `TypeError` indicts the response rather than the program — a + // server that renamed a field must surface as a handleable failure. + return Result.failure( + StreamClientException(message: 'The API call failed unexpectedly', cause: e, stackTrace: stackTrace), + stackTrace, + ); + } +} diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart index 3961d952..30f635c1 100644 --- a/packages/stream_core/test/api/stream_core_dio_exception_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -175,4 +175,44 @@ void main() { expect(wrapped.toStreamException(), same(loose)); }); }); + + group('runApiSafely', () { + test('returns the call result on success', () async { + final result = await runApiSafely(() async => 'ok'); + + expect(result, const Result.success('ok')); + }); + + test('maps a transport failure onto the exception it represents', () async { + final result = await runApiSafely( + () async => throw _failure(body: _errorBody(), statusCode: 401), + ); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.code, 'code', 40), + ); + }); + + test('keeps a Stream exception as it was raised', () async { + const raised = StreamAuthenticationException(message: 'no token'); + final result = await runApiSafely(() async => throw raised); + + expect(result.exceptionOrNull(), same(raised)); + }); + + test('reports a response that would not decode as an SDK failure', () async { + // The call closure decodes wire data; a server that renamed a field + // throws a TypeError there, which must surface as a handleable failure. + final result = await runApiSafely(() async { + const Object renamed = 'not an int'; + return renamed as int; + }); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.cause, 'cause', isA()), + ); + }); + }); } From 6d2d9a1d0237986c4ca0d60a1dd9879009a2c0d5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 15:26:03 +0200 Subject: [PATCH 33/78] style(llc): shape runApiSafely the way runSafely reads Same doc voice, block parameter, and FutureOr signature as the seam it specialises. Co-Authored-By: Claude Fable 5 --- .../src/api/stream_core_dio_exception.dart | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart index d2637d71..be0955d9 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; @@ -116,37 +117,29 @@ Duration? _parseRetryAfter(Response response) { return Duration(seconds: seconds); } -/// Runs an API [call] and returns its outcome, every failure a -/// [StreamException]. +/// Runs a block of API code and returns a [Result] containing the outcome. /// -/// The seam an API call crosses on its way to a caller: transport failures -/// are unwrapped or mapped through [DioExceptionMapping.toStreamException], -/// and anything else the call throws — a response body that would not decode -/// included — becomes a [StreamClientException] with the original error -/// preserved as its cause. -/// -/// ```dart -/// Future> queryChannel(String cid) { -/// return runApiSafely(() async { -/// final response = await _client.get('/channels/$cid'); -/// return Channel.fromJson(response.data); -/// }); -/// } -/// ``` -Future> runApiSafely(Future Function() call) async { +/// If the block completes successfully, the result is a success with the value +/// returned by the block. Otherwise, the failure is always a [StreamException]: +/// a [DioException] is mapped through [DioExceptionMapping.toStreamException], +/// a [StreamException] is kept as it was raised, and anything else — a +/// response body that would not decode included — is wrapped in a +/// [StreamClientException] with the original error preserved as its cause. +Future> runApiSafely(FutureOr Function() block) async { try { - return Result.success(await call()); + final result = await block(); + return Result.success(result); } on DioException catch (e, stackTrace) { return Result.failure(e.toStreamException(), stackTrace); } on StreamException catch (e, stackTrace) { return Result.failure(e, stackTrace); } catch (e, stackTrace) { - // An interpretation seam: the call closure decodes wire data, where a - // thrown `TypeError` indicts the response rather than the program — a - // server that renamed a field must surface as a handleable failure. - return Result.failure( - StreamClientException(message: 'The API call failed unexpectedly', cause: e, stackTrace: stackTrace), - stackTrace, + final exception = StreamClientException( + message: 'The API call failed unexpectedly', + cause: e, + stackTrace: stackTrace, ); + + return Result.failure(exception, stackTrace); } } From 3b7930ea1d4323738449f99b3e0fcfbdbba4975d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 15:26:28 +0200 Subject: [PATCH 34/78] style(llc): drop async from runApiSafely test closures the FutureOr signature made needless Co-Authored-By: Claude Fable 5 --- .../test/api/stream_core_dio_exception_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart index 30f635c1..ee441481 100644 --- a/packages/stream_core/test/api/stream_core_dio_exception_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -178,14 +178,14 @@ void main() { group('runApiSafely', () { test('returns the call result on success', () async { - final result = await runApiSafely(() async => 'ok'); + final result = await runApiSafely(() => 'ok'); expect(result, const Result.success('ok')); }); test('maps a transport failure onto the exception it represents', () async { final result = await runApiSafely( - () async => throw _failure(body: _errorBody(), statusCode: 401), + () => throw _failure(body: _errorBody(), statusCode: 401), ); expect( @@ -196,7 +196,7 @@ void main() { test('keeps a Stream exception as it was raised', () async { const raised = StreamAuthenticationException(message: 'no token'); - final result = await runApiSafely(() async => throw raised); + final result = await runApiSafely(() => throw raised); expect(result.exceptionOrNull(), same(raised)); }); @@ -204,7 +204,7 @@ void main() { test('reports a response that would not decode as an SDK failure', () async { // The call closure decodes wire data; a server that renamed a field // throws a TypeError there, which must surface as a handleable failure. - final result = await runApiSafely(() async { + final result = await runApiSafely(() { const Object renamed = 'not an int'; return renamed as int; }); From 5511d472740b7c2cbfa1ec3599e669af747a4ea1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:02:12 +0200 Subject: [PATCH 35/78] refactor(llc): give the WebSocket client one error boundary, shaped like the HTTP one The two _as* helpers and the inline error-event switch normalized the same way three times over; a single file-private toStreamException extension now mirrors the Dio mapper, with only the per-boundary fallback decided at each site. Co-Authored-By: Claude Fable 5 --- .../src/api/stream_core_dio_exception.dart | 3 +- .../ws/client/stream_web_socket_client.dart | 69 ++++++++++--------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart index be0955d9..7b505412 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart @@ -130,7 +130,8 @@ Future> runApiSafely(FutureOr Function() block) async { final result = await block(); return Result.success(result); } on DioException catch (e, stackTrace) { - return Result.failure(e.toStreamException(), stackTrace); + final exception = e.toStreamException(); + return Result.failure(exception, stackTrace); } on StreamException catch (e, stackTrace) { return Result.failure(e, stackTrace); } catch (e, stackTrace) { diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 0eeabfee..f06fd955 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -92,7 +92,14 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, authenticator: onAuthenticate, tag: '$tag:Auth', onFailure: (error) => disconnect( - source: .authenticationFailed(error: _asAuthenticationFailure(error)), + source: .authenticationFailed( + error: error.toStreamException( + orElse: () => StreamAuthenticationException( + message: 'The connection could not be authenticated', + cause: error, + ), + ), + ), ), ); } @@ -195,36 +202,19 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // even when the close fails. Returned, so a caller connecting again is not refused for the race. return result.getOrElse( (error, stackTrace) => disconnect( - source: .serverInitiated(error: _asOpenFailure(error, stackTrace)), + source: .serverInitiated( + error: error.toStreamException( + orElse: () => StreamNetworkException( + message: 'Failed to open the connection', + cause: error, + stackTrace: stackTrace, + ), + ), + ), ), ); } - // The engine reports whatever the transport threw; an attempt that never - // became usable is a network failure unless it already speaks for itself. - StreamException _asOpenFailure(Object error, StackTrace? stackTrace) { - return switch (error) { - final StreamException exception => exception, - _ => StreamNetworkException( - message: 'Failed to open the connection', - cause: error, - stackTrace: stackTrace, - ), - }; - } - - // Credentials never went out — an authentication failure, unless the - // authenticator already reported one of our own. - StreamException _asAuthenticationFailure(Object error) { - return switch (error) { - final StreamException exception => exception, - _ => StreamAuthenticationException( - message: 'The connection could not be authenticated', - cause: error, - ), - }; - } - /// Closes the WebSocket connection. /// /// When [closeCode] is provided, uses the specified close code for the disconnection. @@ -332,16 +322,12 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void _handleErrorEvent(WsEvent event, Object error) { _logger.w(() => 'server sent an error event', error: error); - // A server error event is a verdict — the same payload a rejected REST - // call carries, delivered over the socket instead. - final exception = switch (error) { - final StreamException exception => exception, - final StreamApiError apiError => StreamApiException.fromApiError(apiError), - _ => StreamClientException( + final exception = error.toStreamException( + orElse: () => StreamClientException( message: 'The server reported an error the client could not interpret', cause: error, ), - }; + ); final source = ServerInitiated(error: exception); return unawaited(disconnect(source: source)); @@ -404,3 +390,18 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, return super.dispose(); } } + +// Maps what the engine, the server, or the authenticator reported onto the +// exception it represents. +extension on Object { + // This failure as the [StreamException] it represents: kept when it is one + // already, read out of a server error payload, and [orElse] otherwise — + // the one judgment that differs per boundary. + StreamException toStreamException({required StreamException Function() orElse}) { + return switch (this) { + final StreamException exception => exception, + final StreamApiError apiError => StreamApiException.fromApiError(apiError), + _ => orElse(), + }; + } +} From 544510b8f8b0ce5cbff4aa8fbf38b30e2bc0cbc0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:08:24 +0200 Subject: [PATCH 36/78] refactor(llc): share the boundary normalization as StreamException.from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass-through-lift-or-wrap judgment lived four times over — the WS client, the auth interceptor, and in degraded form the token manager, which double-wrapped an already classified exception. One factory on the root now carries it, with each boundary supplying only its own fallback. Co-Authored-By: Claude Fable 5 --- .../api/interceptors/auth_interceptor.dart | 14 +++++------ .../lib/src/errors/stream_exception.dart | 16 +++++++++++++ .../lib/src/user/token_manager.dart | 11 +++++---- .../ws/client/stream_web_socket_client.dart | 24 +++++-------------- .../test/errors/stream_exception_test.dart | 13 ++++++++++ 5 files changed, 48 insertions(+), 30 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index da24ee15..97bcacb0 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -40,18 +40,16 @@ class AuthInterceptor extends Interceptor { } catch (e, stackTrace) { _logger.w(() => 'no token to sign ${options.uri} with', error: e, stackTrace: stackTrace); - // Credentials never went out, so this is an authentication failure — - // unless the token manager already said so, in which case its report - // is kept as is. Caught in full: a rejection must deliver a - // StreamException whatever the app's token code threw. - final exception = switch (e) { - final StreamException exception => exception, - _ => StreamAuthenticationException( + // Caught in full: a rejection must deliver a StreamException whatever + // the app's token code threw. + final exception = StreamException.from( + e, + orElse: () => StreamAuthenticationException( message: 'Failed to load an auth token', cause: e, stackTrace: stackTrace, ), - }; + ); final dioError = StreamDioException( exception: exception, diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index ed85e1a8..284cabdc 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -32,6 +32,22 @@ sealed class StreamException extends Equatable implements Exception { this.stackTrace, }); + /// The [StreamException] that [error] represents. + /// + /// Kept as it is when [error] is one already, read out of a server error + /// payload when it is a [StreamApiError], and built by [orElse] otherwise — + /// the one judgment that differs per boundary. + factory StreamException.from( + Object error, { + required StreamException Function() orElse, + }) { + return switch (error) { + final StreamException exception => exception, + final StreamApiError apiError => StreamApiException.fromApiError(apiError), + _ => orElse(), + }; + } + /// What went wrong. /// /// Always present and developer-readable, but not localized and possibly diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 386235da..b245ff57 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -206,10 +206,13 @@ class TokenManager { final result = await runSafely(() => provider.loadToken(userId)); return result.getOrElse((error, stackTrace) { - throw StreamAuthenticationException( - message: 'The token provider failed to load a token for user "$userId"', - cause: error, - stackTrace: stackTrace, + throw StreamException.from( + error, + orElse: () => StreamAuthenticationException( + message: 'The token provider failed to load a token for user "$userId"', + cause: error, + stackTrace: stackTrace, + ), ); }); } diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index f06fd955..f82098db 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -93,7 +93,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, tag: '$tag:Auth', onFailure: (error) => disconnect( source: .authenticationFailed( - error: error.toStreamException( + error: StreamException.from( + error, orElse: () => StreamAuthenticationException( message: 'The connection could not be authenticated', cause: error, @@ -203,7 +204,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, return result.getOrElse( (error, stackTrace) => disconnect( source: .serverInitiated( - error: error.toStreamException( + error: StreamException.from( + error, orElse: () => StreamNetworkException( message: 'Failed to open the connection', cause: error, @@ -322,7 +324,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void _handleErrorEvent(WsEvent event, Object error) { _logger.w(() => 'server sent an error event', error: error); - final exception = error.toStreamException( + final exception = StreamException.from( + error, orElse: () => StreamClientException( message: 'The server reported an error the client could not interpret', cause: error, @@ -390,18 +393,3 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, return super.dispose(); } } - -// Maps what the engine, the server, or the authenticator reported onto the -// exception it represents. -extension on Object { - // This failure as the [StreamException] it represents: kept when it is one - // already, read out of a server error payload, and [orElse] otherwise — - // the one judgment that differs per boundary. - StreamException toStreamException({required StreamException Function() orElse}) { - return switch (this) { - final StreamException exception => exception, - final StreamApiError apiError => StreamApiException.fromApiError(apiError), - _ => orElse(), - }; - } -} diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index 0c293759..ad251556 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -47,6 +47,19 @@ void main() { ); }); + test('from keeps one of ours, lifts a payload, and falls back otherwise', () { + const ours = StreamAuthenticationException(message: 'no token'); + final payload = _apiError(); + StreamException fallback() => const StreamClientException(message: 'unexpected'); + + expect(StreamException.from(ours, orElse: fallback), same(ours)); + expect( + StreamException.from(payload, orElse: fallback), + isA().having((it) => it.apiError, 'apiError', same(payload)), + ); + expect(StreamException.from(StateError('bug'), orElse: fallback), isA()); + }); + test('prints its kind, its message and its cause', () { const exception = StreamClientException( message: 'the event would not decode', From bad8ac88f85a5e58e861597d2457209011668de3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:12:55 +0200 Subject: [PATCH 37/78] style(llc): flatten the boundary fallbacks onto tryFrom and ?? StreamException.tryFrom follows int.tryParse's shape, so every boundary reads as a null-aware chain instead of an orElse closure. Co-Authored-By: Claude Fable 5 --- .../api/interceptors/auth_interceptor.dart | 15 ++++--- .../lib/src/errors/stream_exception.dart | 21 ++++++---- .../lib/src/user/token_manager.dart | 14 +++---- .../ws/client/stream_web_socket_client.dart | 41 +++++++++---------- .../test/errors/stream_exception_test.dart | 9 ++-- 5 files changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 97bcacb0..5ee0f5c3 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -42,14 +42,13 @@ class AuthInterceptor extends Interceptor { // Caught in full: a rejection must deliver a StreamException whatever // the app's token code threw. - final exception = StreamException.from( - e, - orElse: () => StreamAuthenticationException( - message: 'Failed to load an auth token', - cause: e, - stackTrace: stackTrace, - ), - ); + final exception = + StreamException.tryFrom(e) ?? + StreamAuthenticationException( + message: 'Failed to load an auth token', + cause: e, + stackTrace: stackTrace, + ); final dioError = StreamDioException( exception: exception, diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 284cabdc..a758eb05 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -32,19 +32,22 @@ sealed class StreamException extends Equatable implements Exception { this.stackTrace, }); - /// The [StreamException] that [error] represents. + /// The [StreamException] that [error] represents, or `null` when it does + /// not represent one. /// - /// Kept as it is when [error] is one already, read out of a server error - /// payload when it is a [StreamApiError], and built by [orElse] otherwise — - /// the one judgment that differs per boundary. - factory StreamException.from( - Object error, { - required StreamException Function() orElse, - }) { + /// Kept as it is when [error] is one already, and read out of a server + /// error payload when it is a [StreamApiError]. A boundary supplies its own + /// fallback for everything else: + /// + /// ```dart + /// final exception = StreamException.tryFrom(error) ?? + /// StreamNetworkException(message: 'Failed to open the connection', cause: error); + /// ``` + static StreamException? tryFrom(Object error) { return switch (error) { final StreamException exception => exception, final StreamApiError apiError => StreamApiException.fromApiError(apiError), - _ => orElse(), + _ => null, }; } diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index b245ff57..028cea63 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -206,14 +206,12 @@ class TokenManager { final result = await runSafely(() => provider.loadToken(userId)); return result.getOrElse((error, stackTrace) { - throw StreamException.from( - error, - orElse: () => StreamAuthenticationException( - message: 'The token provider failed to load a token for user "$userId"', - cause: error, - stackTrace: stackTrace, - ), - ); + throw StreamException.tryFrom(error) ?? + StreamAuthenticationException( + message: 'The token provider failed to load a token for user "$userId"', + cause: error, + stackTrace: stackTrace, + ); }); } diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index f82098db..652e4ed4 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -93,13 +93,12 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, tag: '$tag:Auth', onFailure: (error) => disconnect( source: .authenticationFailed( - error: StreamException.from( - error, - orElse: () => StreamAuthenticationException( - message: 'The connection could not be authenticated', - cause: error, - ), - ), + error: + StreamException.tryFrom(error) ?? + StreamAuthenticationException( + message: 'The connection could not be authenticated', + cause: error, + ), ), ), ); @@ -204,14 +203,13 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, return result.getOrElse( (error, stackTrace) => disconnect( source: .serverInitiated( - error: StreamException.from( - error, - orElse: () => StreamNetworkException( - message: 'Failed to open the connection', - cause: error, - stackTrace: stackTrace, - ), - ), + error: + StreamException.tryFrom(error) ?? + StreamNetworkException( + message: 'Failed to open the connection', + cause: error, + stackTrace: stackTrace, + ), ), ), ); @@ -324,13 +322,12 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void _handleErrorEvent(WsEvent event, Object error) { _logger.w(() => 'server sent an error event', error: error); - final exception = StreamException.from( - error, - orElse: () => StreamClientException( - message: 'The server reported an error the client could not interpret', - cause: error, - ), - ); + final exception = + StreamException.tryFrom(error) ?? + StreamClientException( + message: 'The server reported an error the client could not interpret', + cause: error, + ); final source = ServerInitiated(error: exception); return unawaited(disconnect(source: source)); diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index ad251556..7c561343 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -47,17 +47,16 @@ void main() { ); }); - test('from keeps one of ours, lifts a payload, and falls back otherwise', () { + test('tryFrom keeps one of ours, lifts a payload, and reads null otherwise', () { const ours = StreamAuthenticationException(message: 'no token'); final payload = _apiError(); - StreamException fallback() => const StreamClientException(message: 'unexpected'); - expect(StreamException.from(ours, orElse: fallback), same(ours)); + expect(StreamException.tryFrom(ours), same(ours)); expect( - StreamException.from(payload, orElse: fallback), + StreamException.tryFrom(payload), isA().having((it) => it.apiError, 'apiError', same(payload)), ); - expect(StreamException.from(StateError('bug'), orElse: fallback), isA()); + expect(StreamException.tryFrom(StateError('bug')), isNull); }); test('prints its kind, its message and its cause', () { From 5a545947811b70fb4bd36e68d30c28a61f75111b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:15:47 +0200 Subject: [PATCH 38/78] style(llc): normalize boundary errors as a named local filled by ??= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tryFrom into a local, the boundary's fallback assigned flat with ??=, and the finished exception used by name — no expression nested in argument lists. Co-Authored-By: Claude Fable 5 --- .../api/interceptors/auth_interceptor.dart | 13 +++--- .../lib/src/user/token_manager.dart | 14 ++++--- .../ws/client/stream_web_socket_client.dart | 42 +++++++++---------- 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 5ee0f5c3..8e4bb72c 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -42,13 +42,12 @@ class AuthInterceptor extends Interceptor { // Caught in full: a rejection must deliver a StreamException whatever // the app's token code threw. - final exception = - StreamException.tryFrom(e) ?? - StreamAuthenticationException( - message: 'Failed to load an auth token', - cause: e, - stackTrace: stackTrace, - ); + var exception = StreamException.tryFrom(e); + exception ??= StreamAuthenticationException( + message: 'Failed to load an auth token', + cause: e, + stackTrace: stackTrace, + ); final dioError = StreamDioException( exception: exception, diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 028cea63..97ae78e7 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -206,12 +206,14 @@ class TokenManager { final result = await runSafely(() => provider.loadToken(userId)); return result.getOrElse((error, stackTrace) { - throw StreamException.tryFrom(error) ?? - StreamAuthenticationException( - message: 'The token provider failed to load a token for user "$userId"', - cause: error, - stackTrace: stackTrace, - ); + var exception = StreamException.tryFrom(error); + exception ??= StreamAuthenticationException( + message: 'The token provider failed to load a token for user "$userId"', + cause: error, + stackTrace: stackTrace, + ); + + throw exception; }); } diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 652e4ed4..46983918 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -91,16 +91,15 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, send: send, authenticator: onAuthenticate, tag: '$tag:Auth', - onFailure: (error) => disconnect( - source: .authenticationFailed( - error: - StreamException.tryFrom(error) ?? - StreamAuthenticationException( - message: 'The connection could not be authenticated', - cause: error, - ), - ), - ), + onFailure: (error) { + var exception = StreamException.tryFrom(error); + exception ??= StreamAuthenticationException( + message: 'The connection could not be authenticated', + cause: error, + ); + + unawaited(disconnect(source: .authenticationFailed(error: exception))); + }, ); } @@ -200,19 +199,16 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Handed to `disconnect`, which reports the reason, closes the socket, and records the closure // even when the close fails. Returned, so a caller connecting again is not refused for the race. - return result.getOrElse( - (error, stackTrace) => disconnect( - source: .serverInitiated( - error: - StreamException.tryFrom(error) ?? - StreamNetworkException( - message: 'Failed to open the connection', - cause: error, - stackTrace: stackTrace, - ), - ), - ), - ); + return result.getOrElse((error, stackTrace) { + var exception = StreamException.tryFrom(error); + exception ??= StreamNetworkException( + message: 'Failed to open the connection', + cause: error, + stackTrace: stackTrace, + ); + + return disconnect(source: .serverInitiated(error: exception)); + }); } /// Closes the WebSocket connection. From e442b1662745689a771d4fbf7173382993d253b9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:17:13 +0200 Subject: [PATCH 39/78] refactor(llc): hand the authentication failure's stack trace to onFailure The handler had the trace and dropped it; the client now stamps it onto the authentication exception it reports. Also finishes the ??= shape at the two sites the formatter had reshaped, replacing the connect closure's getOrElse with an if-case so the disconnect future stays returnable. Co-Authored-By: Claude Fable 5 --- .../ws/client/stream_web_socket_client.dart | 18 +++++++++--------- .../web_socket_authentication_handler.dart | 4 ++-- ...web_socket_authentication_handler_test.dart | 18 +++++++++--------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 46983918..5a0486c1 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -91,11 +91,12 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, send: send, authenticator: onAuthenticate, tag: '$tag:Auth', - onFailure: (error) { + onFailure: (error, stackTrace) { var exception = StreamException.tryFrom(error); exception ??= StreamAuthenticationException( message: 'The connection could not be authenticated', cause: error, + stackTrace: stackTrace, ); unawaited(disconnect(source: .authenticationFailed(error: exception))); @@ -199,7 +200,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Handed to `disconnect`, which reports the reason, closes the socket, and records the closure // even when the close fails. Returned, so a caller connecting again is not refused for the race. - return result.getOrElse((error, stackTrace) { + if (result case Failure(:final error, :final stackTrace)) { var exception = StreamException.tryFrom(error); exception ??= StreamNetworkException( message: 'Failed to open the connection', @@ -208,7 +209,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, ); return disconnect(source: .serverInitiated(error: exception)); - }); + } } /// Closes the WebSocket connection. @@ -318,12 +319,11 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void _handleErrorEvent(WsEvent event, Object error) { _logger.w(() => 'server sent an error event', error: error); - final exception = - StreamException.tryFrom(error) ?? - StreamClientException( - message: 'The server reported an error the client could not interpret', - cause: error, - ); + var exception = StreamException.tryFrom(error); + exception ??= StreamClientException( + message: 'The server reported an error the client could not interpret', + cause: error, + ); final source = ServerInitiated(error: exception); return unawaited(disconnect(source: source)); diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 76267c27..f97fd950 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -42,7 +42,7 @@ class WebSocketAuthenticationHandler { final WebSocketAuthenticator? _authenticator; final WsRequestSender _send; - final void Function(Object error) _onFailure; + final void Function(Object error, StackTrace? stackTrace) _onFailure; // Identifies the attempt in flight: an authenticator can outlive the one that started it. var _attempt = 0; @@ -104,7 +104,7 @@ class WebSocketAuthenticationHandler { if (result case Failure(:final error, :final stackTrace)) { _logger.w(() => 'attempt #$attempt could not be authenticated', error: error, stackTrace: stackTrace); - return _onFailure(error); + return _onFailure(error, stackTrace); } } diff --git a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart index 850f1d69..1e7ba16b 100644 --- a/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart @@ -50,7 +50,7 @@ _subject({WebSocketAuthenticator? authenticator}) { send(const _PingRequest()).getOrThrow(); }, send: (_) => const Result.success(null), - onFailure: failures.add, + onFailure: (error, _) => failures.add(error), ); return (authentication: authentication, asked: asked, failures: failures); @@ -163,7 +163,7 @@ void main() { final authentication = WebSocketAuthenticationHandler( authenticator: null, send: (_) => const Result.success(null), - onFailure: (_) => fail('nothing to authenticate, so nothing can fail'), + onFailure: (_, _) => fail('nothing to authenticate, so nothing can fail'), ); await expectLater(authentication.authenticate(), completes); @@ -186,7 +186,7 @@ void main() { final authentication = WebSocketAuthenticationHandler( authenticator: (_, _) => loaded.future, send: (_) => const Result.success(null), - onFailure: (_) => fail('the credentials went out'), + onFailure: (_, _) => fail('the credentials went out'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -217,7 +217,7 @@ void main() { sent.add(request); return const Result.success(null); }, - onFailure: (_) => fail('the credentials were never offered, so nothing failed to go out'), + onFailure: (_, _) => fail('the credentials were never offered, so nothing failed to go out'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -245,7 +245,7 @@ void main() { throw StateError('token load failed'); }, send: (_) => const Result.success(null), - onFailure: (_) => fail('the attempt this failure belongs to had already been closed'), + onFailure: (_, _) => fail('the attempt this failure belongs to had already been closed'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -275,7 +275,7 @@ void main() { sent.add(request); return const Result.success(null); }, - onFailure: (_) => fail('the credentials were never offered, so nothing failed to go out'), + onFailure: (_, _) => fail('the credentials were never offered, so nothing failed to go out'), ); authentication.onConnectionStateChanged(const Connecting()); @@ -304,7 +304,7 @@ void main() { throw StateError('token load failed'); }, send: (_) => const Result.success(null), - onFailure: failures.add, + onFailure: (error, _) => failures.add(error), ); authentication.onConnectionStateChanged(const Connecting()); @@ -335,7 +335,7 @@ void main() { await loaded.future; }, send: (_) => const Result.success(null), - onFailure: (_) {}, + onFailure: (_, _) {}, ); authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); @@ -362,7 +362,7 @@ void main() { final authentication = WebSocketAuthenticationHandler( authenticator: (_, _) async => throw StateError('token load failed'), send: (_) => const Result.success(null), - onFailure: failures.add, + onFailure: (error, _) => failures.add(error), ); authentication.onConnectionStateChanged(const Connecting()); From e0852327f1714e1dcb11d7faa63041f5b191c13b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:22:21 +0200 Subject: [PATCH 40/78] style(llc): expression-body tryFrom and accept a nullable error Co-Authored-By: Claude Fable 5 --- .../stream_core/lib/src/errors/stream_exception.dart | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index a758eb05..0291242c 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -43,13 +43,11 @@ sealed class StreamException extends Equatable implements Exception { /// final exception = StreamException.tryFrom(error) ?? /// StreamNetworkException(message: 'Failed to open the connection', cause: error); /// ``` - static StreamException? tryFrom(Object error) { - return switch (error) { - final StreamException exception => exception, - final StreamApiError apiError => StreamApiException.fromApiError(apiError), - _ => null, - }; - } + static StreamException? tryFrom(Object? error) => switch (error) { + final StreamException exception => exception, + final StreamApiError apiError => StreamApiException.fromApiError(apiError), + _ => null, + }; /// What went wrong. /// From c53a1f39390075a23e28284b1ef8f4a803e6f67a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:25:12 +0200 Subject: [PATCH 41/78] fix(llc)!: make the engine's failures speak the exception kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double open is misuse and throws instead of dissolving into a Result the client would misread as a network failure; transport failures on open and close arrive as StreamNetworkException naming the URL, and an encode failure as StreamClientException — no raw transport errors leak from the engine's Results. Co-Authored-By: Claude Fable 5 --- .../engine/stream_web_socket_engine.dart | 77 ++++++++++++++----- .../ws/client/engine/web_socket_engine.dart | 5 +- .../engine/stream_web_socket_engine_test.dart | 7 +- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index 0101f7a4..9af1bbb3 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:web_socket_channel/web_socket_channel.dart'; -import '../../../errors.dart' show StreamNetworkException; +import '../../../errors.dart' show StreamClientException, StreamException, StreamNetworkException; import '../../../logger.dart'; import '../../../utils.dart'; import 'web_socket_engine.dart'; @@ -58,24 +58,37 @@ class StreamWebSocketEngine implements WebSocketEngine { @override Future> open(WebSocketOptions options) { + // Misuse rather than a condition: opening over a live socket is a bug in + // the caller, so it fails loudly instead of dissolving into a failure. + if (_ws != null) { + throw StateError('WebSocket is already open. Call close() first.'); + } + return runSafely(() async { - if (_ws != null) { - throw StateError('WebSocket is already open. Call close() first.'); + try { + // Create a new WebSocket connection. + final ws = _ws = _wsProvider.call(options); + _wsSubscription = ws.stream.listen( + _onData, + onDone: _onDone, + cancelOnError: false, + onError: _listener?.onError, + ); + + await ws.ready; + + // A handshake already in flight outlives `close`, so a late one must not report a stale socket. + if (_ws == ws) _listener?.onOpen(); + } catch (e, stackTrace) { + var exception = StreamException.tryFrom(e); + exception ??= StreamNetworkException( + message: 'Failed to open the connection to ${options.url}', + cause: e, + stackTrace: stackTrace, + ); + + throw exception; } - - // Create a new WebSocket connection. - final ws = _ws = _wsProvider.call(options); - _wsSubscription = ws.stream.listen( - _onData, - onDone: _onDone, - cancelOnError: false, - onError: _listener?.onError, - ); - - await ws.ready; - - // A handshake already in flight outlives `close`, so a late one must not report a stale socket. - if (_ws == ws) _listener?.onOpen(); }); } @@ -118,8 +131,19 @@ class StreamWebSocketEngine implements WebSocketEngine { _ws = null; _wsSubscription = null; - await subscription?.cancel(); - await ws?.sink.close(closeCode, closeReason); + try { + await subscription?.cancel(); + await ws?.sink.close(closeCode, closeReason); + } catch (e, stackTrace) { + var exception = StreamException.tryFrom(e); + exception ??= StreamNetworkException( + message: 'Failed to close the connection', + cause: e, + stackTrace: stackTrace, + ); + + throw exception; + } // A new socket can open while this one closes, and must not be brought down by its closure. if (_ws == null) _listener?.onClose(closeCode, closeReason); @@ -136,7 +160,20 @@ class StreamWebSocketEngine implements WebSocketEngine { throw const StreamNetworkException(message: 'The connection is not open, so nothing was sent'); } - final data = _messageCodec.encode(message); + final Object data; + try { + data = _messageCodec.encode(message); + } catch (e, stackTrace) { + var exception = StreamException.tryFrom(e); + exception ??= StreamClientException( + message: 'The message could not be encoded', + cause: e, + stackTrace: stackTrace, + ); + + throw exception; + } + return ws.sink.add(data); }); } diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index 2d8a9e90..36992e23 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -16,9 +16,10 @@ abstract interface class WebSocketEngine { /// Creates a new WebSocket connection using the provided [options] and sets up /// event listeners. /// - /// Fails when a connection is already open. Call [close] before opening another. - /// /// Returns a [Result] indicating success or failure of the connection attempt. + /// + /// Throws a [StateError] when a connection is already open — misuse rather + /// than a failed attempt. Call [close] before opening another. Future> open(WebSocketOptions options); /// Closes the WebSocket connection. diff --git a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart index 67f4ba44..c8bbec4c 100644 --- a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -221,11 +221,10 @@ void main() { final (:engine, :listener, :sockets) = _subjectWithFreshSockets(); await engine.open(_options); - final result = await engine.open(_options); - // Closing the live socket to make room would hide a caller opening a second connection over a - // connection it still has. Refused before a second socket is even created. - expect(result.isFailure, isTrue); + // connection it still has. Misuse rather than a failed attempt, so it throws before a second + // socket is even created. + expect(() => engine.open(_options), throwsStateError); expect(sockets, hasLength(1)); expect(sockets.single.sink.closedWith, isNull); expect(listener.closures, isEmpty); From b6d2b36078dacefc2d76c352ae48e544cb843d1f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:28:43 +0200 Subject: [PATCH 42/78] =?UTF-8?q?refactor(llc):=20keep=20the=20engine=20du?= =?UTF-8?q?mb=20=E2=80=94=20the=20client=20is=20the=20normalization=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open and close wrappers duplicated what the client's boundary already does, so raw transport errors ride the Result up to it again. sendMessage keeps its typed failures: client.send forwards the engine's Result straight to callers, and only the engine can tell a dropped connection from an unencodable message. Co-Authored-By: Claude Fable 5 --- .../engine/stream_web_socket_engine.dart | 59 ++++++------------- .../ws/client/stream_web_socket_client.dart | 2 +- 2 files changed, 18 insertions(+), 43 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index 9af1bbb3..664c052d 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:web_socket_channel/web_socket_channel.dart'; -import '../../../errors.dart' show StreamClientException, StreamException, StreamNetworkException; +import '../../../errors.dart' show StreamClientException, StreamNetworkException; import '../../../logger.dart'; import '../../../utils.dart'; import 'web_socket_engine.dart'; @@ -65,30 +65,19 @@ class StreamWebSocketEngine implements WebSocketEngine { } return runSafely(() async { - try { - // Create a new WebSocket connection. - final ws = _ws = _wsProvider.call(options); - _wsSubscription = ws.stream.listen( - _onData, - onDone: _onDone, - cancelOnError: false, - onError: _listener?.onError, - ); - - await ws.ready; - - // A handshake already in flight outlives `close`, so a late one must not report a stale socket. - if (_ws == ws) _listener?.onOpen(); - } catch (e, stackTrace) { - var exception = StreamException.tryFrom(e); - exception ??= StreamNetworkException( - message: 'Failed to open the connection to ${options.url}', - cause: e, - stackTrace: stackTrace, - ); - - throw exception; - } + // Create a new WebSocket connection. + final ws = _ws = _wsProvider.call(options); + _wsSubscription = ws.stream.listen( + _onData, + onDone: _onDone, + cancelOnError: false, + onError: _listener?.onError, + ); + + await ws.ready; + + // A handshake already in flight outlives `close`, so a late one must not report a stale socket. + if (_ws == ws) _listener?.onOpen(); }); } @@ -131,19 +120,8 @@ class StreamWebSocketEngine implements WebSocketEngine { _ws = null; _wsSubscription = null; - try { - await subscription?.cancel(); - await ws?.sink.close(closeCode, closeReason); - } catch (e, stackTrace) { - var exception = StreamException.tryFrom(e); - exception ??= StreamNetworkException( - message: 'Failed to close the connection', - cause: e, - stackTrace: stackTrace, - ); - - throw exception; - } + await subscription?.cancel(); + await ws?.sink.close(closeCode, closeReason); // A new socket can open while this one closes, and must not be brought down by its closure. if (_ws == null) _listener?.onClose(closeCode, closeReason); @@ -164,14 +142,11 @@ class StreamWebSocketEngine implements WebSocketEngine { try { data = _messageCodec.encode(message); } catch (e, stackTrace) { - var exception = StreamException.tryFrom(e); - exception ??= StreamClientException( + throw StreamClientException( message: 'The message could not be encoded', cause: e, stackTrace: stackTrace, ); - - throw exception; } return ws.sink.add(data); diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 5a0486c1..d397e8d0 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -203,7 +203,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, if (result case Failure(:final error, :final stackTrace)) { var exception = StreamException.tryFrom(error); exception ??= StreamNetworkException( - message: 'Failed to open the connection', + message: 'Failed to open the connection to ${options.url}', cause: error, stackTrace: stackTrace, ); From a623f25dbd9ec29aab58ce3ab2847f06064377a9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:31:39 +0200 Subject: [PATCH 43/78] refactor(llc): let the engine report raw truth on send too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every consumption path of send crosses a normalization seam already — the authenticator's failures reach the client's onFailure, ping results are ignored, and products own their call seams — so the engine keeps its StateError guard and lets codec errors speak for themselves. Co-Authored-By: Claude Fable 5 --- packages/stream_core/CHANGELOG.md | 2 +- .../client/engine/stream_web_socket_engine.dart | 17 ++--------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 71cbc94a..c3e8cba6 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -16,7 +16,7 @@ - `TokenManager.getToken` fails with a `StreamAuthenticationException` — when no identity is configured, when `reset` races a load, and when the `TokenProvider` fails, in which case whatever it threw is preserved as the exception's `cause` - Replaced the `StreamApiError` predicates: `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes), and `isRateLimited` reads the HTTP status. The same predicates exist on `StreamApiException` - Removed `AttachmentUploadException`: `StreamAttachmentUploader.upload` reports the upload's own failure unwrapped, and `uploadBatch` emits `(attachmentId, result)` records so which attachment failed travels beside the outcome rather than inside it -- A send on a connection that is not open, and credentials whose connection attempt was abandoned, fail with a `StreamNetworkException` inside the `Result` rather than a `StateError` — a correct caller can race a dropped connection. A token provider that fails, or returns a token for another user, fails `getToken` with a `StreamAuthenticationException` rather than a raw error +- Credentials whose connection attempt was abandoned fail with a `StreamNetworkException` inside the `Result` rather than a `StateError`. A token provider that fails, or returns a token for another user, fails `getToken` with a `StreamAuthenticationException` rather than a raw error - `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 for one not valid yet, and stays `false` for a refused signature or API key, any other 4xx, and whenever the server marked the error `unrecoverable` - `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 widened = intResult`) or use `fold` diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index 664c052d..5c480584 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:web_socket_channel/web_socket_channel.dart'; -import '../../../errors.dart' show StreamClientException, StreamNetworkException; import '../../../logger.dart'; import '../../../utils.dart'; import 'web_socket_engine.dart'; @@ -133,22 +132,10 @@ class StreamWebSocketEngine implements WebSocketEngine { return runSafelySync(() { final ws = _ws; if (ws == null) { - // A condition, not misuse: a correct caller can race a connection - // that dropped between deciding to send and sending. - throw const StreamNetworkException(message: 'The connection is not open, so nothing was sent'); - } - - final Object data; - try { - data = _messageCodec.encode(message); - } catch (e, stackTrace) { - throw StreamClientException( - message: 'The message could not be encoded', - cause: e, - stackTrace: stackTrace, - ); + throw StateError('WebSocket is not open. Call open() first.'); } + final data = _messageCodec.encode(message); return ws.sink.add(data); }); } From 4b64adc9e94836cb71e61c6fc723f845cc80ccb7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:32:57 +0200 Subject: [PATCH 44/78] refactor(llc): report a double open through the Result like every engine outcome One uniform engine contract: nothing throws, the Result carries the raw truth, and the boundaries above decide what it means. Co-Authored-By: Claude Fable 5 --- .../src/ws/client/engine/stream_web_socket_engine.dart | 10 ++++------ .../lib/src/ws/client/engine/web_socket_engine.dart | 5 ++--- .../client/engine/stream_web_socket_engine_test.dart | 7 ++++--- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index 5c480584..74bb429f 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -57,13 +57,11 @@ class StreamWebSocketEngine implements WebSocketEngine { @override Future> open(WebSocketOptions options) { - // Misuse rather than a condition: opening over a live socket is a bug in - // the caller, so it fails loudly instead of dissolving into a failure. - if (_ws != null) { - throw StateError('WebSocket is already open. Call close() first.'); - } - return runSafely(() async { + if (_ws != null) { + throw StateError('WebSocket is already open. Call close() first.'); + } + // Create a new WebSocket connection. final ws = _ws = _wsProvider.call(options); _wsSubscription = ws.stream.listen( diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index 36992e23..2d8a9e90 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -16,10 +16,9 @@ abstract interface class WebSocketEngine { /// Creates a new WebSocket connection using the provided [options] and sets up /// event listeners. /// - /// Returns a [Result] indicating success or failure of the connection attempt. + /// Fails when a connection is already open. Call [close] before opening another. /// - /// Throws a [StateError] when a connection is already open — misuse rather - /// than a failed attempt. Call [close] before opening another. + /// Returns a [Result] indicating success or failure of the connection attempt. Future> open(WebSocketOptions options); /// Closes the WebSocket connection. diff --git a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart index c8bbec4c..67f4ba44 100644 --- a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -221,10 +221,11 @@ void main() { final (:engine, :listener, :sockets) = _subjectWithFreshSockets(); await engine.open(_options); + final result = await engine.open(_options); + // Closing the live socket to make room would hide a caller opening a second connection over a - // connection it still has. Misuse rather than a failed attempt, so it throws before a second - // socket is even created. - expect(() => engine.open(_options), throwsStateError); + // connection it still has. Refused before a second socket is even created. + expect(result.isFailure, isTrue); expect(sockets, hasLength(1)); expect(sockets.single.sink.closedWith, isNull); expect(listener.closures, isEmpty); From 2f11eaf234b44d62ad13ecf6ed5efe49fddaae6f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 16:53:11 +0200 Subject: [PATCH 45/78] feat(llc)!: shape the attachment uploader for its real consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upload gains the cancelToken chat's per-attachment cancellation needs; uploadBatch keeps streamed (attachmentId, result) records for chat's per-item UI updates and loses eagerError, whose aborting use case is the new uploadAll — the all-or-nothing Result feeds hand-rolls today. Covered by a scripted-CDN test per method. Co-Authored-By: Claude Fable 5 --- packages/stream_core/CHANGELOG.md | 2 +- .../uploader/attachment_uploader.dart | 62 ++++++--- .../attachment/attachment_uploader_test.dart | 118 ++++++++++++++++++ 3 files changed, 161 insertions(+), 21 deletions(-) create mode 100644 packages/stream_core/test/attachment/attachment_uploader_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index c3e8cba6..049ccbb4 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,7 +15,7 @@ - `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` rather than an engine exception and `Object?`, so a `Disconnected` state carries the same error kinds a failed request does - `TokenManager.getToken` fails with a `StreamAuthenticationException` — when no identity is configured, when `reset` races a load, and when the `TokenProvider` fails, in which case whatever it threw is preserved as the exception's `cause` - Replaced the `StreamApiError` predicates: `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes), and `isRateLimited` reads the HTTP status. The same predicates exist on `StreamApiException` -- Removed `AttachmentUploadException`: `StreamAttachmentUploader.upload` reports the upload's own failure unwrapped, and `uploadBatch` emits `(attachmentId, result)` records so which attachment failed travels beside the outcome rather than inside it +- Removed `AttachmentUploadException`: `StreamAttachmentUploader.upload` reports the upload's own failure unwrapped, and `uploadBatch` emits `(attachmentId, result)` records so which attachment failed travels beside the outcome rather than inside it. `upload` takes a `cancelToken`, `uploadBatch` loses `eagerError`, and `uploadAll` returns every outcome as one all-or-nothing `Result` - Credentials whose connection attempt was abandoned fail with a `StreamNetworkException` inside the `Result` rather than a `StateError`. A token provider that fails, or returns a token for another user, fails `getToken` with a `StreamAuthenticationException` rather than a raw error - `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 for one not valid yet, and stays `false` for a refused signature or API key, any other 4xx, and whenever the server marked the error `unrecoverable` diff --git a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart index 8f8a994c..7bd4521a 100644 --- a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart +++ b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:dio/dio.dart' show CancelToken; import 'package:rxdart/rxdart.dart'; import '../../utils.dart'; @@ -51,10 +52,12 @@ class StreamAttachmentUploader { /// /// Returns a [Result] containing the [UploadedAttachment] on success, or /// the upload's own failure otherwise. Progress updates are provided - /// through the optional [onProgress] callback. + /// through the optional [onProgress] callback, and the upload can be called + /// off through [cancelToken], which reads as a cancelled failure. Future> upload( StreamAttachment attachment, { OnUploadProgress? onProgress, + CancelToken? cancelToken, }) async { final uploadFn = switch (attachment.type) { AttachmentType.image => _cdn.uploadImage, @@ -63,6 +66,7 @@ class StreamAttachmentUploader { final result = await uploadFn( attachment.file, + cancelToken: cancelToken, onProgress: onProgress?.let( (f) => (uploaded, total) { if (total == 0) return f(0); @@ -99,25 +103,23 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { /// Uploads multiple attachments as a stream of per-attachment outcomes. /// /// Processes [attachments] concurrently with [maxConcurrent] limit, emitting - /// an [AttachmentUploadResult] as each upload completes. Progress updates - /// are provided through the optional [onProgress] callback. + /// an [AttachmentUploadResult] as each upload completes — a failed upload is + /// emitted as a failure and processing continues, so each attachment's + /// outcome can be acted on the moment it lands. Progress updates are + /// provided through the optional [onProgress] callback. /// - /// When [eagerError] is true, the stream throws an exception and closes - /// immediately on the first upload failure. When false (default), failed - /// uploads are emitted as failures and processing continues. - /// - /// Returns a [Stream] of outcomes in completion order, not input order. + /// Returns a [Stream] of outcomes in completion order, not input order. For + /// all the outcomes as one all-or-nothing result, consider [uploadAll]. Stream uploadBatch( Iterable attachments, { OnBatchUploadProgress? onProgress, int maxConcurrent = 5, - bool eagerError = false, - }) async* { + }) { // Early return for empty list - if (attachments.isEmpty) return; + if (attachments.isEmpty) return const Stream.empty(); // Create a stream that uploads attachments with controlled concurrency - final uploadStream = Stream.fromIterable(attachments).flatMap( + return Stream.fromIterable(attachments).flatMap( maxConcurrent: maxConcurrent, (attachment) => Stream.fromFuture( upload( @@ -129,16 +131,36 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { ).then((result) => (attachmentId: attachment.id, result: result)), ), ); + } - // Yield outcomes as they complete - await for (final outcome in uploadStream) { - // If eagerError is enabled, throw on first failure - if (outcome.result.exceptionOrNull() case final error? when eagerError) { - final stackTrace = outcome.result.stackTraceOrNull(); - Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); - } + /// Uploads multiple attachments and returns every outcome as one [Result]. + /// + /// A success carries every [UploadedAttachment]; the first failure to + /// complete becomes the result's, and uploads already in flight are not + /// awaited further. Progress updates are provided through the optional + /// [onProgress] callback. + Future>> uploadAll( + Iterable attachments, { + OnBatchUploadProgress? onProgress, + int maxConcurrent = 5, + }) async { + final uploaded = []; + + final outcomes = uploadBatch( + attachments, + onProgress: onProgress, + maxConcurrent: maxConcurrent, + ); - yield outcome; + await for (final (attachmentId: _, :result) in outcomes) { + switch (result) { + case Success(:final data): + uploaded.add(data); + case final Failure failure: + return failure; + } } + + return Result.success(uploaded); } } diff --git a/packages/stream_core/test/attachment/attachment_uploader_test.dart b/packages/stream_core/test/attachment/attachment_uploader_test.dart new file mode 100644 index 00000000..99c9a8d4 --- /dev/null +++ b/packages/stream_core/test/attachment/attachment_uploader_test.dart @@ -0,0 +1,118 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +StreamAttachment _attachment(String id, AttachmentFile file) => StreamAttachment( + id: id, + type: AttachmentType.file, + file: file, +); + +/// A CDN whose outcome per file is scripted by [outcomes]. +class _FakeCdn implements CdnClient { + _FakeCdn(this.outcomes); + + final Map> outcomes; + final cancelTokens = {}; + + Future> _upload(AttachmentFile file, CancelToken? cancelToken) async { + cancelTokens[file] = cancelToken; + return outcomes[file]!; + } + + @override + Future> uploadFile( + AttachmentFile file, { + ProgressCallback? onProgress, + CancelToken? cancelToken, + }) => _upload(file, cancelToken); + + @override + Future> uploadImage( + AttachmentFile image, { + ProgressCallback? onProgress, + CancelToken? cancelToken, + }) => _upload(image, cancelToken); + + @override + Future> deleteFile(String url, {CancelToken? cancelToken}) async => const Result.success(null); + + @override + Future> deleteImage(String url, {CancelToken? cancelToken}) async => const Result.success(null); +} + +const _uploadedFile = UploadedFile(fileUrl: 'https://cdn/file'); +const _refused = StreamApiException(message: 'too large', statusCode: 413, code: StreamErrorCode.payloadTooBig); + +void main() { + final fileA = AttachmentFile.fromData(Uint8List(0)); + final fileB = AttachmentFile.fromData(Uint8List(0)); + + group('upload', () { + test('reports the upload failure itself, unwrapped', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: const Result.failure(_refused)}), + ); + + final result = await uploader.upload(_attachment('a', fileA)); + + // The failure stays catchable by kind; which attachment it was is the + // caller's knowledge, not the error's. + expect(result.exceptionOrNull(), same(_refused)); + }); + + test('hands the cancel token to the CDN', () async { + final cdn = _FakeCdn({fileA: const Result.success(_uploadedFile)}); + final uploader = StreamAttachmentUploader(cdn: cdn); + final cancelToken = CancelToken(); + + await uploader.upload(_attachment('a', fileA), cancelToken: cancelToken); + + expect(cdn.cancelTokens[fileA], same(cancelToken)); + }); + }); + + group('uploadBatch', () { + test('pairs every outcome with its attachment', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({ + fileA: const Result.success(_uploadedFile), + fileB: const Result.failure(_refused), + }), + ); + + final outcomes = await uploader.uploadBatch([_attachment('a', fileA), _attachment('b', fileB)]).toList(); + + final byId = {for (final (:attachmentId, :result) in outcomes) attachmentId: result}; + expect(byId['a'], isA>()); + expect(byId['b']?.exceptionOrNull(), same(_refused)); + }); + }); + + group('uploadAll', () { + test('succeeds with every uploaded attachment', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({ + fileA: const Result.success(_uploadedFile), + fileB: const Result.success(_uploadedFile), + }), + ); + + final result = await uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); + + expect(result.getOrNull()?.map((it) => it.id), unorderedEquals(['a', 'b'])); + }); + + test('fails as one with the first failure', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({ + fileA: const Result.success(_uploadedFile), + fileB: const Result.failure(_refused), + }), + ); + + final result = await uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); + + expect(result.exceptionOrNull(), same(_refused)); + }); + }); +} From ac1108bd6921e639df64eaf396f571cf65fe710c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 17:14:46 +0200 Subject: [PATCH 46/78] test(llc): cover the attachment uploader properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field mapping, image/file routing, cancel-token forwarding, progress normalization, completion-order emission, eager and lenient batch modes, and uploadAll's all-or-nothing contract — against a scripted CDN. Co-Authored-By: Claude Fable 5 --- .../uploader/attachment_uploader.dart | 29 ++- .../attachment/attachment_uploader_test.dart | 193 +++++++++++++++--- 2 files changed, 186 insertions(+), 36 deletions(-) diff --git a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart index 7bd4521a..518bc0fb 100644 --- a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart +++ b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart @@ -103,10 +103,13 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { /// Uploads multiple attachments as a stream of per-attachment outcomes. /// /// Processes [attachments] concurrently with [maxConcurrent] limit, emitting - /// an [AttachmentUploadResult] as each upload completes — a failed upload is - /// emitted as a failure and processing continues, so each attachment's - /// outcome can be acted on the moment it lands. Progress updates are - /// provided through the optional [onProgress] callback. + /// an [AttachmentUploadResult] as each upload completes, so each + /// attachment's outcome can be acted on the moment it lands. Progress + /// updates are provided through the optional [onProgress] callback. + /// + /// When [eagerError] is true, the stream throws the first upload's failure + /// and closes. When false (default), failed uploads are emitted as failures + /// and processing continues. /// /// Returns a [Stream] of outcomes in completion order, not input order. For /// all the outcomes as one all-or-nothing result, consider [uploadAll]. @@ -114,12 +117,13 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { Iterable attachments, { OnBatchUploadProgress? onProgress, int maxConcurrent = 5, - }) { + bool eagerError = false, + }) async* { // Early return for empty list - if (attachments.isEmpty) return const Stream.empty(); + if (attachments.isEmpty) return; // Create a stream that uploads attachments with controlled concurrency - return Stream.fromIterable(attachments).flatMap( + final uploadStream = Stream.fromIterable(attachments).flatMap( maxConcurrent: maxConcurrent, (attachment) => Stream.fromFuture( upload( @@ -131,6 +135,17 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { ).then((result) => (attachmentId: attachment.id, result: result)), ), ); + + // Yield outcomes as they complete + await for (final outcome in uploadStream) { + // If eagerError is enabled, throw on first failure + if (outcome.result.exceptionOrNull() case final error? when eagerError) { + final stackTrace = outcome.result.stackTraceOrNull(); + Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); + } + + yield outcome; + } } /// Uploads multiple attachments and returns every outcome as one [Result]. diff --git a/packages/stream_core/test/attachment/attachment_uploader_test.dart b/packages/stream_core/test/attachment/attachment_uploader_test.dart index 99c9a8d4..ff8463e5 100644 --- a/packages/stream_core/test/attachment/attachment_uploader_test.dart +++ b/packages/stream_core/test/attachment/attachment_uploader_test.dart @@ -1,22 +1,39 @@ +import 'dart:async'; + import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -StreamAttachment _attachment(String id, AttachmentFile file) => StreamAttachment( - id: id, - type: AttachmentType.file, - file: file, -); +StreamAttachment _attachment( + String id, + AttachmentFile file, { + AttachmentType type = AttachmentType.file, + Map? custom, +}) => StreamAttachment(id: id, type: type, file: file, custom: custom); -/// A CDN whose outcome per file is scripted by [outcomes]. +/// A CDN whose outcome per file is scripted by [outcomes], recording how each +/// upload was made. class _FakeCdn implements CdnClient { - _FakeCdn(this.outcomes); + _FakeCdn(this.outcomes, {this.progress = const {}}); + + final Map> Function()> outcomes; + final Map> progress; - final Map> outcomes; final cancelTokens = {}; + final methods = {}; - Future> _upload(AttachmentFile file, CancelToken? cancelToken) async { + Future> _upload( + String method, + AttachmentFile file, + ProgressCallback? onProgress, + CancelToken? cancelToken, + ) { + methods[file] = method; cancelTokens[file] = cancelToken; - return outcomes[file]!; + for (final (sent, total) in progress[file] ?? const <(int, int)>[]) { + onProgress?.call(sent, total); + } + + return outcomes[file]!(); } @override @@ -24,14 +41,14 @@ class _FakeCdn implements CdnClient { AttachmentFile file, { ProgressCallback? onProgress, CancelToken? cancelToken, - }) => _upload(file, cancelToken); + }) => _upload('file', file, onProgress, cancelToken); @override Future> uploadImage( AttachmentFile image, { ProgressCallback? onProgress, CancelToken? cancelToken, - }) => _upload(image, cancelToken); + }) => _upload('image', image, onProgress, cancelToken); @override Future> deleteFile(String url, {CancelToken? cancelToken}) async => const Result.success(null); @@ -40,7 +57,12 @@ class _FakeCdn implements CdnClient { Future> deleteImage(String url, {CancelToken? cancelToken}) async => const Result.success(null); } -const _uploadedFile = UploadedFile(fileUrl: 'https://cdn/file'); +Future> Function() _succeeds([UploadedFile file = _uploadedFile]) => + () async => Result.success(file); +Future> Function() _fails() => + () async => const Result.failure(_refused); + +const _uploadedFile = UploadedFile(fileUrl: 'https://cdn/file', thumbUrl: 'https://cdn/thumb'); const _refused = StreamApiException(message: 'too large', statusCode: 413, code: StreamErrorCode.payloadTooBig); void main() { @@ -48,10 +70,26 @@ void main() { final fileB = AttachmentFile.fromData(Uint8List(0)); group('upload', () { - test('reports the upload failure itself, unwrapped', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: const Result.failure(_refused)}), + test('maps the CDN response onto the attachment it uploaded', () async { + final uploader = StreamAttachmentUploader(cdn: _FakeCdn({fileA: _succeeds()})); + + final result = await uploader.upload( + _attachment('a', fileA, type: AttachmentType.image, custom: const {'k': 'v'}), + ); + + expect( + result.getOrNull(), + isA() + .having((it) => it.id, 'id', 'a') + .having((it) => it.type, 'type', AttachmentType.image) + .having((it) => it.custom, 'custom', const {'k': 'v'}) + .having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/file') + .having((it) => it.thumbnailUrl, 'thumbnailUrl', 'https://cdn/thumb'), ); + }); + + test('reports the upload failure itself, unwrapped', () async { + final uploader = StreamAttachmentUploader(cdn: _FakeCdn({fileA: _fails()})); final result = await uploader.upload(_attachment('a', fileA)); @@ -60,8 +98,19 @@ void main() { expect(result.exceptionOrNull(), same(_refused)); }); + test('routes an image through the image upload and everything else through the file one', () async { + final cdn = _FakeCdn({fileA: _succeeds(), fileB: _succeeds()}); + final uploader = StreamAttachmentUploader(cdn: cdn); + + await uploader.upload(_attachment('a', fileA, type: AttachmentType.image)); + await uploader.upload(_attachment('b', fileB, type: AttachmentType.video)); + + expect(cdn.methods[fileA], 'image'); + expect(cdn.methods[fileB], 'file'); + }); + test('hands the cancel token to the CDN', () async { - final cdn = _FakeCdn({fileA: const Result.success(_uploadedFile)}); + final cdn = _FakeCdn({fileA: _succeeds()}); final uploader = StreamAttachmentUploader(cdn: cdn); final cancelToken = CancelToken(); @@ -69,15 +118,27 @@ void main() { expect(cdn.cancelTokens[fileA], same(cancelToken)); }); + + test('normalizes progress to a fraction, clamped, with an empty total as zero', () async { + final cdn = _FakeCdn( + {fileA: _succeeds()}, + progress: { + fileA: [(5, 10), (0, 0), (20, 10)], + }, + ); + final uploader = StreamAttachmentUploader(cdn: cdn); + final seen = []; + + await uploader.upload(_attachment('a', fileA), onProgress: seen.add); + + expect(seen, [0.5, 0.0, 1.0]); + }); }); group('uploadBatch', () { test('pairs every outcome with its attachment', () async { final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({ - fileA: const Result.success(_uploadedFile), - fileB: const Result.failure(_refused), - }), + cdn: _FakeCdn({fileA: _succeeds(), fileB: _fails()}), ); final outcomes = await uploader.uploadBatch([_attachment('a', fileA), _attachment('b', fileB)]).toList(); @@ -86,15 +147,84 @@ void main() { expect(byId['a'], isA>()); expect(byId['b']?.exceptionOrNull(), same(_refused)); }); + + test('emits in completion order, not input order', () async { + final slow = Completer>(); + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: () => slow.future, fileB: _succeeds()}), + ); + + final order = []; + await uploader.uploadBatch([_attachment('a', fileA), _attachment('b', fileB)]).forEach((outcome) { + order.add(outcome.attachmentId); + + // The first attachment finishes only after the second already has. + if (outcome.attachmentId == 'b') { + slow.complete(const Result.success(_uploadedFile)); + } + }); + + expect(order, ['b', 'a']); + }); + + test('reports the per-attachment progress under its id', () async { + final cdn = _FakeCdn( + {fileA: _succeeds()}, + progress: { + fileA: [(5, 10)], + }, + ); + final uploader = StreamAttachmentUploader(cdn: cdn); + final seen = <(String, double)>[]; + + await uploader.uploadBatch( + [_attachment('a', fileA)], + onProgress: (attachmentId, progress) => seen.add((attachmentId, progress)), + ).drain(); + + expect(seen, [('a', 0.5)]); + }); + + test('emits nothing for an empty batch', () async { + final uploader = StreamAttachmentUploader(cdn: _FakeCdn({})); + + expect(await uploader.uploadBatch(const []).toList(), isEmpty); + }); + + test('with eagerError, throws the first failure and closes', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _fails(), fileB: _succeeds()}), + ); + + final outcomes = uploader.uploadBatch( + [_attachment('a', fileA), _attachment('b', fileB)], + maxConcurrent: 1, + eagerError: true, + ); + + await expectLater(outcomes, emitsError(same(_refused))); + }); + + test('without eagerError, emits the failure and continues', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _fails(), fileB: _succeeds()}), + ); + + final outcomes = await uploader.uploadBatch( + [_attachment('a', fileA), _attachment('b', fileB)], + maxConcurrent: 1, + ).toList(); + + expect(outcomes.map((it) => it.attachmentId), ['a', 'b']); + expect(outcomes.first.result, isA()); + expect(outcomes.last.result, isA>()); + }); }); group('uploadAll', () { test('succeeds with every uploaded attachment', () async { final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({ - fileA: const Result.success(_uploadedFile), - fileB: const Result.success(_uploadedFile), - }), + cdn: _FakeCdn({fileA: _succeeds(), fileB: _succeeds()}), ); final result = await uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); @@ -104,15 +234,20 @@ void main() { test('fails as one with the first failure', () async { final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({ - fileA: const Result.success(_uploadedFile), - fileB: const Result.failure(_refused), - }), + cdn: _FakeCdn({fileA: _succeeds(), fileB: _fails()}), ); final result = await uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); expect(result.exceptionOrNull(), same(_refused)); }); + + test('succeeds empty for an empty batch', () async { + final uploader = StreamAttachmentUploader(cdn: _FakeCdn({})); + + final result = await uploader.uploadAll(const []); + + expect(result.getOrNull(), isEmpty); + }); }); } From 37558d4619af7de7e95ccf0737cea9266ec84913 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 17:41:21 +0200 Subject: [PATCH 47/78] test(llc): cover the upload cancellation flows the chat SDK drives A cancel mid-upload settles as a cancelled network failure, one attachment's cancel leaves the rest of its batch untouched, and a cancelled attachment retries cleanly with a fresh token. Co-Authored-By: Claude Fable 5 --- .../attachment/attachment_uploader_test.dart | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/stream_core/test/attachment/attachment_uploader_test.dart b/packages/stream_core/test/attachment/attachment_uploader_test.dart index ff8463e5..f6286484 100644 --- a/packages/stream_core/test/attachment/attachment_uploader_test.dart +++ b/packages/stream_core/test/attachment/attachment_uploader_test.dart @@ -62,8 +62,18 @@ Future> Function() _succeeds([UploadedFile file = _uploaded Future> Function() _fails() => () async => const Result.failure(_refused); +/// An upload that stays in flight until [token] is cancelled, then settles the +/// way a cancelled CDN request does. +Future> Function() _cancelsWith(CancelToken token) => () async { + await token.whenCancel; + return const Result.failure(_cancelled); +}; + const _uploadedFile = UploadedFile(fileUrl: 'https://cdn/file', thumbUrl: 'https://cdn/thumb'); const _refused = StreamApiException(message: 'too large', statusCode: 413, code: StreamErrorCode.payloadTooBig); +const _cancelled = StreamNetworkException(message: 'The upload was cancelled', isCancelled: true); + +final Matcher _isCancelledFailure = isA().having((it) => it.isCancelled, 'isCancelled', isTrue); void main() { final fileA = AttachmentFile.fromData(Uint8List(0)); @@ -119,6 +129,65 @@ void main() { expect(cdn.cancelTokens[fileA], same(cancelToken)); }); + test('cancelling mid-upload settles it as a cancelled failure', () async { + final cancelToken = CancelToken(); + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _cancelsWith(cancelToken)}), + ); + + final pending = uploader.upload(_attachment('a', fileA), cancelToken: cancelToken); + cancelToken.cancel(); + + final result = await pending; + expect(result.exceptionOrNull(), _isCancelledFailure); + }); + + test('cancelling one upload leaves another in flight untouched', () async { + final cancelToken = CancelToken(); + final slow = Completer>(); + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _cancelsWith(cancelToken), fileB: () => slow.future}), + ); + + final cancelled = uploader.upload(_attachment('a', fileA), cancelToken: cancelToken); + final untouched = uploader.upload(_attachment('b', fileB), cancelToken: CancelToken()); + + cancelToken.cancel(); + expect((await cancelled).exceptionOrNull(), _isCancelledFailure); + + // The other upload is still in flight and completes on its own terms. + slow.complete(const Result.success(_uploadedFile)); + expect( + (await untouched).getOrNull(), + isA().having((it) => it.id, 'id', 'b'), + ); + }); + + test('a cancelled attachment can be retried with a fresh token', () async { + final cancelToken = CancelToken(); + var attempts = 0; + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({ + fileA: () { + attempts += 1; + if (attempts == 1) return _cancelsWith(cancelToken)(); + return _succeeds()(); + }, + }), + ); + final attachment = _attachment('a', fileA); + + final first = uploader.upload(attachment, cancelToken: cancelToken); + cancelToken.cancel(); + expect((await first).exceptionOrNull(), _isCancelledFailure); + + final retried = await uploader.upload(attachment, cancelToken: CancelToken()); + expect( + retried.getOrNull(), + isA().having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/file'), + ); + }); + test('normalizes progress to a fraction, clamped, with an empty total as zero', () async { final cdn = _FakeCdn( {fileA: _succeeds()}, @@ -185,6 +254,20 @@ void main() { expect(seen, [('a', 0.5)]); }); + test('a cancelled upload reads as cancelled while the rest of the batch lands', () async { + final cancelToken = CancelToken(); + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _cancelsWith(cancelToken), fileB: _succeeds()}), + ); + + final outcomes = uploader.uploadBatch([_attachment('a', fileA), _attachment('b', fileB)]).toList(); + cancelToken.cancel(); + + final byId = {for (final (:attachmentId, :result) in await outcomes) attachmentId: result}; + expect(byId['a']?.exceptionOrNull(), _isCancelledFailure); + expect(byId['b'], isA>()); + }); + test('emits nothing for an empty batch', () async { final uploader = StreamAttachmentUploader(cdn: _FakeCdn({})); @@ -242,6 +325,18 @@ void main() { expect(result.exceptionOrNull(), same(_refused)); }); + test('fails as one with the cancelled failure when an upload is called off', () async { + final cancelToken = CancelToken(); + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _cancelsWith(cancelToken), fileB: _succeeds()}), + ); + + final pending = uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); + cancelToken.cancel(); + + expect((await pending).exceptionOrNull(), _isCancelledFailure); + }); + test('succeeds empty for an empty batch', () async { final uploader = StreamAttachmentUploader(cdn: _FakeCdn({})); From 0c374dc4cee66fb06a3b36afc0cdcd557cd7bf35 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 18:30:52 +0200 Subject: [PATCH 48/78] test(llc): cover the remaining chat upload flows in the uploader The image endpoint's thumbnail-less response maps cleanly, a failed upload retries to success the way chat's retryAttachmentUpload does, and uploadBatch holds work back until a maxConcurrent slot frees up. Co-Authored-By: Claude Fable 5 --- .../attachment/attachment_uploader_test.dart | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/stream_core/test/attachment/attachment_uploader_test.dart b/packages/stream_core/test/attachment/attachment_uploader_test.dart index f6286484..990021ee 100644 --- a/packages/stream_core/test/attachment/attachment_uploader_test.dart +++ b/packages/stream_core/test/attachment/attachment_uploader_test.dart @@ -108,6 +108,21 @@ void main() { expect(result.exceptionOrNull(), same(_refused)); }); + test('maps an image upload without a thumbnail, the way the image endpoint responds', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _succeeds(const UploadedFile(fileUrl: 'https://cdn/image'))}), + ); + + final result = await uploader.upload(_attachment('a', fileA, type: AttachmentType.image)); + + expect( + result.getOrNull(), + isA() + .having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/image') + .having((it) => it.thumbnailUrl, 'thumbnailUrl', isNull), + ); + }); + test('routes an image through the image upload and everything else through the file one', () async { final cdn = _FakeCdn({fileA: _succeeds(), fileB: _succeeds()}); final uploader = StreamAttachmentUploader(cdn: cdn); @@ -163,6 +178,29 @@ void main() { ); }); + test('a failed upload can be retried, the fresh attempt succeeding', () async { + var attempts = 0; + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({ + fileA: () { + attempts += 1; + if (attempts == 1) return _fails()(); + return _succeeds()(); + }, + }), + ); + final attachment = _attachment('a', fileA); + + final first = await uploader.upload(attachment); + expect(first.exceptionOrNull(), same(_refused)); + + final retried = await uploader.upload(attachment); + expect( + retried.getOrNull(), + isA().having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/file'), + ); + }); + test('a cancelled attachment can be retried with a fresh token', () async { final cancelToken = CancelToken(); var attempts = 0; @@ -236,6 +274,35 @@ void main() { expect(order, ['b', 'a']); }); + test('holds uploads back until a slot frees up under maxConcurrent', () async { + final gate = Completer>(); + final started = []; + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({ + fileA: () { + started.add('a'); + return gate.future; + }, + fileB: () { + started.add('b'); + return _succeeds()(); + }, + }), + ); + + final outcomes = uploader.uploadBatch( + [_attachment('a', fileA), _attachment('b', fileB)], + maxConcurrent: 1, + ).toList(); + + await pumpEventQueue(); + expect(started, ['a']); + + gate.complete(const Result.success(_uploadedFile)); + await outcomes; + expect(started, ['a', 'b']); + }); + test('reports the per-attachment progress under its id', () async { final cdn = _FakeCdn( {fileA: _succeeds()}, From eb41393035e9b95f739a5b7354680a63f6b60e03 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 18:38:34 +0200 Subject: [PATCH 49/78] feat(llc): let uploadAll skip failures instead of failing as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An eagerError flag, true by default to keep the all-or-nothing contract; when false the success carries only what uploaded, leaving the failed attachments to a later attempt — the shape feeds' partial upload flow needs, so it can drop its private fold. Co-Authored-By: Claude Fable 5 --- .../uploader/attachment_uploader.dart | 21 ++++++++++--------- .../attachment/attachment_uploader_test.dart | 13 ++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart index 518bc0fb..6758a874 100644 --- a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart +++ b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart @@ -150,14 +150,17 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { /// Uploads multiple attachments and returns every outcome as one [Result]. /// - /// A success carries every [UploadedAttachment]; the first failure to - /// complete becomes the result's, and uploads already in flight are not - /// awaited further. Progress updates are provided through the optional - /// [onProgress] callback. + /// When [eagerError] is true (default), the first failure to complete + /// becomes the result's, and uploads already in flight are not awaited + /// further. When false, failed uploads are skipped and the success carries + /// only the attachments that made it, leaving the rest to a later attempt. + /// + /// Progress updates are provided through the optional [onProgress] callback. Future>> uploadAll( Iterable attachments, { OnBatchUploadProgress? onProgress, int maxConcurrent = 5, + bool eagerError = true, }) async { final uploaded = []; @@ -168,12 +171,10 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { ); await for (final (attachmentId: _, :result) in outcomes) { - switch (result) { - case Success(:final data): - uploaded.add(data); - case final Failure failure: - return failure; - } + // If eagerError is enabled, fail as one with the first failure + if (result case Failure() when eagerError) return result; + + result.onSuccess(uploaded.add); } return Result.success(uploaded); diff --git a/packages/stream_core/test/attachment/attachment_uploader_test.dart b/packages/stream_core/test/attachment/attachment_uploader_test.dart index 990021ee..e73cf814 100644 --- a/packages/stream_core/test/attachment/attachment_uploader_test.dart +++ b/packages/stream_core/test/attachment/attachment_uploader_test.dart @@ -392,6 +392,19 @@ void main() { expect(result.exceptionOrNull(), same(_refused)); }); + test('without eagerError, succeeds with only what uploaded, skipping the failures', () async { + final uploader = StreamAttachmentUploader( + cdn: _FakeCdn({fileA: _succeeds(), fileB: _fails()}), + ); + + final result = await uploader.uploadAll( + [_attachment('a', fileA), _attachment('b', fileB)], + eagerError: false, + ); + + expect(result.getOrNull()?.map((it) => it.id), ['a']); + }); + test('fails as one with the cancelled failure when an upload is called off', () async { final cancelToken = CancelToken(); final uploader = StreamAttachmentUploader( From 03d8d05014e774b1a5e1565bfae14415038ca346 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 27 Aug 2026 18:46:47 +0200 Subject: [PATCH 50/78] feat(llc): report the eager batch failure as an outcome, not a throw Throwing ripped the error out of its record, losing the attachmentId and forcing try/catch onto a Result-first API. Now eagerError closes the stream right after the failed outcome, and uploadAll simply forwards the flag instead of re-deriving it at the fold. Co-Authored-By: Claude Fable 5 --- .../uploader/attachment_uploader.dart | 20 +++++++------- .../attachment/attachment_uploader_test.dart | 27 +++++++++++-------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart index 6758a874..d8781a50 100644 --- a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart +++ b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart @@ -107,9 +107,10 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { /// attachment's outcome can be acted on the moment it lands. Progress /// updates are provided through the optional [onProgress] callback. /// - /// When [eagerError] is true, the stream throws the first upload's failure - /// and closes. When false (default), failed uploads are emitted as failures - /// and processing continues. + /// When [eagerError] is true, the stream closes right after the first + /// failed upload's outcome, and uploads not yet started never run. When + /// false (default), failed uploads are emitted as failures and processing + /// continues. /// /// Returns a [Stream] of outcomes in completion order, not input order. For /// all the outcomes as one all-or-nothing result, consider [uploadAll]. @@ -138,13 +139,10 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { // Yield outcomes as they complete await for (final outcome in uploadStream) { - // If eagerError is enabled, throw on first failure - if (outcome.result.exceptionOrNull() case final error? when eagerError) { - final stackTrace = outcome.result.stackTraceOrNull(); - Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); - } - yield outcome; + + // If eagerError is enabled, close after the first failure + if (outcome.result case Failure() when eagerError) return; } } @@ -162,14 +160,14 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { int maxConcurrent = 5, bool eagerError = true, }) async { - final uploaded = []; - final outcomes = uploadBatch( attachments, onProgress: onProgress, maxConcurrent: maxConcurrent, + eagerError: eagerError, ); + final uploaded = []; await for (final (attachmentId: _, :result) in outcomes) { // If eagerError is enabled, fail as one with the first failure if (result case Failure() when eagerError) return result; diff --git a/packages/stream_core/test/attachment/attachment_uploader_test.dart b/packages/stream_core/test/attachment/attachment_uploader_test.dart index e73cf814..4f41de04 100644 --- a/packages/stream_core/test/attachment/attachment_uploader_test.dart +++ b/packages/stream_core/test/attachment/attachment_uploader_test.dart @@ -341,18 +341,23 @@ void main() { expect(await uploader.uploadBatch(const []).toList(), isEmpty); }); - test('with eagerError, throws the first failure and closes', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _fails(), fileB: _succeeds()}), - ); - - final outcomes = uploader.uploadBatch( - [_attachment('a', fileA), _attachment('b', fileB)], - maxConcurrent: 1, - eagerError: true, - ); + test('with eagerError, emits the first failure and closes, never starting the rest', () async { + final cdn = _FakeCdn({fileA: _fails(), fileB: _succeeds()}); + final uploader = StreamAttachmentUploader(cdn: cdn); - await expectLater(outcomes, emitsError(same(_refused))); + final outcomes = await uploader + .uploadBatch( + [_attachment('a', fileA), _attachment('b', fileB)], + maxConcurrent: 1, + eagerError: true, + ) + .toList(); + + // The failure arrives as an outcome like any other, still paired with + // the attachment it belongs to. + expect(outcomes.map((it) => it.attachmentId), ['a']); + expect(outcomes.single.result.exceptionOrNull(), same(_refused)); + expect(cdn.methods.keys, isNot(contains(fileB))); }); test('without eagerError, emits the failure and continues', () async { From 5bfed7a8999a4330ddab53c764065b7bfae5bfcb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 13:20:24 +0200 Subject: [PATCH 51/78] revert(llc): hold the attachment uploader rework back for a follow-up The uploader returns to main's shape so this PR stays scoped to the error layer itself; the uploader's adoption of it ships separately. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 6 +- packages/stream_core/CHANGELOG.md | 1 - .../uploader/attachment_uploader.dart | 130 +++--- .../attachment/attachment_uploader_test.dart | 433 ------------------ 4 files changed, 61 insertions(+), 509 deletions(-) delete mode 100644 packages/stream_core/test/attachment/attachment_uploader_test.dart diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index de0879a4..4ed8665c 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -181,10 +181,8 @@ The two rules you actually need: three-question tree above says which exception. 2. **A new exception type needs a new reaction to justify it.** If the catcher of your proposed type would do the same thing they'd do for an existing category, it is not a new type — it is a field - or a `code`. Context (like "which attachment failed") travels in the data channel - (`(attachmentId, Result)`), never by wrapping one category inside another. When a whole batch - fails before any item starts, every item reports the same failure — per-item results are the - contract, and a pre-flight failure is every item failing the same way. + or a `code`. Context (like "which item of a batch failed") travels in the data channel, beside + the outcome, never by wrapping one category inside another. Product SDKs (Chat, Video, Feeds) may extend a category — `StreamChatApiException extends StreamApiException` — but never add a fifth top-level kind and never re-map a core exception into an diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 049ccbb4..c2345ef0 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,7 +15,6 @@ - `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` rather than an engine exception and `Object?`, so a `Disconnected` state carries the same error kinds a failed request does - `TokenManager.getToken` fails with a `StreamAuthenticationException` — when no identity is configured, when `reset` races a load, and when the `TokenProvider` fails, in which case whatever it threw is preserved as the exception's `cause` - Replaced the `StreamApiError` predicates: `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes), and `isRateLimited` reads the HTTP status. The same predicates exist on `StreamApiException` -- Removed `AttachmentUploadException`: `StreamAttachmentUploader.upload` reports the upload's own failure unwrapped, and `uploadBatch` emits `(attachmentId, result)` records so which attachment failed travels beside the outcome rather than inside it. `upload` takes a `cancelToken`, `uploadBatch` loses `eagerError`, and `uploadAll` returns every outcome as one all-or-nothing `Result` - Credentials whose connection attempt was abandoned fail with a `StreamNetworkException` inside the `Result` rather than a `StateError`. A token provider that fails, or returns a token for another user, fails `getToken` with a `StreamAuthenticationException` rather than a raw error - `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 for one not valid yet, and stays `false` for a refused signature or API key, any other 4xx, and whenever the server marked the error `unrecoverable` diff --git a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart index d8781a50..dc4724e9 100644 --- a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart +++ b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:dio/dio.dart' show CancelToken; import 'package:rxdart/rxdart.dart'; import '../../utils.dart'; @@ -14,14 +13,26 @@ import 'uploaded_attachment.dart'; /// Receives the upload [progress] as a value between 0.0 and 1.0. typedef OnUploadProgress = void Function(double progress); -/// The outcome of one attachment's upload within a batch, paired with the -/// attachment it belongs to. +/// Exception thrown when an attachment upload fails. /// -/// The failure inside the result is the upload's own error, unwrapped — an -/// upload refused by the server reads as the same exception kind a refused -/// request does. Which attachment it concerns travels here, beside the -/// outcome, rather than inside it. -typedef AttachmentUploadResult = ({String attachmentId, Result result}); +/// Provides context about which specific attachment failed and the underlying +/// cause for debugging upload issues. +class AttachmentUploadException implements Exception { + /// Creates an [AttachmentUploadException] with the specified [id] and [cause]. + const AttachmentUploadException({ + required this.id, + required this.cause, + }); + + /// The ID of the attachment that failed to upload. + final String id; + + /// The underlying cause of the upload failure. + final Object cause; + + @override + String toString() => 'AttachmentUploadException(id: $id, cause: $cause)'; +} /// Uploads [StreamAttachment] objects to remote storage. /// @@ -50,14 +61,12 @@ class StreamAttachmentUploader { /// Uploads a single attachment to remote storage. /// - /// Returns a [Result] containing the [UploadedAttachment] on success, or - /// the upload's own failure otherwise. Progress updates are provided - /// through the optional [onProgress] callback, and the upload can be called - /// off through [cancelToken], which reads as a cancelled failure. + /// Returns a [Result] containing the [UploadedAttachment] on success or + /// an [AttachmentUploadException] on failure. Progress updates are provided + /// through the optional [onProgress] callback. Future> upload( StreamAttachment attachment, { OnUploadProgress? onProgress, - CancelToken? cancelToken, }) async { final uploadFn = switch (attachment.type) { AttachmentType.image => _cdn.uploadImage, @@ -66,7 +75,6 @@ class StreamAttachmentUploader { final result = await uploadFn( attachment.file, - cancelToken: cancelToken, onProgress: onProgress?.let( (f) => (uploaded, total) { if (total == 0) return f(0); @@ -76,14 +84,26 @@ class StreamAttachmentUploader { ), ); - return result.map( - (data) => UploadedAttachment( - id: attachment.id, - type: attachment.type, - custom: attachment.custom, - remoteUrl: data.fileUrl, - thumbnailUrl: data.thumbUrl, - ), + return result.fold( + onSuccess: (data) { + final uploaded = UploadedAttachment( + id: attachment.id, + type: attachment.type, + custom: attachment.custom, + remoteUrl: data.fileUrl, + thumbnailUrl: data.thumbUrl, + ); + + return Result.success(uploaded); + }, + onFailure: (cause, stackTrace) { + final ex = AttachmentUploadException( + id: attachment.id, + cause: cause, + ); + + return Result.failure(ex, stackTrace); + }, ); } } @@ -100,21 +120,18 @@ typedef OnBatchUploadProgress = void Function(String attachmentId, double progre /// as individual uploads complete, enabling immediate UI updates and partial /// success handling. extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { - /// Uploads multiple attachments as a stream of per-attachment outcomes. + /// Uploads multiple attachments as a stream of results. /// /// Processes [attachments] concurrently with [maxConcurrent] limit, emitting - /// an [AttachmentUploadResult] as each upload completes, so each - /// attachment's outcome can be acted on the moment it lands. Progress - /// updates are provided through the optional [onProgress] callback. + /// [Result] objects as each upload completes. Progress updates are provided + /// through the optional [onProgress] callback. /// - /// When [eagerError] is true, the stream closes right after the first - /// failed upload's outcome, and uploads not yet started never run. When - /// false (default), failed uploads are emitted as failures and processing - /// continues. + /// When [eagerError] is true, the stream throws an exception and closes + /// immediately on the first upload failure. When false (default), failed + /// uploads are emitted as [Result.failure] and processing continues. /// - /// Returns a [Stream] of outcomes in completion order, not input order. For - /// all the outcomes as one all-or-nothing result, consider [uploadAll]. - Stream uploadBatch( + /// Returns a [Stream] of [Result] objects in completion order, not input order. + Stream> uploadBatch( Iterable attachments, { OnBatchUploadProgress? onProgress, int maxConcurrent = 5, @@ -133,48 +150,19 @@ extension StreamAttachmentUploaderBatch on StreamAttachmentUploader { (f) => (progress) => f(attachment.id, progress), ), - ).then((result) => (attachmentId: attachment.id, result: result)), + ), ), ); - // Yield outcomes as they complete - await for (final outcome in uploadStream) { - yield outcome; - - // If eagerError is enabled, close after the first failure - if (outcome.result case Failure() when eagerError) return; - } - } - - /// Uploads multiple attachments and returns every outcome as one [Result]. - /// - /// When [eagerError] is true (default), the first failure to complete - /// becomes the result's, and uploads already in flight are not awaited - /// further. When false, failed uploads are skipped and the success carries - /// only the attachments that made it, leaving the rest to a later attempt. - /// - /// Progress updates are provided through the optional [onProgress] callback. - Future>> uploadAll( - Iterable attachments, { - OnBatchUploadProgress? onProgress, - int maxConcurrent = 5, - bool eagerError = true, - }) async { - final outcomes = uploadBatch( - attachments, - onProgress: onProgress, - maxConcurrent: maxConcurrent, - eagerError: eagerError, - ); - - final uploaded = []; - await for (final (attachmentId: _, :result) in outcomes) { - // If eagerError is enabled, fail as one with the first failure - if (result case Failure() when eagerError) return result; + // Yield results as they complete + await for (final result in uploadStream) { + // If eagerError is enabled, throw on first failure + if (result.exceptionOrNull() case final error? when eagerError) { + final stackTrace = result.stackTraceOrNull(); + Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); + } - result.onSuccess(uploaded.add); + yield result; } - - return Result.success(uploaded); } } diff --git a/packages/stream_core/test/attachment/attachment_uploader_test.dart b/packages/stream_core/test/attachment/attachment_uploader_test.dart deleted file mode 100644 index 4f41de04..00000000 --- a/packages/stream_core/test/attachment/attachment_uploader_test.dart +++ /dev/null @@ -1,433 +0,0 @@ -import 'dart:async'; - -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -StreamAttachment _attachment( - String id, - AttachmentFile file, { - AttachmentType type = AttachmentType.file, - Map? custom, -}) => StreamAttachment(id: id, type: type, file: file, custom: custom); - -/// A CDN whose outcome per file is scripted by [outcomes], recording how each -/// upload was made. -class _FakeCdn implements CdnClient { - _FakeCdn(this.outcomes, {this.progress = const {}}); - - final Map> Function()> outcomes; - final Map> progress; - - final cancelTokens = {}; - final methods = {}; - - Future> _upload( - String method, - AttachmentFile file, - ProgressCallback? onProgress, - CancelToken? cancelToken, - ) { - methods[file] = method; - cancelTokens[file] = cancelToken; - for (final (sent, total) in progress[file] ?? const <(int, int)>[]) { - onProgress?.call(sent, total); - } - - return outcomes[file]!(); - } - - @override - Future> uploadFile( - AttachmentFile file, { - ProgressCallback? onProgress, - CancelToken? cancelToken, - }) => _upload('file', file, onProgress, cancelToken); - - @override - Future> uploadImage( - AttachmentFile image, { - ProgressCallback? onProgress, - CancelToken? cancelToken, - }) => _upload('image', image, onProgress, cancelToken); - - @override - Future> deleteFile(String url, {CancelToken? cancelToken}) async => const Result.success(null); - - @override - Future> deleteImage(String url, {CancelToken? cancelToken}) async => const Result.success(null); -} - -Future> Function() _succeeds([UploadedFile file = _uploadedFile]) => - () async => Result.success(file); -Future> Function() _fails() => - () async => const Result.failure(_refused); - -/// An upload that stays in flight until [token] is cancelled, then settles the -/// way a cancelled CDN request does. -Future> Function() _cancelsWith(CancelToken token) => () async { - await token.whenCancel; - return const Result.failure(_cancelled); -}; - -const _uploadedFile = UploadedFile(fileUrl: 'https://cdn/file', thumbUrl: 'https://cdn/thumb'); -const _refused = StreamApiException(message: 'too large', statusCode: 413, code: StreamErrorCode.payloadTooBig); -const _cancelled = StreamNetworkException(message: 'The upload was cancelled', isCancelled: true); - -final Matcher _isCancelledFailure = isA().having((it) => it.isCancelled, 'isCancelled', isTrue); - -void main() { - final fileA = AttachmentFile.fromData(Uint8List(0)); - final fileB = AttachmentFile.fromData(Uint8List(0)); - - group('upload', () { - test('maps the CDN response onto the attachment it uploaded', () async { - final uploader = StreamAttachmentUploader(cdn: _FakeCdn({fileA: _succeeds()})); - - final result = await uploader.upload( - _attachment('a', fileA, type: AttachmentType.image, custom: const {'k': 'v'}), - ); - - expect( - result.getOrNull(), - isA() - .having((it) => it.id, 'id', 'a') - .having((it) => it.type, 'type', AttachmentType.image) - .having((it) => it.custom, 'custom', const {'k': 'v'}) - .having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/file') - .having((it) => it.thumbnailUrl, 'thumbnailUrl', 'https://cdn/thumb'), - ); - }); - - test('reports the upload failure itself, unwrapped', () async { - final uploader = StreamAttachmentUploader(cdn: _FakeCdn({fileA: _fails()})); - - final result = await uploader.upload(_attachment('a', fileA)); - - // The failure stays catchable by kind; which attachment it was is the - // caller's knowledge, not the error's. - expect(result.exceptionOrNull(), same(_refused)); - }); - - test('maps an image upload without a thumbnail, the way the image endpoint responds', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _succeeds(const UploadedFile(fileUrl: 'https://cdn/image'))}), - ); - - final result = await uploader.upload(_attachment('a', fileA, type: AttachmentType.image)); - - expect( - result.getOrNull(), - isA() - .having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/image') - .having((it) => it.thumbnailUrl, 'thumbnailUrl', isNull), - ); - }); - - test('routes an image through the image upload and everything else through the file one', () async { - final cdn = _FakeCdn({fileA: _succeeds(), fileB: _succeeds()}); - final uploader = StreamAttachmentUploader(cdn: cdn); - - await uploader.upload(_attachment('a', fileA, type: AttachmentType.image)); - await uploader.upload(_attachment('b', fileB, type: AttachmentType.video)); - - expect(cdn.methods[fileA], 'image'); - expect(cdn.methods[fileB], 'file'); - }); - - test('hands the cancel token to the CDN', () async { - final cdn = _FakeCdn({fileA: _succeeds()}); - final uploader = StreamAttachmentUploader(cdn: cdn); - final cancelToken = CancelToken(); - - await uploader.upload(_attachment('a', fileA), cancelToken: cancelToken); - - expect(cdn.cancelTokens[fileA], same(cancelToken)); - }); - - test('cancelling mid-upload settles it as a cancelled failure', () async { - final cancelToken = CancelToken(); - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _cancelsWith(cancelToken)}), - ); - - final pending = uploader.upload(_attachment('a', fileA), cancelToken: cancelToken); - cancelToken.cancel(); - - final result = await pending; - expect(result.exceptionOrNull(), _isCancelledFailure); - }); - - test('cancelling one upload leaves another in flight untouched', () async { - final cancelToken = CancelToken(); - final slow = Completer>(); - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _cancelsWith(cancelToken), fileB: () => slow.future}), - ); - - final cancelled = uploader.upload(_attachment('a', fileA), cancelToken: cancelToken); - final untouched = uploader.upload(_attachment('b', fileB), cancelToken: CancelToken()); - - cancelToken.cancel(); - expect((await cancelled).exceptionOrNull(), _isCancelledFailure); - - // The other upload is still in flight and completes on its own terms. - slow.complete(const Result.success(_uploadedFile)); - expect( - (await untouched).getOrNull(), - isA().having((it) => it.id, 'id', 'b'), - ); - }); - - test('a failed upload can be retried, the fresh attempt succeeding', () async { - var attempts = 0; - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({ - fileA: () { - attempts += 1; - if (attempts == 1) return _fails()(); - return _succeeds()(); - }, - }), - ); - final attachment = _attachment('a', fileA); - - final first = await uploader.upload(attachment); - expect(first.exceptionOrNull(), same(_refused)); - - final retried = await uploader.upload(attachment); - expect( - retried.getOrNull(), - isA().having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/file'), - ); - }); - - test('a cancelled attachment can be retried with a fresh token', () async { - final cancelToken = CancelToken(); - var attempts = 0; - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({ - fileA: () { - attempts += 1; - if (attempts == 1) return _cancelsWith(cancelToken)(); - return _succeeds()(); - }, - }), - ); - final attachment = _attachment('a', fileA); - - final first = uploader.upload(attachment, cancelToken: cancelToken); - cancelToken.cancel(); - expect((await first).exceptionOrNull(), _isCancelledFailure); - - final retried = await uploader.upload(attachment, cancelToken: CancelToken()); - expect( - retried.getOrNull(), - isA().having((it) => it.remoteUrl, 'remoteUrl', 'https://cdn/file'), - ); - }); - - test('normalizes progress to a fraction, clamped, with an empty total as zero', () async { - final cdn = _FakeCdn( - {fileA: _succeeds()}, - progress: { - fileA: [(5, 10), (0, 0), (20, 10)], - }, - ); - final uploader = StreamAttachmentUploader(cdn: cdn); - final seen = []; - - await uploader.upload(_attachment('a', fileA), onProgress: seen.add); - - expect(seen, [0.5, 0.0, 1.0]); - }); - }); - - group('uploadBatch', () { - test('pairs every outcome with its attachment', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _succeeds(), fileB: _fails()}), - ); - - final outcomes = await uploader.uploadBatch([_attachment('a', fileA), _attachment('b', fileB)]).toList(); - - final byId = {for (final (:attachmentId, :result) in outcomes) attachmentId: result}; - expect(byId['a'], isA>()); - expect(byId['b']?.exceptionOrNull(), same(_refused)); - }); - - test('emits in completion order, not input order', () async { - final slow = Completer>(); - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: () => slow.future, fileB: _succeeds()}), - ); - - final order = []; - await uploader.uploadBatch([_attachment('a', fileA), _attachment('b', fileB)]).forEach((outcome) { - order.add(outcome.attachmentId); - - // The first attachment finishes only after the second already has. - if (outcome.attachmentId == 'b') { - slow.complete(const Result.success(_uploadedFile)); - } - }); - - expect(order, ['b', 'a']); - }); - - test('holds uploads back until a slot frees up under maxConcurrent', () async { - final gate = Completer>(); - final started = []; - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({ - fileA: () { - started.add('a'); - return gate.future; - }, - fileB: () { - started.add('b'); - return _succeeds()(); - }, - }), - ); - - final outcomes = uploader.uploadBatch( - [_attachment('a', fileA), _attachment('b', fileB)], - maxConcurrent: 1, - ).toList(); - - await pumpEventQueue(); - expect(started, ['a']); - - gate.complete(const Result.success(_uploadedFile)); - await outcomes; - expect(started, ['a', 'b']); - }); - - test('reports the per-attachment progress under its id', () async { - final cdn = _FakeCdn( - {fileA: _succeeds()}, - progress: { - fileA: [(5, 10)], - }, - ); - final uploader = StreamAttachmentUploader(cdn: cdn); - final seen = <(String, double)>[]; - - await uploader.uploadBatch( - [_attachment('a', fileA)], - onProgress: (attachmentId, progress) => seen.add((attachmentId, progress)), - ).drain(); - - expect(seen, [('a', 0.5)]); - }); - - test('a cancelled upload reads as cancelled while the rest of the batch lands', () async { - final cancelToken = CancelToken(); - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _cancelsWith(cancelToken), fileB: _succeeds()}), - ); - - final outcomes = uploader.uploadBatch([_attachment('a', fileA), _attachment('b', fileB)]).toList(); - cancelToken.cancel(); - - final byId = {for (final (:attachmentId, :result) in await outcomes) attachmentId: result}; - expect(byId['a']?.exceptionOrNull(), _isCancelledFailure); - expect(byId['b'], isA>()); - }); - - test('emits nothing for an empty batch', () async { - final uploader = StreamAttachmentUploader(cdn: _FakeCdn({})); - - expect(await uploader.uploadBatch(const []).toList(), isEmpty); - }); - - test('with eagerError, emits the first failure and closes, never starting the rest', () async { - final cdn = _FakeCdn({fileA: _fails(), fileB: _succeeds()}); - final uploader = StreamAttachmentUploader(cdn: cdn); - - final outcomes = await uploader - .uploadBatch( - [_attachment('a', fileA), _attachment('b', fileB)], - maxConcurrent: 1, - eagerError: true, - ) - .toList(); - - // The failure arrives as an outcome like any other, still paired with - // the attachment it belongs to. - expect(outcomes.map((it) => it.attachmentId), ['a']); - expect(outcomes.single.result.exceptionOrNull(), same(_refused)); - expect(cdn.methods.keys, isNot(contains(fileB))); - }); - - test('without eagerError, emits the failure and continues', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _fails(), fileB: _succeeds()}), - ); - - final outcomes = await uploader.uploadBatch( - [_attachment('a', fileA), _attachment('b', fileB)], - maxConcurrent: 1, - ).toList(); - - expect(outcomes.map((it) => it.attachmentId), ['a', 'b']); - expect(outcomes.first.result, isA()); - expect(outcomes.last.result, isA>()); - }); - }); - - group('uploadAll', () { - test('succeeds with every uploaded attachment', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _succeeds(), fileB: _succeeds()}), - ); - - final result = await uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); - - expect(result.getOrNull()?.map((it) => it.id), unorderedEquals(['a', 'b'])); - }); - - test('fails as one with the first failure', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _succeeds(), fileB: _fails()}), - ); - - final result = await uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); - - expect(result.exceptionOrNull(), same(_refused)); - }); - - test('without eagerError, succeeds with only what uploaded, skipping the failures', () async { - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _succeeds(), fileB: _fails()}), - ); - - final result = await uploader.uploadAll( - [_attachment('a', fileA), _attachment('b', fileB)], - eagerError: false, - ); - - expect(result.getOrNull()?.map((it) => it.id), ['a']); - }); - - test('fails as one with the cancelled failure when an upload is called off', () async { - final cancelToken = CancelToken(); - final uploader = StreamAttachmentUploader( - cdn: _FakeCdn({fileA: _cancelsWith(cancelToken), fileB: _succeeds()}), - ); - - final pending = uploader.uploadAll([_attachment('a', fileA), _attachment('b', fileB)]); - cancelToken.cancel(); - - expect((await pending).exceptionOrNull(), _isCancelledFailure); - }); - - test('succeeds empty for an empty batch', () async { - final uploader = StreamAttachmentUploader(cdn: _FakeCdn({})); - - final result = await uploader.uploadAll(const []); - - expect(result.getOrNull(), isEmpty); - }); - }); -} From a6ac5fea0cd434dd78cd3f3ef751bc2193a4620f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 13:47:53 +0200 Subject: [PATCH 52/78] revert(llc): pull the retry helpers until chat consumes them isRetriable and RetryPolicy.standard() leave the PR; the decision procedure stays documented, and the helpers return with the first real retry queue built on them. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 50 +++++----- packages/stream_core/CHANGELOG.md | 1 - packages/stream_core/lib/src/errors.dart | 1 - .../lib/src/errors/retry_policy.dart | 72 -------------- .../test/errors/retry_policy_test.dart | 97 ------------------- 5 files changed, 24 insertions(+), 197 deletions(-) delete mode 100644 packages/stream_core/lib/src/errors/retry_policy.dart delete mode 100644 packages/stream_core/test/errors/retry_policy_test.dart diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 4ed8665c..c8e468f2 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -78,7 +78,7 @@ your provider is static, or the fresh token was refused too. | You caught | It means | You typically... | |---|---|---| -| `StreamApiException` | the server said no; `message`/`code`/`moreInfo` say why | show `message`; branch on `code` for special cases | +| `StreamApiException` | the server said no; `message`/`code`/`moreInfo` say why | show your own copy keyed off `code`; branch on it for special cases | | `StreamNetworkException` | the server was never heard from — the outcome is **unknown** | show offline UI, retry on connectivity; ignore if `isCancelled` | | `StreamAuthenticationException` | your token provider / login setup is broken | send the user through your auth flow again | | `StreamClientException` | the SDK (or a callback you gave it) hit an unexpected error | report to your crash tracker — not the end user's problem | @@ -93,8 +93,9 @@ codes to more than one status, so never infer one from the other. Failures arrive on two channels, carrying the same four types: -- **Operations** return `Result`; a `Failure` always holds a `StreamException` — enforced by the - type system (`Failure.error` is typed `StreamException`), not by convention. Nothing is thrown. +- **Operations** return `Result`; a `Failure` from an SDK operation always holds a + `StreamException` — every call runs through the seam that guarantees it (`runApiSafely`, below). + Nothing is thrown. - **Connection lifecycle** failures arrive as state: `connectionState` emits `Disconnected(source)`, where `source` says who ended the connection and carries the error when there was one: @@ -120,7 +121,7 @@ switch (result) { case Failure(:final error): switch (error) { case StreamApiException(isRateLimited: true): scheduleRetry(); - case StreamApiException(:final message): showError(message); + case StreamApiException(:final code): showError(copyFor(code)); case StreamNetworkException(isCancelled: true): break; // user navigated away case StreamNetworkException(): showOfflineBanner(); case StreamAuthenticationException(): redirectToLogin(); @@ -151,17 +152,23 @@ already carry the right type — if you are not writing a boundary, you never pi | HTTP error mapper (the only file that reads Dio) | `StreamApiException` from a server error body or bare status; `StreamNetworkException` from timeout / cancel / socket errors | | Response/event decoding | `StreamClientException` when wire data will not decode, whatever the decoder threw (see the seam rule below) | | WebSocket engine + auth handler | `StreamNetworkException` for transport failures; `StreamAuthenticationException` when credentials couldn't be sent; server error events become `StreamApiException` — the inner error object is the same as REST, but it arrives in two envelopes (`{"type":"connection.error",...}` from the monolith, bare `{"error":{...}}` from the edge) and the decoder must accept both | -| `TokenManager` | `StreamAuthenticationException` when the `TokenProvider` fails (its error preserved as `cause`), when no user is configured, or when a reset raced the load | -| `runSafely` (the normalization seam) | passes an existing `StreamException` through untouched; wraps any other `Exception` (an app callback no boundary owns) into `StreamClientException`, preserving `cause`. Does **not** catch `Error` — bugs propagate and crash loudly | +| `TokenManager` | `StreamAuthenticationException` when no user is configured, when a reset raced the load, or when the `TokenProvider` fails with something unclassified (preserved as `cause`); a provider failure that is already a `StreamException` passes through as itself, so a transient network failure stays retriable | +| `runApiSafely` (the API call seam) | passes an existing `StreamException` through untouched; maps Dio failures to `StreamApiException`/`StreamNetworkException`; wraps anything else — `Exception` or `Error` alike — into `StreamClientException`, preserving `cause` | -There are two kinds of seams, and they treat `Error` differently: +Both capture helpers catch **everything**, `Error` included — they differ in what they hand back: -- **Propagation seams** (`runSafely`, everything that moves `Result`s around) never catch `Error` — - a `StateError` or `TypeError` there is a bug in the program, and it should crash loudly. -- **Interpretation seams** (decoding a response body, decoding a WS event) catch **everything**, - `Error` included, and wrap it into `StreamClientException`. A `TypeError` thrown while decoding - wire data indicts the data, not the program — a server that renamed a field must surface as a - handleable failure, not a crash. +- **`runSafely`** (the generic capture) stores whatever was thrown in the `Failure` untouched — raw + truth, classified by the boundary above it via `StreamException.tryFrom` plus a kind-specific + fallback. +- **`runApiSafely`** (the API boundary) delivers only `StreamException`s. A `TypeError` thrown while + decoding wire data indicts the data, not the program — a server that renamed a field must surface + as a handleable failure, not a crash — so it arrives as a `StreamClientException` with the + original `Error` as `cause`. A `StateError` from a bug under the seam arrives the same way; it is + still a bug, so treat that `StreamClientException` as a crash report, not a condition to handle. + +The errors-vs-exceptions rule governs the throw site, not the catch site: SDK guards still throw +`StateError`/`ArgumentError` for misuse. A guard above any seam crashes loudly; one that fires under +a seam surfaces as the `cause` of a `StreamClientException`. A decode failure on a **live event stream** is the one failure with no operation to fail and no reason to kill a healthy connection: drop the event, log it through the SDK logger, and count it — @@ -220,19 +227,10 @@ The decision runs in order: `DisconnectionSource.isReconnectable` is this procedure specialized for the connection (reconnecting is inherently idempotent), and the interceptor's one-shot token refresh is the code-40 row. What -remains for callers is operation retry, expressed as a policy: - -```dart -abstract interface class RetryPolicy { - bool shouldRetry(StreamException error, int attempt); -} -``` - -Core ships two pieces of this: `StreamException.isRetriable`, the fact-level judgment (steps 1–2's -error-only rows — necessary, but not sufficient, since it cannot know the operation's idempotency), -and `RetryPolicy.standard()`, which composes it with an attempt budget. The policy answers -*whether*; *when* comes from `retryAfter` where the server named a wait, and from the caller's -backoff otherwise. +remains for callers is operation retry: steps 1–2 answer *whether* from the error alone (necessary, +but not sufficient, since the error cannot know the operation's idempotency), *when* comes from +`retryAfter` where the server named a wait and from the caller's backoff otherwise, and the budget +is the caller's. Product SDKs compose this into their retry queues. One honesty rule about retrying writes: a `StreamNetworkException` means the outcome is **unknown** — the server may have performed the operation. Retry a write only through an idempotent path diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index c2345ef0..943b6180 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -37,7 +37,6 @@ - Added `DioException.toStreamException()`, the failure a Dio error represents: a response — Stream error payload or bare status — reads as a `StreamApiException`, and anything that ended before a verdict as a `StreamNetworkException`, with cancellations and timeouts marked as such - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses - Added `StreamErrorCode`, the API's error-code registry as named constants over `int` — one shared vocabulary for every Stream product, tolerant of codes the SDK does not know yet. `StreamApiException.code` is typed with it -- Added `StreamException.isRetriable`, whether a failure is about the moment rather than the request — necessary but not sufficient, since re-sending safely also depends on the operation — and `RetryPolicy`, with `RetryPolicy.standard()` composing that judgment with an attempt budget - Added `runApiSafely`, the seam an API call crosses on its way to a caller: every failure it reports is a `StreamException` — transport failures mapped, a response that would not decode included - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none diff --git a/packages/stream_core/lib/src/errors.dart b/packages/stream_core/lib/src/errors.dart index 53bdfb04..63a5dc0e 100644 --- a/packages/stream_core/lib/src/errors.dart +++ b/packages/stream_core/lib/src/errors.dart @@ -1,4 +1,3 @@ -export 'errors/retry_policy.dart'; export 'errors/stream_api_error.dart'; export 'errors/stream_error_code.dart'; export 'errors/stream_exception.dart'; diff --git a/packages/stream_core/lib/src/errors/retry_policy.dart b/packages/stream_core/lib/src/errors/retry_policy.dart deleted file mode 100644 index 3615e819..00000000 --- a/packages/stream_core/lib/src/errors/retry_policy.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'stream_error_code.dart'; -import 'stream_exception.dart'; - -/// Decides whether a failed operation is worth attempting again. -/// -/// A retry decision has three inputs: what happened (the exception), what the -/// caller was doing, and how many attempts have been spent. The exception -/// knows only the first, which is why this is a policy the caller owns rather -/// than a property of the error. -/// -/// [RetryPolicy.standard] answers for the common case; products with their -/// own rules implement this interface. -abstract interface class RetryPolicy { - /// The default policy: retries what [StreamExceptionRetry.isRetriable] - /// allows, up to [StandardRetryPolicy.maxAttempts] attempts. - /// - /// Safe only for operations that can be re-sent without side effects — a - /// read, or a write carrying an idempotency key. A non-idempotent write - /// that fails without a verdict may already have been performed. - const factory RetryPolicy.standard({int maxAttempts}) = StandardRetryPolicy; - - /// Whether the operation that failed with [error] should be attempted - /// again. - /// - /// The [attempt] is the number of the attempt that just failed, starting - /// at 1. When the answer is yes, [StreamApiException.retryAfter] names the - /// wait when the server sent one. - bool shouldRetry(StreamException error, int attempt); -} - -/// The [RetryPolicy] used when a caller does not bring their own. -final class StandardRetryPolicy implements RetryPolicy { - /// Creates a [StandardRetryPolicy] allowing [maxAttempts] attempts. - const StandardRetryPolicy({this.maxAttempts = 3}); - - /// How many attempts an operation is given in total, the first included. - final int maxAttempts; - - @override - bool shouldRetry(StreamException error, int attempt) { - return attempt < maxAttempts && error.isRetriable; - } -} - -/// The retry judgment that can be made from a failure alone. -extension StreamExceptionRetry on StreamException { - /// Whether this failure is about the moment rather than about the request - /// or the setup, so a later attempt can end differently. - /// - /// True for rate limits, server-side faults and timeouts, tokens not valid - /// yet, and transport failures that were not cancelled. False for every - /// verdict a resend reproduces — validation, permissions, refused - /// signatures and keys — for credentials that never went out, and for - /// failures inside the SDK. - /// - /// Necessary, but not on its own sufficient: whether re-sending is *safe* - /// depends on the operation. A transport failure leaves the outcome - /// unknown, so a write is worth re-sending only through an idempotent - /// path. That knowledge is the caller's, which is why this getter feeds a - /// [RetryPolicy] rather than replacing one. - bool get isRetriable => switch (this) { - StreamApiException(unrecoverable: true) => false, - StreamApiException(isRateLimited: true) => true, - StreamApiException(isTokenNotYetValid: true) => true, - StreamApiException(code: StreamErrorCode.requestTimeout) => true, - StreamApiException(:final statusCode) => statusCode >= 500, - StreamNetworkException(isCancelled: true) => false, - StreamNetworkException() => true, - StreamAuthenticationException() => false, - StreamClientException() => false, - }; -} diff --git a/packages/stream_core/test/errors/retry_policy_test.dart b/packages/stream_core/test/errors/retry_policy_test.dart deleted file mode 100644 index 2ba182e8..00000000 --- a/packages/stream_core/test/errors/retry_policy_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -StreamApiException _api( - int code, { - int statusCode = 400, - bool unrecoverable = false, -}) => StreamApiException( - message: 'error $code', - statusCode: statusCode, - code: StreamErrorCode(code), - unrecoverable: unrecoverable, -); - -void main() { - group('isRetriable', () { - test('never retries what the server declared unrecoverable', () { - // A 500 would otherwise retry; the server's own verdict overrides it. - expect(_api(17, statusCode: 500, unrecoverable: true).isRetriable, isFalse); - }); - - test('retries a rate limit, which clears on its own', () { - expect(_api(9, statusCode: 429).isRetriable, isTrue); - }); - - test('retries a token that is not valid yet, since waiting is the fix', () { - for (final code in [41, 42]) { - expect(_api(code, statusCode: 401).isRetriable, isTrue, reason: 'code $code'); - } - }); - - test('retries a server-side request timeout, which is not a verdict on the request', () { - expect(_api(48, statusCode: 408).isRetriable, isTrue); - }); - - test('retries a server-side failure', () { - expect(_api(-1, statusCode: 500).isRetriable, isTrue); - expect(_api(112, statusCode: 503).isRetriable, isTrue); - }); - - test('does not retry an expired token, since the refresh already happened', () { - // The SDK refreshes and retries code 40 once on its own; one that still - // surfaced means a fresh token could not help. - expect(_api(40, statusCode: 401).isRetriable, isFalse); - }); - - test('does not retry a verdict a resend reproduces', () { - expect(_api(4).isRetriable, isFalse, reason: 'validation'); - expect(_api(17, statusCode: 403).isRetriable, isFalse, reason: 'permission'); - expect(_api(43, statusCode: 401).isRetriable, isFalse, reason: 'signature'); - expect(_api(2, statusCode: 401).isRetriable, isFalse, reason: 'api key'); - }); - - test('does not retry a request the caller cancelled', () { - const cancelled = StreamNetworkException(message: 'cancelled', isCancelled: true); - - expect(cancelled.isRetriable, isFalse); - }); - - test('retries a transport failure, whose outcome a later attempt can settle', () { - const timeout = StreamNetworkException(message: 'timed out', isTimeout: true); - const dropped = StreamNetworkException(message: 'gone', closeCode: CloseCode.abnormalClosure); - - expect(timeout.isRetriable, isTrue); - expect(dropped.isRetriable, isTrue); - }); - - test('does not retry credentials that never went out', () { - const auth = StreamAuthenticationException(message: 'no token'); - - expect(auth.isRetriable, isFalse); - }); - - test('does not retry a failure inside the SDK', () { - const client = StreamClientException(message: 'undecodable'); - - expect(client.isRetriable, isFalse); - }); - }); - - group('RetryPolicy.standard', () { - test('retries a retriable failure until the attempts run out', () { - const policy = RetryPolicy.standard(); - final rateLimited = _api(9, statusCode: 429); - - expect(policy.shouldRetry(rateLimited, 1), isTrue); - expect(policy.shouldRetry(rateLimited, 2), isTrue); - expect(policy.shouldRetry(rateLimited, 3), isFalse); - }); - - test('never retries a verdict, however many attempts remain', () { - const policy = RetryPolicy.standard(); - - expect(policy.shouldRetry(_api(17, statusCode: 403), 1), isFalse); - }); - }); -} From 3733d2a2d55ce9cd8df1f7ca4311b63a8f538e1c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 13:47:53 +0200 Subject: [PATCH 53/78] =?UTF-8?q?fix(llc):=20address=20the=20review=20?= =?UTF-8?q?=E2=80=94=20apiError=20counts=20in=20equality,=20docs=20match?= =?UTF-8?q?=20the=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retained API payload joins StreamApiException.props, the error doc stops telling apps to show the server's message and describes what runSafely and runApiSafely actually catch, and TokenManager's doc states the pass-through of already-classified provider failures. StateError coverage pins both seams' behavior. Co-Authored-By: Claude Fable 5 --- .../lib/src/errors/stream_exception.dart | 2 +- .../lib/src/user/token_manager.dart | 7 ++++--- .../api/stream_core_dio_exception_test.dart | 9 +++++++++ .../test/errors/stream_exception_test.dart | 18 ++++++++++++++++++ .../stream_core/test/utils/result_test.dart | 11 +++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 0291242c..fbfc7a1f 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -190,7 +190,7 @@ base class StreamApiException extends StreamException { bool get isRateLimited => statusCode == 429; @override - List get props => [...super.props, statusCode, code, moreInfo, unrecoverable, retryAfter]; + List get props => [...super.props, statusCode, code, moreInfo, unrecoverable, retryAfter, apiError]; @override String toString() { diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 97ae78e7..010966f3 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -144,9 +144,10 @@ class TokenManager { /// identity that replaced it. /// /// Fails with a [StreamAuthenticationException] when no identity is configured, when [reset] runs - /// while the token is loading, and when the [TokenProvider] fails — whatever the provider threw is - /// preserved as the exception's [StreamException.cause] — or returns a token that does not belong - /// to the user it was loading for. + /// while the token is loading, when the [TokenProvider] fails with anything unclassified — + /// preserved as the exception's [StreamException.cause] — and when it returns a token that does + /// not belong to the user it was loading for. A provider failure that is already a + /// [StreamException] passes through as itself, so a transient network failure stays retriable. Future getToken() async { final cached = peekToken(); if (cached != null && !_isSpent(cached)) return cached; diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart index ee441481..03a4ccb9 100644 --- a/packages/stream_core/test/api/stream_core_dio_exception_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -214,5 +214,14 @@ void main() { isA().having((it) => it.cause, 'cause', isA()), ); }); + + test('reports a bug under the seam as an SDK failure carrying the Error', () async { + final result = await runApiSafely(() => throw StateError('misuse under the seam')); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.cause, 'cause', isA()), + ); + }); }); } diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index 7c561343..cc5a82e5 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -203,5 +203,23 @@ void main() { isNot(const StreamNetworkException(message: 'gone')), ); }); + + test('a different retained payload is a different failure', () { + // Two refusals equal in every fact can still differ in what the server + // sent — `apiError` is state, so it must count. + StreamApiException withDetails(List details) => StreamApiException.fromApiError( + StreamApiError( + code: StreamErrorCode.inputError, + details: details, + duration: '0ms', + message: 'refused', + moreInfo: '', + statusCode: 400, + ), + ); + + expect(withDetails(const [1]), isNot(withDetails(const [2]))); + expect(withDetails(const [1]), withDetails(const [1])); + }); }); } diff --git a/packages/stream_core/test/utils/result_test.dart b/packages/stream_core/test/utils/result_test.dart index 02934abe..fd9fd482 100644 --- a/packages/stream_core/test/utils/result_test.dart +++ b/packages/stream_core/test/utils/result_test.dart @@ -109,4 +109,15 @@ void main() { expect(recovered.exceptionOrNull(), isA()); }); }); + + group('runSafely', () { + test('captures whatever was thrown untouched, Error included', () async { + // The generic capture stores raw truth; classification belongs to the + // boundary above it. + final bug = StateError('a bug under the capture'); + final result = await runSafely(() => throw bug); + + expect(result.exceptionOrNull(), same(bug)); + }); + }); } From b9e036558a29b73ffe59ab659fd6e573cdcdda28 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 14:20:43 +0200 Subject: [PATCH 54/78] =?UTF-8?q?fix(llc):=20close=20the=20review's=20gaps?= =?UTF-8?q?=20=E2=80=94=20send=20gets=20its=20seam,=20auth=20reconnect=20r?= =?UTF-8?q?eads=20the=20facts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A send refused because the socket is not open now classifies as the network failure it is instead of leaking a raw StateError through a public Result, and an authentication stopped by the network — a token endpoint briefly unreachable — reconnects instead of staying down on credentials that were never the problem. The docs stop describing sources, fields and predicates that do not exist, and the changelog names the StreamApiError.code type change and objectRuntimeType. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 15 ++++---- STYLE_GUIDE.md | 13 +++---- packages/stream_core/CHANGELOG.md | 4 ++- .../ws/client/stream_web_socket_client.dart | 26 +++++++++++++- .../client/web_socket_connection_state.dart | 13 +++++-- .../api/stream_core_dio_exception_test.dart | 13 +++++++ .../test/errors/stream_exception_test.dart | 36 +++++++++---------- .../client/stream_web_socket_client_test.dart | 17 +++++++++ .../web_socket_connection_state_test.dart | 15 ++++++++ 9 files changed, 117 insertions(+), 35 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index c8e468f2..296631b3 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -24,8 +24,9 @@ sealed class StreamException implements Exception { } base class StreamApiException extends StreamException { - final int statusCode; // HTTP status — independent of `code`; never derive one from the other - final int code; // Stream's stable error code — branch on this, never on message + final int statusCode; // HTTP status — independent of `code`; never derive one from the other + final StreamErrorCode? code; // Stream's stable error code — branch on this, never on message. + // Null when the verdict never reached Stream (a proxy's bare status) final String? moreInfo; // docs URL; populated on REST errors, empty on WebSocket errors final bool unrecoverable; // when true, the server says retrying will not help — authoritative. // Absence means nothing: only Video sets it deliberately (plus the @@ -64,7 +65,7 @@ It's our own code's fault → StreamClientException ``` One rule resolves the classic overlap: **a server that rejects your token has answered** — that is a -`StreamApiException` (check `isTokenExpired` / `isTokenInvalid`). `StreamAuthenticationException` is +`StreamApiException` (check `isTokenExpired` / `isTokenSignatureInvalid`). `StreamAuthenticationException` is only for credentials that never went out: the `TokenProvider` threw or returned nothing usable, no user is configured, or the WebSocket auth message could not be sent. Whatever the provider threw is preserved in `cause`. @@ -104,10 +105,10 @@ Failures arrive on two channels, carrying the same four types: client.connectionState.listen((state) { if (state case Disconnected(:final source)) { switch (source) { - case UserInitiated(): break; // you called disconnect() - case ServerRefused(:final error): _onError(error); // StreamApiException — server said no - case ConnectionLost(:final error): _showReconnecting(); // StreamNetworkException — SDK retries - case AuthenticationFailed(:final error): _reLogin(); // StreamAuthenticationException + case UserInitiated(): break; // you called disconnect() + case AuthenticationFailed(): _reLogin(); // credentials could not be produced or sent + case ServerInitiated(:final error): _onServerClosed(error); // the server ended it; the error (if any) says why + case _: _showReconnecting(); // transport trouble — the SDK retries } } }); diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index e7a764a4..6bb86b5a 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -605,12 +605,13 @@ models (`StreamApiError` is the server's wire payload, not a throwable). "Error" remains fine as a domain word in prose, fields, and codes (`StreamErrorCode`, `errorBuilder`). -Two seams deliberately cross the don't-catch-`Error` line, each with a stated -reason: decoding wire data catches everything, because a `TypeError` there indicts -the data rather than the program; and the auth boundaries catch everything thrown -by app-supplied token code, because a rejection must always deliver a -`StreamException` (the original error stays visible in `cause`). Everywhere else, -an `Error` propagates to the crash reporter where it belongs. +The capture seams deliberately cross the don't-catch-`Error` line, each with a +stated reason: `runApiSafely` and wire decoding catch everything, because a +`TypeError` there indicts the data rather than the program; the auth boundaries +catch everything thrown by app-supplied token code, because a rejection must +always deliver a `StreamException` (the original error stays visible in `cause`); +and `runSafely` captures raw truth for the boundary above it to classify. Outside +a seam, an `Error` propagates to the crash reporter where it belongs. ### Prefer specialized functions, methods, and constructors diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 943b6180..631b8a85 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -14,7 +14,8 @@ - Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and `DioException.toClientException()` is now `toStreamException()` - `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` rather than an engine exception and `Object?`, so a `Disconnected` state carries the same error kinds a failed request does - `TokenManager.getToken` fails with a `StreamAuthenticationException` — when no identity is configured, when `reset` races a load, and when the `TokenProvider` fails, in which case whatever it threw is preserved as the exception's `cause` -- Replaced the `StreamApiError` predicates: `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes), and `isRateLimited` reads the HTTP status. The same predicates exist on `StreamApiException` +- Replaced the `StreamApiError` predicates: the code predicates live on `StreamErrorCode` (reachable as `error.code.isTokenExpired`) and on `StreamApiException` — `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes). `StreamApiError` itself keeps only `isRateLimited`, read from the HTTP status +- `StreamApiError.code` is typed `StreamErrorCode` rather than `int`. Reads keep working — a `StreamErrorCode` is an `int` — but construction now takes `StreamErrorCode(40)` in place of `40` - Credentials whose connection attempt was abandoned fail with a `StreamNetworkException` inside the `Result` rather than a `StateError`. A token provider that fails, or returns a token for another user, fails `getToken` with a `StreamAuthenticationException` rather than a raw error - `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 for one not valid yet, and stays `false` for a refused signature or API key, any other 4xx, and whenever the server marked the error `unrecoverable` @@ -33,6 +34,7 @@ - `User.guest` takes an `image`, which it previously dropped - 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 `objectRuntimeType`, naming an object's type for `toString` implementations in a way that stays stable under minification, matching Flutter's utility of the same name - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision - Added `DioException.toStreamException()`, the failure a Dio error represents: a response — Stream error payload or bare status — reads as a `StreamApiException`, and anything that ended before a verdict as a `StreamNetworkException`, with cancellations and timeouts marked as such - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index d397e8d0..0a5ed967 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -163,7 +163,31 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// The [request] is encoded using the configured message codec and sent to the server. /// /// Returns a [Result] indicating success or failure of the send operation. - Result send(WsRequest request) => _engine.sendMessage(request); + /// A failure holds a [StreamException]: a [StreamNetworkException] when the + /// connection is not open — a drop can race any send — or a + /// [StreamClientException] when the request could not be encoded. + Result send(WsRequest request) { + final result = _engine.sendMessage(request); + if (result case Failure(:final error, :final stackTrace)) { + var exception = StreamException.tryFrom(error); + exception ??= switch (error) { + StateError() => StreamNetworkException( + message: 'The connection is not open', + cause: error, + stackTrace: stackTrace, + ), + _ => StreamClientException( + message: 'The request could not be sent', + cause: error, + stackTrace: stackTrace, + ), + }; + + return Result.failure(exception, stackTrace); + } + + return result; + } /// Establishes a WebSocket connection. /// diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 20b98cb9..b319f2f6 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -273,7 +273,10 @@ sealed class DisconnectionSource extends Equatable { /// /// {@template webSocketReconnectionRules} /// - [UserInitiated] — no, the caller asked for the connection to close. - /// - [AuthenticationFailed] — no, credentials that never went out will not go out on a retry. + /// - [AuthenticationFailed] — no, credentials that could not be produced or sent will not fare + /// better on a retry — unless the failure it carries is a non-cancelled + /// [StreamNetworkException], which indicts the moment rather than the credentials (a token + /// endpoint that was briefly unreachable), and reconnects. /// - [SystemInitiated], [UnHealthyConnection], [ConnectTimeout] — yes. /// - [ServerInitiated] — decided by the error it carries: /// - no error — yes, the closure said nothing against trying again. @@ -295,7 +298,13 @@ sealed class DisconnectionSource extends Equatable { /// {@endtemplate} bool get isReconnectable => switch (this) { UserInitiated() => false, - AuthenticationFailed() => false, + // Credentials that could not be produced or sent will not fare better on + // a retry — unless what stopped them was the network itself, which is + // about the moment, not the credentials. + AuthenticationFailed(:final error) => switch (error) { + StreamNetworkException(isCancelled: false) => true, + _ => false, + }, SystemInitiated() => true, UnHealthyConnection() => true, ConnectTimeout() => true, diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart index 03a4ccb9..742c0438 100644 --- a/packages/stream_core/test/api/stream_core_dio_exception_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -120,6 +120,19 @@ void main() { ); }); + test('drops a Retry-After that is not a non-negative number of seconds', () { + StreamException withHeader(String value) => _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'Too many requests'), + statusCode: 429, + headers: { + 'retry-after': [value], + }, + ).toStreamException(); + + expect(withHeader('-7'), isA().having((it) => it.retryAfter, 'retryAfter', isNull)); + expect(withHeader('soon'), isA().having((it) => it.retryAfter, 'retryAfter', isNull)); + }); + test('reports no verdict when there is no response at all', () { final exception = _failure(message: 'connection refused').toStreamException(); diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index cc5a82e5..828f1c62 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -167,6 +167,24 @@ void main() { expect(printed, contains('token expired')); expect(printed, contains('https://getstream.io/docs/errors')); }); + + test('a different retained payload is a different failure', () { + // Two refusals equal in every fact can still differ in what the server + // sent — `apiError` is state, so it must count. + StreamApiException withDetails(List details) => StreamApiException.fromApiError( + StreamApiError( + code: StreamErrorCode.inputError, + details: details, + duration: '0ms', + message: 'refused', + moreInfo: '', + statusCode: 400, + ), + ); + + expect(withDetails(const [1]), isNot(withDetails(const [2]))); + expect(withDetails(const [1]), withDetails(const [1])); + }); }); group('StreamNetworkException', () { @@ -203,23 +221,5 @@ void main() { isNot(const StreamNetworkException(message: 'gone')), ); }); - - test('a different retained payload is a different failure', () { - // Two refusals equal in every fact can still differ in what the server - // sent — `apiError` is state, so it must count. - StreamApiException withDetails(List details) => StreamApiException.fromApiError( - StreamApiError( - code: StreamErrorCode.inputError, - details: details, - duration: '0ms', - message: 'refused', - moreInfo: '', - statusCode: 400, - ), - ); - - expect(withDetails(const [1]), isNot(withDetails(const [2]))); - expect(withDetails(const [1]), withDetails(const [1])); - }); }); } diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 2de2a27b..c700a9ac 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -187,6 +187,23 @@ void main() { ); }); + group('send', () { + wsClientTest( + 'fails as a network problem when the connection is not open', + connect: (_) async {}, // never connected + body: (tester) { + // A drop can race any send, so a correct caller can hit this: it + // reads as the moment's failure, classified like every other one. + final result = tester.client.send(const HealthCheckPingEvent(connectionId: 'connection-id')); + + expect( + result.exceptionOrNull(), + isA().having((it) => it.cause, 'cause', isA()), + ); + }, + ); + }); + group('authenticate', () { wsClientTest( 'presents credentials once the socket is open, while authenticating', diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index a9e17d7f..0320f3f6 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -127,6 +127,21 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isFalse); }); + test('automatic reconnection is enabled when authentication failed on the network, since the ' + 'moment is at fault rather than the credentials', () { + // A token endpoint that was briefly unreachable classifies as a network + // failure and passes through the token manager as itself. + const transient = Disconnected( + source: AuthenticationFailed(error: StreamNetworkException(message: 'endpoint unreachable')), + ); + const cancelled = Disconnected( + source: AuthenticationFailed(error: StreamNetworkException(message: 'stopped', isCancelled: true)), + ); + + expect(transient.isAutomaticReconnectionEnabled, isTrue); + expect(cancelled.isAutomaticReconnectionEnabled, isFalse); + }); + test('automatic reconnection is enabled when a connected socket stops answering health checks', () { const state = Disconnected(source: UnHealthyConnection()); From b337da72b01b0ab0f50c50a164e3b2453f873783 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 14:24:50 +0200 Subject: [PATCH 55/78] style(llc): drop an async the no-op connect never needed Co-Authored-By: Claude Fable 5 --- .../test/ws/client/stream_web_socket_client_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index c700a9ac..c0a9070b 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -190,7 +190,7 @@ void main() { group('send', () { wsClientTest( 'fails as a network problem when the connection is not open', - connect: (_) async {}, // never connected + connect: (_) {}, // never connected body: (tester) { // A drop can race any send, so a correct caller can hit this: it // reads as the moment's failure, classified like every other one. From d3c76b49c97f2a03a085fb23cc8d2d3ff8301677 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 14:32:02 +0200 Subject: [PATCH 56/78] test(llc): keep only the tests that pin a decision The throw-and-catch loop restated language semantics, the constructor defaults are pinned where the mapper actually produces them, and the success passthrough of a five-line seam protects nothing its callers would not catch. What stays is one line the compiler cannot enforce: the root is an Exception a blanket handler still sees. Co-Authored-By: Claude Fable 5 --- .../api/stream_core_dio_exception_test.dart | 8 ------ .../test/errors/stream_exception_test.dart | 25 +++---------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart index 742c0438..ba2b838a 100644 --- a/packages/stream_core/test/api/stream_core_dio_exception_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -152,11 +152,9 @@ void main() { test('marks a request the caller cancelled as such', () { final cancelled = _failure(type: DioExceptionType.cancel).toStreamException(); - final refused = _failure(body: _errorBody(), statusCode: 401).toStreamException(); // A caller that called the request off should not be shown it as a failure. expect(cancelled, isA().having((it) => it.isCancelled, 'isCancelled', isTrue)); - expect(refused, isA()); }); test('never leaves the message empty of meaning, so a caller always has something to show', () { @@ -190,12 +188,6 @@ void main() { }); group('runApiSafely', () { - test('returns the call result on success', () async { - final result = await runApiSafely(() => 'ok'); - - expect(result, const Result.success('ok')); - }); - test('maps a transport failure onto the exception it represents', () async { final result = await runApiSafely( () => throw _failure(body: _errorBody(), statusCode: 401), diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index 828f1c62..7bdb47d9 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -19,19 +19,10 @@ StreamApiError _apiError({ void main() { group('StreamException', () { - test('every kind can be caught as one', () { - const exceptions = [ - StreamApiException(message: 'refused', statusCode: 400, code: StreamErrorCode.inputError), - StreamNetworkException(message: 'offline'), - StreamAuthenticationException(message: 'no token'), - StreamClientException(message: 'broken'), - ]; - - for (final exception in exceptions) { - // The point of one root: `on StreamException` always means "a Stream problem". - expect(exception, isA(), reason: '$exception'); - expect(() => throw exception, throwsA(isA()), reason: '$exception'); - } + test('is an Exception, so a blanket handler still sees it', () { + // Nothing at compile time enforces the root's `implements Exception`; + // losing it would silently skip Stream failures in `on Exception` code. + expect(const StreamClientException(message: 'broken'), isA()); }); test('compares by what it carries', () { @@ -188,14 +179,6 @@ void main() { }); group('StreamNetworkException', () { - test('defaults to a plain unexplained failure', () { - const exception = StreamNetworkException(message: 'gone'); - - expect(exception.isCancelled, isFalse); - expect(exception.isTimeout, isFalse); - expect(exception.closeCode, isNull); - }); - test('prints the close code when the failure was a socket closure', () { const closed = StreamNetworkException( message: 'The connection was closed unexpectedly', From 16b6ea15c80776cc3e749edf6818e4fdac1c4c03 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 14:38:33 +0200 Subject: [PATCH 57/78] docs(changelog): one short bullet per entry, the way the policy asks The rationale lives in ERROR_LAYER.md; the changelog keeps the functional change and the migration fact. Co-Authored-By: Claude Fable 5 --- packages/stream_core/CHANGELOG.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 631b8a85..f01e8893 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -10,15 +10,15 @@ - `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, called once per connection attempt - `WebSocketOptions.connectTimeout` is now a non-nullable `Duration`, 30 seconds by default, and is honoured: a connection that does not come up is given up on instead of waited on indefinitely. A connection that drops later is retried for you; a `connect` that times out is not, so call it again - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the `StreamApiException` the server closed the previous attempt with, and throws to say the credentials did not go out -- Reworked the error layer around one sealed root. Every failure the SDK reports is a `StreamException`, of exactly four kinds named for what the caller should do: `StreamApiException` (the server answered with an error), `StreamNetworkException` (the server was never heard from — outcome unknown), `StreamAuthenticationException` (credentials could not be produced or sent), and `StreamClientException` (the SDK itself failed). See `ERROR_LAYER.md` for the full contract +- Reworked the error layer around one sealed root: every failure the SDK reports is a `StreamException` of four kinds — `StreamApiException`, `StreamNetworkException`, `StreamAuthenticationException` or `StreamClientException`. See `ERROR_LAYER.md` for the contract - Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and `DioException.toClientException()` is now `toStreamException()` -- `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` rather than an engine exception and `Object?`, so a `Disconnected` state carries the same error kinds a failed request does -- `TokenManager.getToken` fails with a `StreamAuthenticationException` — when no identity is configured, when `reset` races a load, and when the `TokenProvider` fails, in which case whatever it threw is preserved as the exception's `cause` -- Replaced the `StreamApiError` predicates: the code predicates live on `StreamErrorCode` (reachable as `error.code.isTokenExpired`) and on `StreamApiException` — `isTokenExpired` is code 40 (a fresh token fixes it), `isTokenNotYetValid` is codes 41 and 42 (clock skew — waiting fixes it, a fresh token from the same skewed clock does not), `isTokenSignatureInvalid` is code 43 and `isApiKeyInvalid` is code 2 (configuration nothing at runtime fixes). `StreamApiError` itself keeps only `isRateLimited`, read from the HTTP status -- `StreamApiError.code` is typed `StreamErrorCode` rather than `int`. Reads keep working — a `StreamErrorCode` is an `int` — but construction now takes `StreamErrorCode(40)` in place of `40` -- Credentials whose connection attempt was abandoned fail with a `StreamNetworkException` inside the `Result` rather than a `StateError`. A token provider that fails, or returns a token for another user, fails `getToken` with a `StreamAuthenticationException` rather than a raw error +- `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` +- `TokenManager.getToken` fails with a `StreamAuthenticationException` rather than raw errors; a failed provider's own error is preserved as `cause` +- Replaced the `StreamApiError` predicates with `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid` and `isApiKeyInvalid` on `StreamErrorCode` and `StreamApiException`; `StreamApiError` keeps only `isRateLimited` +- `StreamApiError.code` is typed `StreamErrorCode` rather than `int`; construction takes `StreamErrorCode(40)` in place of `40`, reads are unchanged +- A `WsRequestSender` whose connection attempt was abandoned fails with a `StreamNetworkException` rather than a `StateError` - `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 for one not valid yet, and stays `false` for a refused signature or API key, any other 4xx, and whenever the server marked the error `unrecoverable` +- `WebSocketConnectionState.isAutomaticReconnectionEnabled` reads the error's facts: token conditions that heal and transient network failures reconnect; refused signatures or API keys, other 4xx and `unrecoverable` verdicts do not - `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 widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag` @@ -34,12 +34,12 @@ - `User.guest` takes an `image`, which it previously dropped - 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 `objectRuntimeType`, naming an object's type for `toString` implementations in a way that stays stable under minification, matching Flutter's utility of the same name +- Added `objectRuntimeType`, naming an object's type in `toString` in a way that survives minification - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision -- Added `DioException.toStreamException()`, the failure a Dio error represents: a response — Stream error payload or bare status — reads as a `StreamApiException`, and anything that ended before a verdict as a `StreamNetworkException`, with cancellations and timeouts marked as such +- Added `DioException.toStreamException()`, mapping a Dio failure to the `StreamException` it represents - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses -- Added `StreamErrorCode`, the API's error-code registry as named constants over `int` — one shared vocabulary for every Stream product, tolerant of codes the SDK does not know yet. `StreamApiException.code` is typed with it -- Added `runApiSafely`, the seam an API call crosses on its way to a caller: every failure it reports is a `StreamException` — transport failures mapped, a response that would not decode included +- Added `StreamErrorCode`, the API's error-code registry as named constants over `int`, tolerant of codes the SDK does not know yet +- Added `runApiSafely`, which runs an API call and reports every failure as a `StreamException` - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none - Added `ConnectUserDetailsRequest.fromUser`, which builds the details a client may send from a `User` @@ -48,7 +48,7 @@ ### 🐛 Bug Fixes -- Fixed `StreamApiError` failing to decode when `details` carries anything other than a list of numbers, as a moderation rejection's does; such values now read as empty instead of failing the whole error +- Fixed `StreamApiError` failing to decode when `details` is not a list of numbers, as a moderation rejection's is; such values read as empty - 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 From 5eb3ae310b256e64f28dc7c6e8c034824f77fd8c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 14:46:19 +0200 Subject: [PATCH 58/78] docs(changelog): speak only of what shipped, in the file's own voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header stays the 💥 form this package's releases already use — the policy now says match the file rather than migrate it. The abandoned- sender entry described a change to a WsRequestSender that never shipped, AuthenticationFailed leaves the typing entry for the same reason, and the predicates entry now names the released predicates it replaces so a migrating reader can grep for them. Co-Authored-By: Claude Fable 5 --- STYLE_GUIDE.md | 7 ++++--- packages/stream_core/CHANGELOG.md | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 6bb86b5a..5fd55c73 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1429,9 +1429,10 @@ entries are acceptable for user-visible multi-facet features where the extra context matters to someone deciding whether to upgrade — but avoid sub-bullets, per-method enumeration, and internal implementation notes. -Older entries in the changelog use `### 🐞 Fixed` and `### 💥 Breaking Changes` / -`### 💥 BREAKING CHANGES` — those forms are grandfathered but new entries should -use the labels above. +Some changelogs use older labels — `### 🐞 Fixed`, `### 💥 Breaking Changes` / +`### 💥 BREAKING CHANGES`. Match the header style the package's changelog already +uses rather than mixing forms within one file; a new package starts on the labels +above. ### Cross-package PRs diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index f01e8893..d0d3f76f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,6 +1,6 @@ ## Upcoming -### 🛑 Breaking / Removals +### 💥 BREAKING CHANGES - Raised the minimum Dart SDK to `^3.12.0` - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` @@ -12,11 +12,10 @@ - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the `StreamApiException` the server closed the previous attempt with, and throws to say the credentials did not go out - Reworked the error layer around one sealed root: every failure the SDK reports is a `StreamException` of four kinds — `StreamApiException`, `StreamNetworkException`, `StreamAuthenticationException` or `StreamClientException`. See `ERROR_LAYER.md` for the contract - Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and `DioException.toClientException()` is now `toStreamException()` -- `ServerInitiated.error` and `AuthenticationFailed.error` are typed `StreamException?` +- `ServerInitiated.error` is typed `StreamException?` rather than `WebSocketEngineException?` - `TokenManager.getToken` fails with a `StreamAuthenticationException` rather than raw errors; a failed provider's own error is preserved as `cause` -- Replaced the `StreamApiError` predicates with `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid` and `isApiKeyInvalid` on `StreamErrorCode` and `StreamApiException`; `StreamApiError` keeps only `isRateLimited` +- Replaced `StreamApiError.isTokenExpiredError`, `isClientError` and `isRateLimitError`: the conditions live on `StreamErrorCode` and `StreamApiException` as `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid`, `isApiKeyInvalid` and `isRateLimited`; `StreamApiError` keeps only `isRateLimited` - `StreamApiError.code` is typed `StreamErrorCode` rather than `int`; construction takes `StreamErrorCode(40)` in place of `40`, reads are unchanged -- A `WsRequestSender` whose connection attempt was abandoned fails with a `StreamNetworkException` rather than a `StateError` - `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another - `WebSocketConnectionState.isAutomaticReconnectionEnabled` reads the error's facts: token conditions that heal and transient network failures reconnect; refused signatures or API keys, other 4xx and `unrecoverable` verdicts do not - `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 widened = intResult`) or use `fold` From 59adec59babf5b899fe3b00ca61e921baff40963 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 28 Aug 2026 16:55:43 +0200 Subject: [PATCH 59/78] docs: teach the no-code case in the catch example StreamApiException.code is null for a proxy's bare status, and the example now shows the fallback instead of passing null to copyFor. Co-Authored-By: Claude Fable 5 --- ERROR_LAYER.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 296631b3..0d074946 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -122,7 +122,8 @@ switch (result) { case Failure(:final error): switch (error) { case StreamApiException(isRateLimited: true): scheduleRetry(); - case StreamApiException(:final code): showError(copyFor(code)); + case StreamApiException(:final code?): showError(copyFor(code)); + case StreamApiException(): showError(genericFailureCopy); // no Stream code: a proxy's bare status case StreamNetworkException(isCancelled: true): break; // user navigated away case StreamNetworkException(): showOfflineBanner(); case StreamAuthenticationException(): redirectToLogin(); From 1aed17bd8f4c62b3b86db789ada840f2e68b0b16 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 16:40:17 +0200 Subject: [PATCH 60/78] fix(llc): reconnect on a server-side processing timeout, and stop parsing errors twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ERROR_LAYER.md` says a 408 is about the moment rather than a verdict on the request, so it retries with backoff — but `isAutomaticReconnectionEnabled` fell through to the 4xx rule and refused it. The code now honours the contract the doc states. `ServerInitiated`'s inner switch ended in `_ => true`, reachable only for a null error since all four exception kinds are matched above. Written as `null => true`, a fifth kind becomes a compile error rather than a silent reconnect. `AuthInterceptor.onError` classified every error to find the one code it acts on, then handed the raw failure to `ApiErrorInterceptor`, which read the same body again. It now forwards the `StreamDioException` it already has — the same one `ApiErrorInterceptor` would have built. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../src/api/interceptors/auth_interceptor.dart | 18 +++++++++++++++++- .../ws/client/web_socket_connection_state.dart | 3 ++- .../web_socket_connection_state_test.dart | 9 +++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index d0d3f76f..48080d01 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -17,7 +17,7 @@ - Replaced `StreamApiError.isTokenExpiredError`, `isClientError` and `isRateLimitError`: the conditions live on `StreamErrorCode` and `StreamApiException` as `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid`, `isApiKeyInvalid` and `isRateLimited`; `StreamApiError` keeps only `isRateLimited` - `StreamApiError.code` is typed `StreamErrorCode` rather than `int`; construction takes `StreamErrorCode(40)` in place of `40`, reads are unchanged - `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another -- `WebSocketConnectionState.isAutomaticReconnectionEnabled` reads the error's facts: token conditions that heal and transient network failures reconnect; refused signatures or API keys, other 4xx and `unrecoverable` verdicts do not +- `WebSocketConnectionState.isAutomaticReconnectionEnabled` reads the error's facts: token conditions that heal, rate limits, server-side processing timeouts and transient network failures reconnect; refused signatures or API keys, other 4xx and `unrecoverable` verdicts do not - `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 widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag` diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 8e4bb72c..243465a8 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -68,7 +68,23 @@ class AuthInterceptor extends Interceptor { // other token codes are clock or configuration problems a refresh cannot // help. final error = err.toStreamException(); - if (error is! StreamApiException || !error.isTokenExpired) return handler.next(err); + if (error is! StreamApiException || !error.isTokenExpired) { + // The classification is done; handing it on as a StreamDioException is + // what ApiErrorInterceptor would build anyway, and spares it reading the + // same body a second time. + if (err is StreamDioException) return handler.next(err); + + return handler.next( + StreamDioException( + exception: error, + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + stackTrace: err.stackTrace, + message: err.message, + ), + ); + } final options = err.requestOptions; diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index b319f2f6..d58ee6c6 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -315,12 +315,13 @@ sealed class DisconnectionSource extends Equatable { StreamApiException(isTokenExpired: true) => true, StreamApiException(isTokenNotYetValid: true) => true, StreamApiException(isRateLimited: true) => true, + StreamApiException(statusCode: 408) => true, StreamApiException(:final statusCode) => statusCode < 400 || statusCode >= 500, StreamNetworkException(closeCode: CloseCode.normalClosure) => false, StreamNetworkException() => true, StreamAuthenticationException() => false, StreamClientException() => true, - _ => true, + null => true, }, }; diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 0320f3f6..2438776d 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -59,6 +59,15 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isTrue); }); + test('automatic reconnection is enabled when the server timed out processing', () { + // 48 is a request timeout, sent as 408: about the moment, not a verdict on + // the request, so it is retried with backoff like the other 5xx-shaped + // failures rather than refused like the rest of the 4xx range. + final state = _serverDisconnect(_apiError(48, statusCode: 408)); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + test('automatic reconnection is disabled for any other client error', () { // 17 is not allowed, and retrying does not change the answer. final state = _serverDisconnect(_apiError(17, statusCode: 403)); From 2a111efce1bc030ce00261c75307eb9cb592e5dd Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 16:44:16 +0200 Subject: [PATCH 61/78] revert(llc): leave the error mapping to ApiErrorInterceptor alone `AuthInterceptor.onError` was forwarding a `StreamDioException` it built itself so `ApiErrorInterceptor` would not read the same body again. Mapping a failure onto its `StreamException` is the one job that interceptor has, and installing it last is what makes the guarantee hold; a second place building the same object to save a `jsonDecode` on error responses spreads that responsibility for no benefit worth having. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/api/interceptors/auth_interceptor.dart | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 243465a8..8e4bb72c 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -68,23 +68,7 @@ class AuthInterceptor extends Interceptor { // other token codes are clock or configuration problems a refresh cannot // help. final error = err.toStreamException(); - if (error is! StreamApiException || !error.isTokenExpired) { - // The classification is done; handing it on as a StreamDioException is - // what ApiErrorInterceptor would build anyway, and spares it reading the - // same body a second time. - if (err is StreamDioException) return handler.next(err); - - return handler.next( - StreamDioException( - exception: error, - requestOptions: err.requestOptions, - response: err.response, - type: err.type, - stackTrace: err.stackTrace, - message: err.message, - ), - ); - } + if (error is! StreamApiException || !error.isTokenExpired) return handler.next(err); final options = err.requestOptions; From 6d158c61ff2b4561aea298cb0e7bc95c063ebe11 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 16:48:17 +0200 Subject: [PATCH 62/78] refactor(llc): name the 408 condition rather than matching the number `isAutomaticReconnectionEnabled` matched `statusCode: 408` inline, the one magic number in a block where every other branch reads a named condition off the exception. `StreamApiException.isRequestTimeout` joins `isRateLimited` and the token conditions, so a caller writing the retry rule the contract describes has a fact to branch on rather than a status to remember. Named for the status rather than the cause: the dartdoc separates it from `StreamNetworkException.isTimeout`, which is a request that never reached the server at all. Co-Authored-By: Claude Opus 5 (1M context) --- ERROR_LAYER.md | 1 + packages/stream_core/CHANGELOG.md | 1 + .../stream_core/lib/src/errors/stream_exception.dart | 11 +++++++++++ .../src/ws/client/web_socket_connection_state.dart | 2 +- .../test/errors/stream_exception_test.dart | 5 +++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 0d074946..9a701c3c 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -39,6 +39,7 @@ base class StreamApiException extends StreamException { bool get isTokenSignatureInvalid; // code 43 — configuration problem; no token or wait fixes it bool get isApiKeyInvalid; // code 2 — wrong key, or product not enabled on the app bool get isRateLimited; // statusCode 429 + bool get isRequestTimeout; // statusCode 408 — ran out of time before a verdict; worth retrying } base class StreamNetworkException extends StreamException { diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 48080d01..5561ce06 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -37,6 +37,7 @@ - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision - Added `DioException.toStreamException()`, mapping a Dio failure to the `StreamException` it represents - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses +- Added `StreamApiException.isRequestTimeout`, true when the request ran out of time before the server reached a verdict (HTTP 408) — about the moment rather than the request, so the same call is worth retrying - Added `StreamErrorCode`, the API's error-code registry as named constants over `int`, tolerant of codes the SDK does not know yet - Added `runApiSafely`, which runs an API call and reports every failure as a `StreamException` - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index fbfc7a1f..13932b7e 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -189,6 +189,17 @@ base class StreamApiException extends StreamException { /// [retryAfter] carries the server's suggested wait when one was sent. bool get isRateLimited => statusCode == 429; + /// Whether the request ran out of time before the server reached a verdict + /// on it ([StreamErrorCode.requestTimeout], HTTP 408). + /// + /// About the moment rather than the request, so the same call is worth + /// retrying — unlike the rest of the 4xx range, which answers the same way + /// however often it is asked. + /// + /// Distinct from [StreamNetworkException.isTimeout], which is a request that + /// never reached the server at all. + bool get isRequestTimeout => statusCode == 408; + @override List get props => [...super.props, statusCode, code, moreInfo, unrecoverable, retryAfter, apiError]; diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index d58ee6c6..bb6f2c7b 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -315,7 +315,7 @@ sealed class DisconnectionSource extends Equatable { StreamApiException(isTokenExpired: true) => true, StreamApiException(isTokenNotYetValid: true) => true, StreamApiException(isRateLimited: true) => true, - StreamApiException(statusCode: 408) => true, + StreamApiException(isRequestTimeout: true) => true, StreamApiException(:final statusCode) => statusCode < 400 || statusCode >= 500, StreamNetworkException(closeCode: CloseCode.normalClosure) => false, StreamNetworkException() => true, diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index 7bdb47d9..a20ea6ac 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -120,6 +120,11 @@ void main() { expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 500)).isRateLimited, isFalse); }); + test('reads a request timeout off the status, not the code', () { + expect(StreamApiException.fromApiError(_apiError(code: 48, statusCode: 408)).isRequestTimeout, isTrue); + expect(StreamApiException.fromApiError(_apiError(code: 48, statusCode: 500)).isRequestTimeout, isFalse); + }); + test('carries no code for a verdict that was not a Stream error', () { // An edge or proxy answers with a status and no Stream payload. No sentinel stands in for // the missing code, because any number would collide with a real one. From c1cedd1ab2ebf2f0644ed3bcfbea3a58c180a51e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 16:49:19 +0200 Subject: [PATCH 63/78] docs(llc): say what a 408 is without guessing which end was slow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry table called it "a server-side processing timeout" and `isRequestTimeout` said the server had not reached a verdict. Neither is reliably true: a 408 is raised both when a request body does not arrive in time and when processing exceeds its deadline, so the honest statement is that the request did not complete in time — and either way the server answered, which is what makes a retry worth trying. Also corrects the contrast with `StreamNetworkException.isTimeout`, which is the caller giving up before any answer arrived, not a request that never reached the server. Co-Authored-By: Claude Opus 5 (1M context) --- ERROR_LAYER.md | 2 +- packages/stream_core/CHANGELOG.md | 2 +- .../stream_core/lib/src/errors/stream_exception.dart | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 9a701c3c..3934734c 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -221,7 +221,7 @@ The decision runs in order: | `StreamApiException`, 5xx | Yes, with backoff. | | `StreamApiException(isTokenExpired: true)` | No — the SDK already refreshed and retried once; seeing it means refresh could not help. | | `StreamApiException(isTokenNotYetValid: true)` | Yes, after waiting — clock skew heals, bounded. | - | `StreamApiException`, 408 (code 48) | Yes, with backoff — a server-side processing timeout, not a verdict on the request. | + | `StreamApiException`, 408 (code 48) | Yes, with backoff — the request did not complete in time, which is not a verdict on the request. | | `StreamApiException`, any other 4xx | No — the same request gets the same verdict. (A channel cooldown, code 60, does clear on its own, but its wait is not machine-readable — surface it rather than auto-retry.) | | `StreamAuthenticationException` | No — fix credentials first, then re-attempt the operation. | | `StreamClientException` | No — a bug does not heal on resend; report it. | diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 5561ce06..3a6dbb70 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -37,7 +37,7 @@ - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision - Added `DioException.toStreamException()`, mapping a Dio failure to the `StreamException` it represents - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses -- Added `StreamApiException.isRequestTimeout`, true when the request ran out of time before the server reached a verdict (HTTP 408) — about the moment rather than the request, so the same call is worth retrying +- Added `StreamApiException.isRequestTimeout`, true when the request did not complete in time (HTTP 408) — about the moment rather than the request, so the same call is worth retrying - Added `StreamErrorCode`, the API's error-code registry as named constants over `int`, tolerant of codes the SDK does not know yet - Added `runApiSafely`, which runs an API call and reports every failure as a `StreamException` - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 13932b7e..fd60136c 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -189,15 +189,16 @@ base class StreamApiException extends StreamException { /// [retryAfter] carries the server's suggested wait when one was sent. bool get isRateLimited => statusCode == 429; - /// Whether the request ran out of time before the server reached a verdict - /// on it ([StreamErrorCode.requestTimeout], HTTP 408). + /// Whether the request did not complete in time + /// ([StreamErrorCode.requestTimeout], HTTP 408). /// /// About the moment rather than the request, so the same call is worth /// retrying — unlike the rest of the 4xx range, which answers the same way /// however often it is asked. /// - /// Distinct from [StreamNetworkException.isTimeout], which is a request that - /// never reached the server at all. + /// The server answered, so this is a verdict that a retry can change. + /// [StreamNetworkException.isTimeout] is the other timeout: the caller gave + /// up before any answer arrived, leaving the outcome unknown. bool get isRequestTimeout => statusCode == 408; @override From 302add23f52df7349aadc7478c53ba27a7953075 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 16:56:57 +0200 Subject: [PATCH 64/78] fix(llc): stop reconnecting on a timeout a socket cannot deliver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every WebSocket error frame is a close with a code the server chose, and none of those are a request timeout — so the 408 branch in `isAutomaticReconnectionEnabled` was answering a question the connection never asks. Removing it hands 408 back to the 4xx fallthrough, which is unreachable here either way. `StreamApiException.isRequestTimeout` stays: a REST caller can be handed a 408, and the retry table wants a fact to branch on. Co-Authored-By: Claude Opus 5 (1M context) --- ERROR_LAYER.md | 2 +- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/ws/client/web_socket_connection_state.dart | 1 - .../test/ws/client/web_socket_connection_state_test.dart | 9 --------- 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 3934734c..1ad6dfc2 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -39,7 +39,7 @@ base class StreamApiException extends StreamException { bool get isTokenSignatureInvalid; // code 43 — configuration problem; no token or wait fixes it bool get isApiKeyInvalid; // code 2 — wrong key, or product not enabled on the app bool get isRateLimited; // statusCode 429 - bool get isRequestTimeout; // statusCode 408 — ran out of time before a verdict; worth retrying + bool get isRequestTimeout; // statusCode 408 — the request did not complete in time; worth retrying } base class StreamNetworkException extends StreamException { diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 3a6dbb70..14f86317 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -17,7 +17,7 @@ - Replaced `StreamApiError.isTokenExpiredError`, `isClientError` and `isRateLimitError`: the conditions live on `StreamErrorCode` and `StreamApiException` as `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid`, `isApiKeyInvalid` and `isRateLimited`; `StreamApiError` keeps only `isRateLimited` - `StreamApiError.code` is typed `StreamErrorCode` rather than `int`; construction takes `StreamErrorCode(40)` in place of `40`, reads are unchanged - `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another -- `WebSocketConnectionState.isAutomaticReconnectionEnabled` reads the error's facts: token conditions that heal, rate limits, server-side processing timeouts and transient network failures reconnect; refused signatures or API keys, other 4xx and `unrecoverable` verdicts do not +- `WebSocketConnectionState.isAutomaticReconnectionEnabled` reads the error's facts: token conditions that heal, rate limits and transient network failures reconnect; refused signatures or API keys, other 4xx and `unrecoverable` verdicts do not - `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 widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag` diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index bb6f2c7b..2f8d4a5b 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -315,7 +315,6 @@ sealed class DisconnectionSource extends Equatable { StreamApiException(isTokenExpired: true) => true, StreamApiException(isTokenNotYetValid: true) => true, StreamApiException(isRateLimited: true) => true, - StreamApiException(isRequestTimeout: true) => true, StreamApiException(:final statusCode) => statusCode < 400 || statusCode >= 500, StreamNetworkException(closeCode: CloseCode.normalClosure) => false, StreamNetworkException() => true, diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 2438776d..0320f3f6 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -59,15 +59,6 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isTrue); }); - test('automatic reconnection is enabled when the server timed out processing', () { - // 48 is a request timeout, sent as 408: about the moment, not a verdict on - // the request, so it is retried with backoff like the other 5xx-shaped - // failures rather than refused like the rest of the 4xx range. - final state = _serverDisconnect(_apiError(48, statusCode: 408)); - - expect(state.isAutomaticReconnectionEnabled, isTrue); - }); - test('automatic reconnection is disabled for any other client error', () { // 17 is not allowed, and retrying does not change the answer. final state = _serverDisconnect(_apiError(17, statusCode: 403)); From f69edec4e53add3c7f411e5fdb4ae0975787761a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 17:11:56 +0200 Subject: [PATCH 65/78] revert(llc): drop isRequestTimeout until something needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The getter had no caller once the WebSocket stopped consulting it, which made it the only condition on `StreamApiException` that named a fact nothing in the SDK reads. A caller who wants the 408 row of the retry table can still branch on `statusCode`; the table keeps that row. Also narrows `StreamNetworkException.isTimeout`'s doc, which claimed to cover a connection attempt. Nothing sets it for one — the WebSocket connect deadline reports a `ConnectTimeout` source instead. Co-Authored-By: Claude Opus 5 (1M context) --- ERROR_LAYER.md | 1 - packages/stream_core/CHANGELOG.md | 1 - .../lib/src/errors/stream_exception.dart | 14 +------------- .../test/errors/stream_exception_test.dart | 5 ----- 4 files changed, 1 insertion(+), 20 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 1ad6dfc2..4fec86ef 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -39,7 +39,6 @@ base class StreamApiException extends StreamException { bool get isTokenSignatureInvalid; // code 43 — configuration problem; no token or wait fixes it bool get isApiKeyInvalid; // code 2 — wrong key, or product not enabled on the app bool get isRateLimited; // statusCode 429 - bool get isRequestTimeout; // statusCode 408 — the request did not complete in time; worth retrying } base class StreamNetworkException extends StreamException { diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 14f86317..457d0e8e 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -37,7 +37,6 @@ - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision - Added `DioException.toStreamException()`, mapping a Dio failure to the `StreamException` it represents - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses -- Added `StreamApiException.isRequestTimeout`, true when the request did not complete in time (HTTP 408) — about the moment rather than the request, so the same call is worth retrying - Added `StreamErrorCode`, the API's error-code registry as named constants over `int`, tolerant of codes the SDK does not know yet - Added `runApiSafely`, which runs an API call and reports every failure as a `StreamException` - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index fd60136c..5948707b 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -189,18 +189,6 @@ base class StreamApiException extends StreamException { /// [retryAfter] carries the server's suggested wait when one was sent. bool get isRateLimited => statusCode == 429; - /// Whether the request did not complete in time - /// ([StreamErrorCode.requestTimeout], HTTP 408). - /// - /// About the moment rather than the request, so the same call is worth - /// retrying — unlike the rest of the 4xx range, which answers the same way - /// however often it is asked. - /// - /// The server answered, so this is a verdict that a retry can change. - /// [StreamNetworkException.isTimeout] is the other timeout: the caller gave - /// up before any answer arrived, leaving the outcome unknown. - bool get isRequestTimeout => statusCode == 408; - @override List get props => [...super.props, statusCode, code, moreInfo, unrecoverable, retryAfter, apiError]; @@ -243,7 +231,7 @@ base class StreamNetworkException extends StreamException { /// not to surface. final bool isCancelled; - /// Whether the request or connection attempt timed out. + /// Whether the request timed out before the server answered. final bool isTimeout; /// The WebSocket close code, when the failure was a socket closure. diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index a20ea6ac..7bdb47d9 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -120,11 +120,6 @@ void main() { expect(StreamApiException.fromApiError(_apiError(code: 9, statusCode: 500)).isRateLimited, isFalse); }); - test('reads a request timeout off the status, not the code', () { - expect(StreamApiException.fromApiError(_apiError(code: 48, statusCode: 408)).isRequestTimeout, isTrue); - expect(StreamApiException.fromApiError(_apiError(code: 48, statusCode: 500)).isRequestTimeout, isFalse); - }); - test('carries no code for a verdict that was not a Stream error', () { // An edge or proxy answers with a status and no Stream payload. No sentinel stands in for // the missing code, because any number would collide with a real one. From 13e6af8d1d5f3be205431810ebcdda0c0af3b91a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 17:24:45 +0200 Subject: [PATCH 66/78] fix(llc)!: stop re-exporting dart:typed_data from the barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSocket engine re-exported the whole of `dart:typed_data`, which made `Uint8List` public API of this package by accident — the only `dart:` library the barrel passed on. Nothing in `lib/` needed it: the engine's own signatures are `Object /*String|Uint8List*/`, naming the type in a comment rather than in the type. The two tests that reached it through the barrel now import `dart:typed_data` themselves. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + .../stream_core/lib/src/ws/client/engine/web_socket_engine.dart | 1 - .../stream_core/test/api/interceptors/auth_interceptor_test.dart | 1 + .../test/api/interceptors/logging_interceptor_test.dart | 1 + 4 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 457d0e8e..70db6297 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -21,6 +21,7 @@ - `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 widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag` +- The package no longer re-exports `dart:typed_data`, so code that reached `Uint8List` through `package:stream_core/stream_core.dart` must import `dart:typed_data` itself ### ✨ Features diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart index 2d8a9e90..386b3ea9 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -3,7 +3,6 @@ import 'dart:typed_data'; import '../../../utils.dart'; import 'web_socket_options.dart'; -export 'dart:typed_data'; export 'web_socket_options.dart'; /// Interface for WebSocket engine implementations. diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index ebfa94f9..f6c12dd9 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; diff --git a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart index 7eed8dfb..e93f4dce 100644 --- a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; From 9c1e57ab29c00a59187a4a8ba1432a01ae4e6c9b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 21:05:49 +0200 Subject: [PATCH 67/78] docs(llc): say when a failed authentication reconnects, and read it that way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WebSocketAuthenticator` promised that a connection closed with `AuthenticationFailed` "is not reconnected". It is, when what stopped the credentials was the network rather than the credentials themselves — which is the case the SDK relies on to recover from a socket dropping mid-handshake. The doc now says so, and asks an authenticator to pass a failed send's error on rather than replacing it, since that error is what the recovery handler ends up branching on. The branch it branches on is reshaped to match: `isCancelled: false` on the reconnecting arm reads as "when cancelled" often enough that it was read that way here, so cancellation is now refused first and every other network failure reconnects — the same shape `ServerInitiated` already uses directly below. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/ws/client/web_socket_authentication_handler.dart | 5 +++-- .../lib/src/ws/client/web_socket_connection_state.dart | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index f97fd950..f8ea31b4 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -19,8 +19,9 @@ typedef WsRequestSender = Result Function(WsRequest request); /// attempt after a refusal sees it. Use it to replace credentials that were refused. /// /// Throw when the credentials did not go out, whether because sending failed or because this -/// function chose not to send them. The connection is then closed with [AuthenticationFailed], and -/// is not reconnected. +/// function chose not to send them. The connection is then closed with [AuthenticationFailed] +/// carrying what was thrown, and reconnected only when that says the network was at fault rather +/// than the credentials — so pass a failed [WsRequestSender]'s error on rather than replacing it. typedef WebSocketAuthenticator = Future Function(WsRequestSender send, StreamApiException? previousError); /// A handler that authenticates newly opened connections and remembers why the server refused the diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 2f8d4a5b..fbe47ac5 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -302,7 +302,8 @@ sealed class DisconnectionSource extends Equatable { // a retry — unless what stopped them was the network itself, which is // about the moment, not the credentials. AuthenticationFailed(:final error) => switch (error) { - StreamNetworkException(isCancelled: false) => true, + StreamNetworkException(isCancelled: true) => false, + StreamNetworkException() => true, _ => false, }, SystemInitiated() => true, From 790ee4339817b784d8b6371131f27de30c35de48 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 11:54:39 +0200 Subject: [PATCH 68/78] fix(llc): let a token provider name the moment, not the credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthenticationFailed` reconnects only on a non-cancelled `StreamNetworkException`, and every producer normalized unknown errors away from that type — so a provider throwing anything its own HTTP client raised left the socket down for good. `TokenManager` now reads a `TimeoutException` as a `StreamNetworkException`, and `TokenProvider.loadToken` documents the rest of the contract: what an implementation throws is how it says whether the moment or the credentials were at fault. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/user/token_manager.dart | 31 +++++++++++++------ .../lib/src/user/token_provider.dart | 8 +++++ .../client/web_socket_connection_state.dart | 9 +++--- .../test/user/token_manager_test.dart | 28 +++++++++++++++++ .../client/stream_web_socket_client_test.dart | 26 ++++++++++++++++ .../web_socket_connection_state_test.dart | 3 ++ 7 files changed, 93 insertions(+), 14 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 70db6297..ce39801a 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -13,7 +13,7 @@ - Reworked the error layer around one sealed root: every failure the SDK reports is a `StreamException` of four kinds — `StreamApiException`, `StreamNetworkException`, `StreamAuthenticationException` or `StreamClientException`. See `ERROR_LAYER.md` for the contract - Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and `DioException.toClientException()` is now `toStreamException()` - `ServerInitiated.error` is typed `StreamException?` rather than `WebSocketEngineException?` -- `TokenManager.getToken` fails with a `StreamAuthenticationException` rather than raw errors; a failed provider's own error is preserved as `cause` +- `TokenManager.getToken` fails with a `StreamAuthenticationException` rather than raw errors; a failed provider's own error is preserved as `cause`. A provider failure that is already a `StreamException`, or a `TimeoutException`, keeps its own kind, so a load that failed at the moment stays retriable - Replaced `StreamApiError.isTokenExpiredError`, `isClientError` and `isRateLimitError`: the conditions live on `StreamErrorCode` and `StreamApiException` as `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid`, `isApiKeyInvalid` and `isRateLimited`; `StreamApiError` keeps only `isRateLimited` - `StreamApiError.code` is typed `StreamErrorCode` rather than `int`; construction takes `StreamErrorCode(40)` in place of `40`, reads are unchanged - `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 010966f3..f8efd305 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import '../errors/stream_exception.dart'; import '../utils/in_flight_cache.dart'; import '../utils/result.dart'; @@ -144,10 +146,12 @@ class TokenManager { /// identity that replaced it. /// /// Fails with a [StreamAuthenticationException] when no identity is configured, when [reset] runs - /// while the token is loading, when the [TokenProvider] fails with anything unclassified — - /// preserved as the exception's [StreamException.cause] — and when it returns a token that does - /// not belong to the user it was loading for. A provider failure that is already a - /// [StreamException] passes through as itself, so a transient network failure stays retriable. + /// while the token is loading, and when the [TokenProvider] returns a token that does not belong + /// to the user it was loading for. + /// + /// A [StreamException] from the [TokenProvider] passes through as itself, a [TimeoutException] + /// becomes a [StreamNetworkException], and anything else a [StreamAuthenticationException] + /// carrying it as [StreamException.cause]. See [TokenProvider.loadToken]. Future getToken() async { final cached = peekToken(); if (cached != null && !_isSpent(cached)) return cached; @@ -208,11 +212,20 @@ class TokenManager { return result.getOrElse((error, stackTrace) { var exception = StreamException.tryFrom(error); - exception ??= StreamAuthenticationException( - message: 'The token provider failed to load a token for user "$userId"', - cause: error, - stackTrace: stackTrace, - ); + exception ??= switch (error) { + // A provider that timed itself out is naming the moment, not the credentials. + TimeoutException() => StreamNetworkException( + message: 'The token provider timed out loading a token for user "$userId"', + isTimeout: true, + cause: error, + stackTrace: stackTrace, + ), + _ => StreamAuthenticationException( + message: 'The token provider failed to load a token for user "$userId"', + cause: error, + stackTrace: stackTrace, + ), + }; throw exception; }); diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 938ef2cc..8385de14 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import '../errors/stream_exception.dart'; import 'user_token.dart'; /// A provider for loading user authentication tokens. @@ -44,6 +47,11 @@ abstract interface class TokenProvider { /// /// Throws an [ArgumentError] if the token does not belong to [userId], or if /// it is not valid for the provider's authentication type. + /// + /// What a failure throws decides whether the connection is retried. A + /// [StreamNetworkException] or [TimeoutException] means the token could not + /// be loaded right then, and it retries; anything else blames the credentials + /// and surfaces as a [StreamAuthenticationException], which does not. Future loadToken(String userId); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index fbe47ac5..659c2c35 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import '../../errors.dart'; +import '../../user/token_provider.dart'; import '../../utils.dart'; import '../events/ws_event.dart'; import 'engine/web_socket_engine.dart'; @@ -273,10 +274,10 @@ sealed class DisconnectionSource extends Equatable { /// /// {@template webSocketReconnectionRules} /// - [UserInitiated] — no, the caller asked for the connection to close. - /// - [AuthenticationFailed] — no, credentials that could not be produced or sent will not fare - /// better on a retry — unless the failure it carries is a non-cancelled - /// [StreamNetworkException], which indicts the moment rather than the credentials (a token - /// endpoint that was briefly unreachable), and reconnects. + /// - [AuthenticationFailed] — no, with or without an error: credentials that could not be + /// produced or sent will not fare better on a retry. The exception is a non-cancelled + /// [StreamNetworkException], which indicts the moment rather than the credentials — see + /// [TokenProvider.loadToken]. /// - [SystemInitiated], [UnHealthyConnection], [ConnectTimeout] — yes. /// - [ServerInitiated] — decided by the error it carries: /// - no error — yes, the closure said nothing against trying again. diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 64b88f18..bbc6ba53 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -224,6 +224,34 @@ void main() { expect(token, generateTestUserToken('user-1')); expect(provider.loadCount, 2); }); + + test('keeps a failure the provider already classified', () async { + const failure = StreamNetworkException(message: 'The token endpoint was unreachable'); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) async => throw failure), + ); + + // A provider saying "this was the moment" must stay a network failure: wrapped as an + // authentication one it would read as "fix the credentials" and stop the reconnect. + await expectLater(manager.getToken(), throwsA(same(failure))); + }); + + test('reads a provider timeout as a network failure', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) async => throw TimeoutException('took too long')), + ); + + await expectLater( + manager.getToken(), + throwsA( + isA() + .having((it) => it.isTimeout, 'isTimeout', isTrue) + .having((it) => it.cause, 'cause', isA()), + ), + ); + }); }); group('expireToken', () { diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index c0a9070b..866388cd 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -413,6 +413,32 @@ void main() { }, ); + wsClientTest( + 'retries when the token provider named the moment rather than the credentials', + authenticator: (_, _) async => throw const StreamNetworkException( + message: 'The token endpoint was unreachable', + ), + recover: true, + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + // A failure the provider classified itself keeps its kind all the way here, which is what + // makes the reconnect carve-out reachable at all. + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error, 'error', isA()), + ), + ); + + expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); + }, + ); + wsClientTest( 'reports a refusal the server sent, carrying what it said', connect: (tester) async { diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 0320f3f6..454c8b13 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -123,8 +123,11 @@ void main() { const state = Disconnected( source: AuthenticationFailed(error: StreamAuthenticationException(message: 'no token')), ); + // A failure that named nothing is no better a case for trying again. + const bare = Disconnected(source: AuthenticationFailed()); expect(state.isAutomaticReconnectionEnabled, isFalse); + expect(bare.isAutomaticReconnectionEnabled, isFalse); }); test('automatic reconnection is enabled when authentication failed on the network, since the ' From 7f7569b02652556bf3371c4cddb770d1da8a890b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 11:54:51 +0200 Subject: [PATCH 69/78] fix(llc): stop exporting objectRuntimeType, which collides with Flutter's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name is also `package:flutter/foundation.dart`'s, so any consumer file importing that alongside `package:stream_core/stream_core.dart` could no longer name either — an `ambiguous_import`. It is a core-internal `toString` helper, so it leaves the barrel rather than the package. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE_GUIDE.md | 3 ++- packages/stream_core/CHANGELOG.md | 1 - packages/stream_core/lib/stream_core.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 5fd55c73..23d2f976 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -790,7 +790,8 @@ do it. For classes that appear in error messages or logs, override `toString`. Avoid bare `$runtimeType` — use `objectRuntimeType(this, 'ClassName')`, which strips runtime -type at release-mode. +type at release-mode. Flutter code gets it from `package:flutter/foundation.dart`; +in `stream_core` it is package-internal, imported from `src/utils/object.dart`. ### Be explicit about `dispose()` and the object lifecycle diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index ce39801a..800d18f1 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -34,7 +34,6 @@ - `User.guest` takes an `image`, which it previously dropped - 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 `objectRuntimeType`, naming an object's type in `toString` in a way that survives minification - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision - Added `DioException.toStreamException()`, mapping a Dio failure to the `StreamException` it represents - Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses diff --git a/packages/stream_core/lib/stream_core.dart b/packages/stream_core/lib/stream_core.dart index 8e7d86b0..33dfe3af 100644 --- a/packages/stream_core/lib/stream_core.dart +++ b/packages/stream_core/lib/stream_core.dart @@ -7,5 +7,5 @@ export 'src/logger.dart'; export 'src/platform.dart'; export 'src/query.dart'; export 'src/user.dart'; -export 'src/utils.dart' hide SharedEmitterImpl, StateEmitterImpl; +export 'src/utils.dart' hide SharedEmitterImpl, StateEmitterImpl, objectRuntimeType; export 'src/ws.dart'; From ec62b0e1d09488031adc41fdbf79b2c57cb3ef04 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 11:54:51 +0200 Subject: [PATCH 70/78] docs: reconcile the two 408 rows, and say what misuse still throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry table retries a 408 while `isReconnectable` reads every 4xx as no-reconnect. Both are right — a connection never sees a 408 — so say which question each row answers instead of leaving them reading as a contradiction. "Nothing is thrown" also overstated it: an operation throws no runtime condition, but misusing one still raises `StateError` or `ArgumentError`. Co-Authored-By: Claude Opus 5 (1M context) --- ERROR_LAYER.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 4fec86ef..1ec5088f 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -96,7 +96,8 @@ Failures arrive on two channels, carrying the same four types: - **Operations** return `Result`; a `Failure` from an SDK operation always holds a `StreamException` — every call runs through the seam that guarantees it (`runApiSafely`, below). - Nothing is thrown. + No runtime condition is thrown. Misuse still is: calling an operation wrongly raises `StateError` + or `ArgumentError`, which is a bug to fix rather than a failure to handle. - **Connection lifecycle** failures arrive as state: `connectionState` emits `Disconnected(source)`, where `source` says who ended the connection and carries the error when there was one: @@ -154,7 +155,7 @@ already carry the right type — if you are not writing a boundary, you never pi | HTTP error mapper (the only file that reads Dio) | `StreamApiException` from a server error body or bare status; `StreamNetworkException` from timeout / cancel / socket errors | | Response/event decoding | `StreamClientException` when wire data will not decode, whatever the decoder threw (see the seam rule below) | | WebSocket engine + auth handler | `StreamNetworkException` for transport failures; `StreamAuthenticationException` when credentials couldn't be sent; server error events become `StreamApiException` — the inner error object is the same as REST, but it arrives in two envelopes (`{"type":"connection.error",...}` from the monolith, bare `{"error":{...}}` from the edge) and the decoder must accept both | -| `TokenManager` | `StreamAuthenticationException` when no user is configured, when a reset raced the load, or when the `TokenProvider` fails with something unclassified (preserved as `cause`); a provider failure that is already a `StreamException` passes through as itself, so a transient network failure stays retriable | +| `TokenManager` | `StreamAuthenticationException` when no user is configured, when a reset raced the load, or when the `TokenProvider` fails with something unclassified (preserved as `cause`); a provider failure that is already a `StreamException` passes through as itself, and a `TimeoutException` becomes a `StreamNetworkException`, so what is about the moment stays retriable | | `runApiSafely` (the API call seam) | passes an existing `StreamException` through untouched; maps Dio failures to `StreamApiException`/`StreamNetworkException`; wraps anything else — `Exception` or `Error` alike — into `StreamClientException`, preserving `cause` | Both capture helpers catch **everything**, `Error` included — they differ in what they hand back: @@ -228,11 +229,15 @@ The decision runs in order: 3. **Apply the budget**: max attempts, exponential backoff with jitter, a delay cap. `DisconnectionSource.isReconnectable` is this procedure specialized for the connection (reconnecting -is inherently idempotent), and the interceptor's one-shot token refresh is the code-40 row. What -remains for callers is operation retry: steps 1–2 answer *whether* from the error alone (necessary, -but not sufficient, since the error cannot know the operation's idempotency), *when* comes from -`retryAfter` where the server named a wait and from the caller's backoff otherwise, and the budget -is the caller's. Product SDKs compose this into their retry queues. +is inherently idempotent), and the interceptor's one-shot token refresh is the code-40 row. It parts +from the table on one row: it treats every 4xx as no-reconnect, 408 included, because a 408 is an +HTTP verdict on a request that arrived too slowly, and nothing on the connection path produces one. +The 408 row is for operation retry, where the status can actually arrive. + +What remains for callers is operation retry: steps 1–2 answer *whether* from the error alone +(necessary, but not sufficient, since the error cannot know the operation's idempotency), *when* +comes from `retryAfter` where the server named a wait and from the caller's backoff otherwise, and +the budget is the caller's. Product SDKs compose this into their retry queues. One honesty rule about retrying writes: a `StreamNetworkException` means the outcome is **unknown** — the server may have performed the operation. Retry a write only through an idempotent path From 4f4343fd42cb684879df0d818643b2112d299d94 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:28:56 +0200 Subject: [PATCH 71/78] fix(llc): classify what the HTTP client could not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dio raises transport trouble under its own types, so a failure carrying neither a response nor one of those came out of its own pipeline — a body its response transformer could not decode above all. Reporting that as a `StreamNetworkException` said the outcome was unknown and invited a retry of a write the server had already performed. `Retry-After` is read as the list a header always is. `Headers.value` throws when one was sent twice, and it threw from inside the mapper — which `runApiSafely` calls from its own `catch`, so the throw escaped the seam that promises every failure arrives as a `Result`. The dropped test that pinned `statusCode` coming from the payload while `retryAfter` comes from the headers is restored; without it a plausible simplification to `response.statusCode ?? 0` passed green while breaking `isRateLimited` and the reconnect verdict. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/interceptors/auth_interceptor.dart | 9 +- .../src/api/stream_core_dio_exception.dart | 45 ++++--- .../api/stream_core_dio_exception_test.dart | 122 ++++++++++++++++++ 3 files changed, 150 insertions(+), 26 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 8e4bb72c..696e25da 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -37,16 +37,15 @@ class AuthInterceptor extends Interceptor { options.headers['stream-auth-type'] = token.authType.headerValue; return handler.next(options); - } catch (e, stackTrace) { - _logger.w(() => 'no token to sign ${options.uri} with', error: e, stackTrace: stackTrace); + } catch (error, stackTrace) { + _logger.w(() => 'no token to sign ${options.uri} with', error: error, stackTrace: stackTrace); // Caught in full: a rejection must deliver a StreamException whatever // the app's token code threw. - var exception = StreamException.tryFrom(e); + var exception = StreamException.tryFrom(error); exception ??= StreamAuthenticationException( message: 'Failed to load an auth token', - cause: e, - stackTrace: stackTrace, + cause: error, ); final dioError = StreamDioException( diff --git a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart index 7b505412..fab18a47 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_exception.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_exception.dart @@ -5,7 +5,6 @@ import 'package:dio/dio.dart'; import '../errors.dart'; import '../utils/result.dart'; -import '../utils/standard.dart'; /// A [DioException] carrying the [StreamException] that caused it. /// @@ -34,10 +33,9 @@ class StreamDioException extends DioException { extension DioExceptionMapping on DioException { /// This failure as the [StreamException] it represents. /// - /// A response from the server — parseable Stream error payload or bare - /// status — becomes a [StreamApiException]. Everything that ended before a - /// verdict (timeout, cancellation, socket failure) becomes a - /// [StreamNetworkException]. + /// An answer from the server becomes a [StreamApiException], a transport + /// failure a [StreamNetworkException], and data that could not be read a + /// [StreamClientException]. StreamException toStreamException() { if (this case StreamDioException(:final exception)) return exception; @@ -46,56 +44,58 @@ extension DioExceptionMapping on DioException { // than re-diagnosed from a wrapper that has no response to read. if (error case final StreamException exception) return exception; - if (type == DioExceptionType.cancel) { + if (type == .cancel) { return StreamNetworkException( message: 'The request was cancelled', isCancelled: true, cause: this, - stackTrace: stackTrace, ); } - if (_isTimeout) { + if (type case .connectionTimeout || .sendTimeout || .receiveTimeout) { return StreamNetworkException( message: 'The request timed out before the server answered', isTimeout: true, cause: this, - stackTrace: stackTrace, ); } // A response means the server reached a verdict, even when its body is // not a Stream error payload — an edge or proxy answering on its own. if (response case final response?) { + final retryAfter = _parseRetryAfter(response); + if (_parseApiError(response.data) case final apiError?) { return StreamApiException.fromApiError( apiError, - retryAfter: _parseRetryAfter(response), + retryAfter: retryAfter, cause: this, - stackTrace: stackTrace, ); } return StreamApiException( message: response.statusMessage ?? message ?? 'The server responded with an error', statusCode: response.statusCode ?? 0, - retryAfter: _parseRetryAfter(response), + retryAfter: retryAfter, cause: this, - stackTrace: stackTrace, + ); + } + + // Dio reports transport trouble under its own types, so a failure carrying + // neither a response nor one of those came out of its own pipeline: a body + // it could not decode, or a request it could not build. + if (error case FormatException() || TypeError()) { + return StreamClientException( + message: 'The request could not be completed', + cause: error, ); } return StreamNetworkException( message: message ?? 'The request failed before the server answered', cause: this, - stackTrace: stackTrace, ); } - - bool get _isTimeout => switch (type) { - DioExceptionType.connectionTimeout || DioExceptionType.sendTimeout || DioExceptionType.receiveTimeout => true, - _ => false, - }; } // An interpretation seam: decoding wire data catches everything, `Error` @@ -112,7 +112,11 @@ StreamApiError? _parseApiError(Object? data) { } Duration? _parseRetryAfter(Response response) { - final seconds = response.headers.value('retry-after')?.let(int.tryParse); + final values = response.headers['retry-after']; + if (values == null || values.isEmpty) return null; + + // Only the delta-seconds form is read; RFC 9110 also allows an HTTP date. + final seconds = int.tryParse(values.first); if (seconds == null || seconds < 0) return null; return Duration(seconds: seconds); } @@ -138,7 +142,6 @@ Future> runApiSafely(FutureOr Function() block) async { final exception = StreamClientException( message: 'The API call failed unexpectedly', cause: e, - stackTrace: stackTrace, ); return Result.failure(exception, stackTrace); diff --git a/packages/stream_core/test/api/stream_core_dio_exception_test.dart b/packages/stream_core/test/api/stream_core_dio_exception_test.dart index ba2b838a..8d266d0e 100644 --- a/packages/stream_core/test/api/stream_core_dio_exception_test.dart +++ b/packages/stream_core/test/api/stream_core_dio_exception_test.dart @@ -45,6 +45,128 @@ DioException _failure({ void main() { group('DioException.toStreamException', () { + test('takes the status from the payload, and the wait from the headers', () { + // The two deliberately disagree, so each assertion says which one won. + // Reading the status off the response instead would leave a rate limit + // behind an edge's 500 reporting as neither rate limited nor 4xx. + final exception = _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'rate limited'), + statusCode: 500, + headers: const { + 'retry-after': ['30'], + }, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.statusCode, 'statusCode', 429) + .having((it) => it.isRateLimited, 'isRateLimited', isTrue) + .having((it) => it.retryAfter, 'retryAfter', const Duration(seconds: 30)), + ); + }); + + test('reports a body the transport could not decode as an SDK failure', () { + // Dio raises transport trouble under its own types, so an `unknown` + // without a response came out of its pipeline — its response transformer + // failing on a truncated body above all. The server answered and did + // what was asked; reporting that as a network failure would say the + // outcome is unknown and invite a retry of a write already performed. + final exception = DioException( + requestOptions: RequestOptions(path: '/test'), + error: const FormatException('Unexpected end of input'), + ).toStreamException(); + + expect( + exception, + isA().having((it) => it.cause, 'cause', isA()), + ); + }); + + test('keeps a status the server answered with, even when the body would not decode', () { + // A response means a verdict was reached, whatever its body turned out + // to be. Reading this as an SDK failure would lose the status. + final exception = _failure( + body: 'gateway error', + statusCode: 502, + ).toStreamException(); + + expect(exception, isA().having((it) => it.statusCode, 'statusCode', 502)); + }); + + test('survives a Retry-After sent more than once', () { + // Reading it as a single value throws, and this runs while an error is + // already being reported — so the mapper itself would fail, and the + // failure would escape the seam that promises to classify it. + final exception = _failure( + body: _errorBody(code: 9, statusCode: 429, message: 'rate limited'), + statusCode: 429, + headers: const { + 'retry-after': ['30', '60'], + }, + ).toStreamException(); + + expect( + exception, + isA().having((it) => it.retryAfter, 'retryAfter', const Duration(seconds: 30)), + ); + }); + + test('reports a bare status when a payload field is not the type it should be', () { + // The mapper runs while a failure is already being reported, so nothing + // in it may throw: a field of the wrong type has to cost the payload, + // not the error. + final exception = _failure( + body: const {'code': 'not-a-number', 'message': 'refused', 'StatusCode': 401}, + statusCode: 401, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.statusCode, 'statusCode', 401) + .having((it) => it.code, 'code', isNull), + ); + }); + + test('reads the details a moderation rejection sends as objects', () { + // The case `_detailsFromJson` exists for: code 73 sends a list of + // objects rather than of numbers, which must read as empty rather than + // failing the whole error. + final exception = _failure( + body: _errorBody( + code: 73, + details: const [ + {'field': 'text'}, + ], + ), + statusCode: 400, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 73) + .having((it) => it.apiError?.details, 'apiError.details', isEmpty), + ); + }); + + test('reports a bare status when the payload will not decode', () { + // A payload missing a field it once required still answers with what a + // caller needs, rather than losing the whole error. + final exception = _failure( + body: {'code': 40, 'message': 'token expired', 'StatusCode': 401}, + statusCode: 401, + ).toStreamException(); + + expect( + exception, + isA() + .having((it) => it.code, 'code', 40) + .having((it) => it.isTokenExpired, 'isTokenExpired', isTrue), + ); + }); + test('reads the Stream error from a decoded body', () { final exception = _failure(body: _errorBody(), statusCode: 401).toStreamException(); From da1dbd8e48c7e5fcaf4c3ecea11f6e6857647960 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:29:00 +0200 Subject: [PATCH 72/78] fix(llc): decode an error payload missing the fields that carry least MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `duration` and `more_info` were required, so a payload without one failed to decode and took the `code` with it — which reads as a bare status, so `isTokenExpired` is false and the interceptor stops refreshing. Both read as empty now; `code`, `message` and `StatusCode` stay strict, matching what the API declares as required. Verified the rest against the backend while here. `unrecoverable` is not Video-only: every v2 permission denial carries it, so an app following the old dartdoc would retry a 403 the server marked unretryable. A WebSocket error can carry `more_info`. Code 4 covers more than validation. Code 113 was missing. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/errors/stream_api_error.dart | 17 +++++--- .../lib/src/errors/stream_api_error.g.dart | 4 +- .../lib/src/errors/stream_error_code.dart | 9 ++++- .../lib/src/errors/stream_exception.dart | 16 ++------ .../test/errors/stream_error_code_test.dart | 40 +++++++++++++++++++ .../test/errors/stream_exception_test.dart | 22 ++++++++++ 6 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 packages/stream_core/test/errors/stream_error_code_test.dart diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index f34ecc3a..76707a82 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -37,12 +37,15 @@ class StreamApiError extends Equatable { /// Additional error detail codes providing more context. /// - /// Not every error carries numeric detail codes; anything else in the wire - /// value reads as absent rather than failing the whole error. + /// Anything that is not a number reads as absent rather than failing the + /// whole error. @JsonKey(fromJson: _detailsFromJson) final List details; /// The processing duration before the error occurred. + /// + /// Empty when the error arrived without one. + @JsonKey(defaultValue: '') final String duration; /// Additional context about the exception as key-value pairs. @@ -52,6 +55,9 @@ class StreamApiError extends Equatable { final String message; /// Additional information or documentation URL for this error. + /// + /// Empty when the error arrived without one. + @JsonKey(defaultValue: '') final String moreInfo; /// The HTTP status code associated with this error. @@ -60,10 +66,9 @@ class StreamApiError extends Equatable { /// Whether this error is unrecoverable and should not be retried. /// - /// Only Video sets this as a deliberate retry signal, so it is only worth - /// consulting there. Absence means nothing anywhere: most errors never - /// carry it, and `null` or `false` must not be read as "retrying will - /// help". + /// Worth consulting on any product: a permission denial carries it, as do + /// some Video errors. Absence means nothing: most errors never carry it, + /// and `null` or `false` must not be read as "retrying will help". final bool? unrecoverable; Map toJson() => _$StreamApiErrorToJson(this); diff --git a/packages/stream_core/lib/src/errors/stream_api_error.g.dart b/packages/stream_core/lib/src/errors/stream_api_error.g.dart index e6c4dbe2..7bea0229 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.g.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.g.dart @@ -9,12 +9,12 @@ part of 'stream_api_error.dart'; StreamApiError _$StreamApiErrorFromJson(Map json) => StreamApiError( code: StreamErrorCode.fromJson(json['code'] as num), details: _detailsFromJson(json['details']), - duration: json['duration'] as String, + duration: json['duration'] as String? ?? '', exceptionFields: (json['exception_fields'] as Map?)?.map( (k, e) => MapEntry(k, e as String), ), message: json['message'] as String, - moreInfo: json['more_info'] as String, + moreInfo: json['more_info'] as String? ?? '', statusCode: (json['StatusCode'] as num).toInt(), unrecoverable: json['unrecoverable'] as bool?, ); diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart index 5587c16f..9508032c 100644 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -25,7 +25,8 @@ extension type const StreamErrorCode(int code) implements int { /// addresses is not enabled for the app. static const apiKeyInvalid = StreamErrorCode(2); - /// `4` – The request input failed validation. + /// `4` – The request could not be acted on as sent: input that failed + /// validation, or a feature the app has not configured. static const inputError = StreamErrorCode(4); /// `5` – Authentication failed for a reason other than the token codes @@ -156,6 +157,12 @@ extension type const StreamErrorCode(int code) implements int { /// `112` – Feeds storage is unavailable. static const feedsStorageUnavailable = StreamErrorCode(112); + + /// `113` – Some of the users named by the request do not exist. + /// + /// Distinct from [notFound] so the missing ids can be read from the error's + /// `exception_fields` and created before trying again. + static const usersNotFound = StreamErrorCode(113); } /// Convenience predicates grouping the codes that share a remedy. diff --git a/packages/stream_core/lib/src/errors/stream_exception.dart b/packages/stream_core/lib/src/errors/stream_exception.dart index 5948707b..671556bb 100644 --- a/packages/stream_core/lib/src/errors/stream_exception.dart +++ b/packages/stream_core/lib/src/errors/stream_exception.dart @@ -29,7 +29,6 @@ sealed class StreamException extends Equatable implements Exception { const StreamException({ required this.message, this.cause, - this.stackTrace, }); /// The [StreamException] that [error] represents, or `null` when it does @@ -60,9 +59,6 @@ sealed class StreamException extends Equatable implements Exception { /// The failure underneath this one, when this exception wraps another. final Object? cause; - /// Where the failure was raised. - final StackTrace? stackTrace; - @override List get props => [message, cause]; @@ -93,7 +89,6 @@ base class StreamApiException extends StreamException { this.retryAfter, this.apiError, super.cause, - super.stackTrace, }); /// Creates a [StreamApiException] from the server's error payload. @@ -101,7 +96,6 @@ base class StreamApiException extends StreamException { StreamApiError error, { Duration? retryAfter, Object? cause, - StackTrace? stackTrace, }) { return StreamApiException( message: error.message, @@ -112,7 +106,6 @@ base class StreamApiException extends StreamException { retryAfter: retryAfter, apiError: error, cause: cause, - stackTrace: stackTrace, ); } @@ -134,13 +127,13 @@ base class StreamApiException extends StreamException { /// A documentation URL for this error, when the server sent one. /// - /// Populated on REST errors; WebSocket errors carry none. + /// Most errors carry none; a refused connection is the likeliest to. final String? moreInfo; /// Whether the server declared that retrying will not help. /// - /// Only Video sets this as a deliberate retry signal, so it is only worth - /// consulting there. Authoritative when `true`; `false` only means the + /// Worth consulting on any product: a permission denial carries it, as do + /// some Video errors. Authoritative when `true`; `false` only means the /// server said nothing — it must not be read as "retrying will help". final bool unrecoverable; @@ -222,7 +215,6 @@ base class StreamNetworkException extends StreamException { this.isTimeout = false, this.closeCode, super.cause, - super.stackTrace, }); /// Whether the caller cancelled the request. @@ -271,7 +263,6 @@ base class StreamAuthenticationException extends StreamException { const StreamAuthenticationException({ required super.message, super.cause, - super.stackTrace, }); @override @@ -294,7 +285,6 @@ base class StreamClientException extends StreamException { const StreamClientException({ required super.message, super.cause, - super.stackTrace, }); @override diff --git a/packages/stream_core/test/errors/stream_error_code_test.dart b/packages/stream_core/test/errors/stream_error_code_test.dart new file mode 100644 index 00000000..5168547b --- /dev/null +++ b/packages/stream_core/test/errors/stream_error_code_test.dart @@ -0,0 +1,40 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamErrorCode', () { + test('behaves as its number, so an unknown code still reads', () { + // The registry grows server-side. A code with no constant yet has to + // survive the wire rather than fail the error it arrived on. + const unknown = StreamErrorCode(9999); + + expect(unknown, 9999); + expect(unknown.toString(), '9999'); + expect(StreamErrorCode.tokenExpired, 40); + }); + + test('reads an integral double, the shape a JSON number takes on web', () { + expect(StreamErrorCode.fromJson(40), StreamErrorCode.tokenExpired); + expect(StreamErrorCode.fromJson(40.0), StreamErrorCode.tokenExpired); + }); + + test('names the fix rather than the number', () { + expect(StreamErrorCode.tokenExpired.isTokenExpired, isTrue); + expect(StreamErrorCode.tokenSignatureInvalid.isTokenExpired, isFalse); + + // Two codes, one condition: a clock that disagrees with the token's + // claims, which waiting fixes and a fresh token does not. + expect(StreamErrorCode.tokenNotValidYet.isTokenNotYetValid, isTrue); + expect(StreamErrorCode.tokenUsedBeforeIssuedAt.isTokenNotYetValid, isTrue); + expect(StreamErrorCode.tokenExpired.isTokenNotYetValid, isFalse); + + expect(StreamErrorCode.tokenSignatureInvalid.isTokenSignatureInvalid, isTrue); + expect(StreamErrorCode.apiKeyInvalid.isApiKeyInvalid, isTrue); + }); + + test('round-trips through json', () { + expect(StreamErrorCode.toJson(StreamErrorCode.rateLimited), 9); + expect(StreamErrorCode.fromJson(StreamErrorCode.toJson(StreamErrorCode.inputError)), StreamErrorCode.inputError); + }); + }); +} diff --git a/packages/stream_core/test/errors/stream_exception_test.dart b/packages/stream_core/test/errors/stream_exception_test.dart index 7bdb47d9..d3c1e913 100644 --- a/packages/stream_core/test/errors/stream_exception_test.dart +++ b/packages/stream_core/test/errors/stream_exception_test.dart @@ -176,6 +176,28 @@ void main() { expect(withDetails(const [1]), isNot(withDetails(const [2]))); expect(withDetails(const [1]), withDetails(const [1])); }); + + test('a fact the constructor was handed on its own is a different failure', () { + // The payload overlaps `statusCode`, `code` and `moreInfo`, so `props` + // carrying both looks redundant — but the constructor takes them with no + // payload at all, which is how the WebSocket path and every subclass + // build one. Dropping them would make two refusals that behave in + // opposite ways compare equal. + const expired = StreamApiException(message: 'Refused', statusCode: 401, code: StreamErrorCode.tokenExpired); + const badSignature = StreamApiException( + message: 'Refused', + statusCode: 401, + code: StreamErrorCode.tokenSignatureInvalid, + ); + + expect(expired, isNot(badSignature)); + expect(expired.isTokenExpired, isNot(badSignature.isTokenExpired)); + + expect( + const StreamApiException(message: 'Refused', statusCode: 401), + isNot(const StreamApiException(message: 'Refused', statusCode: 500)), + ); + }); }); group('StreamNetworkException', () { From 73d172cdab35839d044bd5490577f7b584df846f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:29:17 +0200 Subject: [PATCH 73/78] fix(llc): classify a token provider's transport failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_loadFrom` classified by Dart type only, and `tryFrom` knows nothing of Dio — so the ordinary provider, `(id) async => (await dio.get('/token')).data`, hit the fallback on any blip and reported credentials at fault. That reads as "not reconnectable", the recovery handler then clears `_hasEstablishedConnection`, and realtime stays down until the app calls `connect` again. A `DioException` is now classified the way the SDK classifies its own, so an unreachable token endpoint indicts the moment and reconnects while a refusal still reads as a verdict. The provider's own stack trace survives the rethrow. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/user/token_manager.dart | 15 ++--- .../lib/src/user/token_provider.dart | 8 +-- .../test/user/token_manager_test.dart | 61 +++++++++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index f8efd305..cdfc3bac 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:dio/dio.dart'; + +import '../api/stream_core_dio_exception.dart'; import '../errors/stream_exception.dart'; import '../utils/in_flight_cache.dart'; import '../utils/result.dart'; @@ -149,9 +152,8 @@ class TokenManager { /// while the token is loading, and when the [TokenProvider] returns a token that does not belong /// to the user it was loading for. /// - /// A [StreamException] from the [TokenProvider] passes through as itself, a [TimeoutException] - /// becomes a [StreamNetworkException], and anything else a [StreamAuthenticationException] - /// carrying it as [StreamException.cause]. See [TokenProvider.loadToken]. + /// A failing [TokenProvider] fails this too, with the kind its own failure named — see + /// [TokenProvider.loadToken]. Future getToken() async { final cached = peekToken(); if (cached != null && !_isSpent(cached)) return cached; @@ -213,21 +215,20 @@ class TokenManager { return result.getOrElse((error, stackTrace) { var exception = StreamException.tryFrom(error); exception ??= switch (error) { - // A provider that timed itself out is naming the moment, not the credentials. + DioException() => error.toStreamException(), TimeoutException() => StreamNetworkException( message: 'The token provider timed out loading a token for user "$userId"', isTimeout: true, cause: error, - stackTrace: stackTrace, ), _ => StreamAuthenticationException( message: 'The token provider failed to load a token for user "$userId"', cause: error, - stackTrace: stackTrace, ), }; - throw exception; + // The provider's own trace, not this line's. + Error.throwWithStackTrace(exception, stackTrace ?? StackTrace.current); }); } diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 8385de14..c52c0a4f 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -48,10 +48,10 @@ abstract interface class TokenProvider { /// Throws an [ArgumentError] if the token does not belong to [userId], or if /// it is not valid for the provider's authentication type. /// - /// What a failure throws decides whether the connection is retried. A - /// [StreamNetworkException] or [TimeoutException] means the token could not - /// be loaded right then, and it retries; anything else blames the credentials - /// and surfaces as a [StreamAuthenticationException], which does not. + /// What a failure throws decides whether the connection is retried: a + /// [StreamNetworkException] or [TimeoutException] is about the moment and + /// retries, a `DioException` is read for what it carries, and anything else + /// blames the credentials and does not. Future loadToken(String userId); } diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index bbc6ba53..1143557b 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -225,6 +225,30 @@ void main() { expect(provider.loadCount, 2); }); + test('reports the failure with the trace the provider raised it at', () async { + final raised = StackTrace.current; + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider( + (_) => Future.error(StateError('load failed'), raised), + ), + ); + + // A plain rethrow would restart the trace inside `TokenManager`, which + // points at the SDK rather than at the code that actually failed. + await expectLater( + manager.getToken(), + throwsA(isA()), + ); + + try { + await manager.getToken(); + fail('expected a throw'); + } catch (_, stackTrace) { + expect(stackTrace, same(raised)); + } + }); + test('keeps a failure the provider already classified', () async { const failure = StreamNetworkException(message: 'The token endpoint was unreachable'); final manager = TokenManager( @@ -237,6 +261,43 @@ void main() { await expectLater(manager.getToken(), throwsA(same(failure))); }); + test('reads a provider that could not reach its endpoint as a network failure', () async { + // The ordinary provider fetches over Dio. Blaming the credentials for a + // blip would leave the connection down until the app connects again. + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider( + (_) async => throw DioException.connectionError( + requestOptions: RequestOptions(path: '/token'), + reason: 'network is unreachable', + ), + ), + ); + + await expectLater(manager.getToken(), throwsA(isA())); + }); + + test('keeps a refusal the token endpoint answered with', () async { + // A refusal is the server's verdict, not the moment's, so it must not + // read as retriable the way an unreachable endpoint does. + final options = RequestOptions(path: '/token'); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider( + (_) async => throw DioException.badResponse( + statusCode: 403, + requestOptions: options, + response: Response(requestOptions: options, statusCode: 403), + ), + ), + ); + + await expectLater( + manager.getToken(), + throwsA(isA().having((it) => it.statusCode, 'statusCode', 403)), + ); + }); + test('reads a provider timeout as a network failure', () async { final manager = TokenManager( userId: 'user-1', From 275dad5239c94e5ace72bf8bb200623362530f87 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:29:18 +0200 Subject: [PATCH 74/78] fix(llc): tell misuse from a condition, and keep the trace beside the failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `send` mapped every engine `StateError` to a `StreamNetworkException`, so calling it before `connect` — the case both rulebooks use as their "never wrapped" example — became the one reconnectable branch of `isReconnectable` and drove a backoff loop over an ordering bug. It now throws while the client is `Initialized`, which leaves the engine's `StateError` meaning what it says: an established connection that dropped. `ServerInitiated` and `AuthenticationFailed` carry a `stackTrace` beside their error, the shape `Failure` already uses. A failure that travels as data has no catch site to ask, and three producers had a real trace they were discarding — the socket failure only reached the log. A closure read off the wire carries none, which is the honest answer rather than a trace pointing at the decoder. Co-Authored-By: Claude Opus 5 (1M context) --- .../connection_recovery_handler.dart | 2 +- .../ws/client/stream_web_socket_client.dart | 35 ++++++++++--------- .../client/web_socket_connection_state.dart | 16 +++++++-- .../client/stream_web_socket_client_test.dart | 16 ++++++++- .../web_socket_connection_state_test.dart | 13 +++++++ 5 files changed, 60 insertions(+), 22 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 6dc268ae..3ac62208 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -36,7 +36,7 @@ import 'retry_strategy.dart'; /// final recoveryHandler = ConnectionRecoveryHandler( /// client: client, /// networkStateProvider: NetworkStateProvider(), -/// appLifecycleStateProvider: AppLifecycleStateProvider(), +/// lifecycleStateProvider: myLifecycleStateProvider, /// ); /// ``` class ConnectionRecoveryHandler extends Disposable { diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 0a5ed967..b22adb9f 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -96,10 +96,10 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, exception ??= StreamAuthenticationException( message: 'The connection could not be authenticated', cause: error, - stackTrace: stackTrace, ); - unawaited(disconnect(source: .authenticationFailed(error: exception))); + final source = DisconnectionSource.authenticationFailed(error: exception, stackTrace: stackTrace); + unawaited(disconnect(source: source)); }, ); } @@ -162,24 +162,26 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// /// The [request] is encoded using the configured message codec and sent to the server. /// - /// Returns a [Result] indicating success or failure of the send operation. - /// A failure holds a [StreamException]: a [StreamNetworkException] when the - /// connection is not open — a drop can race any send — or a - /// [StreamClientException] when the request could not be encoded. + /// Returns a [Result] whose failure carries a [StreamNetworkException] when + /// the connection dropped, and a [StreamClientException] otherwise. + /// + /// Throws a [StateError] when this client has not been connected. Result send(WsRequest request) { + if (connectionState.value case Initialized()) { + throw StateError('The connection has not been opened. Call connect() first.'); + } + final result = _engine.sendMessage(request); if (result case Failure(:final error, :final stackTrace)) { var exception = StreamException.tryFrom(error); exception ??= switch (error) { StateError() => StreamNetworkException( - message: 'The connection is not open', + message: 'The connection dropped before the request went out', cause: error, - stackTrace: stackTrace, ), _ => StreamClientException( message: 'The request could not be sent', cause: error, - stackTrace: stackTrace, ), }; @@ -229,10 +231,10 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, exception ??= StreamNetworkException( message: 'Failed to open the connection to ${options.url}', cause: error, - stackTrace: stackTrace, ); - return disconnect(source: .serverInitiated(error: exception)); + final source = DisconnectionSource.serverInitiated(error: exception, stackTrace: stackTrace); + return disconnect(source: source); } } @@ -310,17 +312,16 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, void onError(Object error, [StackTrace? stackTrace]) { _logger.e(() => 'socket failed', error: error, stackTrace: stackTrace); - final source = ServerInitiated( - error: StreamNetworkException( - message: 'The connection reported an error', - cause: error, - stackTrace: stackTrace, - ), + var exception = StreamException.tryFrom(error); + exception ??= StreamNetworkException( + message: 'The connection reported an error', + cause: error, ); // Update the connection state to 'disconnecting' with the source. // // The socket closes itself after an error, so the closure that follows records the disconnection. + final source = ServerInitiated(error: exception, stackTrace: stackTrace); _connectionState = WebSocketConnectionState.disconnecting(source: source); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 659c2c35..a80b246b 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -218,6 +218,7 @@ sealed class DisconnectionSource extends Equatable { /// Reconnection eligibility depends on the specific error type. const factory DisconnectionSource.serverInitiated({ StreamException? error, + StackTrace? stackTrace, }) = ServerInitiated; /// Creates a [SystemInitiated] disconnection source. @@ -242,7 +243,10 @@ sealed class DisconnectionSource extends Equatable { /// /// Indicates that the connection opened but could not be authenticated, so it /// was closed without ever being usable. - const factory DisconnectionSource.authenticationFailed({StreamException? error}) = AuthenticationFailed; + const factory DisconnectionSource.authenticationFailed({ + StreamException? error, + StackTrace? stackTrace, + }) = AuthenticationFailed; /// A human-readable description of the disconnection source. /// @@ -347,7 +351,7 @@ final class UserInitiated extends DisconnectionSource { /// provides additional context about the disconnection cause. final class ServerInitiated extends DisconnectionSource { /// Creates a [ServerInitiated] disconnection source. - const ServerInitiated({this.error}); + const ServerInitiated({this.error, this.stackTrace}); /// The error that caused the server to close the connection. /// @@ -356,6 +360,9 @@ final class ServerInitiated extends DisconnectionSource { /// the closure when it did not. final StreamException? error; + /// Where [error] was raised, when it was raised rather than read off the wire. + final StackTrace? stackTrace; + @override List get props => [error]; } @@ -396,7 +403,7 @@ final class ConnectTimeout extends DisconnectionSource { /// error event instead. final class AuthenticationFailed extends DisconnectionSource { /// Creates an [AuthenticationFailed] disconnection source. - const AuthenticationFailed({this.error}); + const AuthenticationFailed({this.error, this.stackTrace}); /// The error that prevented the connection from authenticating. /// @@ -404,6 +411,9 @@ final class AuthenticationFailed extends DisconnectionSource { /// is whatever the authenticator threw. final StreamException? error; + /// Where [error] was raised. + final StackTrace? stackTrace; + @override List get props => [error]; } diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 866388cd..231df881 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -189,9 +189,23 @@ void main() { group('send', () { wsClientTest( - 'fails as a network problem when the connection is not open', + 'throws when nothing has connected yet, which is an order to fix rather than a failure', connect: (_) {}, // never connected body: (tester) { + // Reported where the caller wrote it: no retry, and no reconnection, + // makes a send that came before the connection work. + expect( + () => tester.client.send(const HealthCheckPingEvent(connectionId: 'connection-id')), + throwsStateError, + ); + }, + ); + + wsClientTest( + 'fails as a network problem when a connection that was established has dropped', + body: (tester) async { + await tester.client.disconnect(); + // A drop can race any send, so a correct caller can hit this: it // reads as the moment's failure, classified like every other one. final result = tester.client.send(const HealthCheckPingEvent(connectionId: 'connection-id')); diff --git a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart index 454c8b13..59f54026 100644 --- a/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart +++ b/packages/stream_core/test/ws/client/web_socket_connection_state_test.dart @@ -151,6 +151,19 @@ void main() { expect(state.isAutomaticReconnectionEnabled, isTrue); }); + test('a source carries the trace of a failure that was raised, and none for one that was read', () { + final raised = StackTrace.current; + + // Removed from the exception, so the source is what carries it — the same + // shape `Failure` uses, error and trace side by side. + const failure = StreamNetworkException(message: 'gone'); + expect(ServerInitiated(error: failure, stackTrace: raised).stackTrace, same(raised)); + expect(AuthenticationFailed(error: failure, stackTrace: raised).stackTrace, same(raised)); + + // A closure the server reported arrives as data, so there is none. + expect(const ServerInitiated(error: StreamApiException(message: 'refused', statusCode: 403)).stackTrace, isNull); + }); + test('closeReason reads differently for every source', () { const sources = [ UserInitiated(), From ca848a00ccb25001001201dcb916c98ac36b0e58 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:29:30 +0200 Subject: [PATCH 75/78] docs: say where the connection path parts from the retry table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isReconnectable` differs on three rows, not the one that was disclosed: an expired token and an SDK failure reconnect where the table says no, and every 4xx does not where it says yes. The reason belongs to the token row alone — a reconnect authenticates again where an operation retry cannot — so it is attached there rather than spread over all three. The sealed-switch guarantee needed narrowing too: `Failure.error` is `Object`, so the check applies only once you have narrowed to the root. The example now narrows inside the case, since narrowing in the `Failure` pattern leaves the outer switch non-exhaustive and does not compile. `doc/web_socket.md` documented a constructor and a callback that no longer exist, listed reconnection rules that had inverted, and named a provider class that was never real. Its rule list now points at `isReconnectable` rather than keeping a copy that drifts. Co-Authored-By: Claude Opus 5 (1M context) --- ERROR_LAYER.md | 48 ++++++++++++----------- packages/stream_core/doc/web_socket.md | 53 ++++++++++++++------------ 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index 1ec5088f..e3a264a7 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -8,7 +8,7 @@ Every failure a Stream SDK reports is a `StreamException`. There are exactly fou what the caller should do about them: ``` -StreamException (sealed) message · cause · stackTrace +StreamException (sealed) message · cause ├── StreamApiException the server answered, and the answer was an error ├── StreamNetworkException the server was never heard from — outcome unknown ├── StreamAuthenticationException credentials could not be produced or sent @@ -20,18 +20,18 @@ sealed class StreamException implements Exception { final String message; // always present, developer-readable; for user-facing UI, // key your own strings off `code` (see localization note below) final Object? cause; // the error underneath, when this wraps another - final StackTrace? stackTrace; } base class StreamApiException extends StreamException { final int statusCode; // HTTP status — independent of `code`; never derive one from the other final StreamErrorCode? code; // Stream's stable error code — branch on this, never on message. // Null when the verdict never reached Stream (a proxy's bare status) - final String? moreInfo; // docs URL; populated on REST errors, empty on WebSocket errors + final String? moreInfo; // docs URL, when the server sent one; most errors carry none final bool unrecoverable; // when true, the server says retrying will not help — authoritative. - // Absence means nothing: only Video sets it deliberately (plus the - // shared permission-denied path); most errors never carry it. - final Duration? retryAfter; // from the Retry-After header on HTTP 429; absent on WS rate limits + // Absence means nothing: a permission denial carries it, some Video + // errors do, and most errors never carry it at all. + final Duration? retryAfter; // from the Retry-After header on any error response carrying one; + // absent on WS rate limits bool get isTokenExpired; // code 40 — a fresh token fixes it bool get isTokenNotYetValid; // codes 41, 42 — clock skew (nbf/iat); waiting fixes it, a fresh @@ -121,21 +121,25 @@ switch (result) { case Success(:final data): render(data); case Failure(:final error): - switch (error) { - case StreamApiException(isRateLimited: true): scheduleRetry(); - case StreamApiException(:final code?): showError(copyFor(code)); - case StreamApiException(): showError(genericFailureCopy); // no Stream code: a proxy's bare status - case StreamNetworkException(isCancelled: true): break; // user navigated away - case StreamNetworkException(): showOfflineBanner(); - case StreamAuthenticationException(): redirectToLogin(); - case StreamClientException(): reportToCrashTracker(error); + // Narrowing here is what makes the switch below exhaustive. + if (error case final StreamException failure) { + switch (failure) { + case StreamApiException(isRateLimited: true): scheduleRetry(); + case StreamApiException(:final code?): showError(copyFor(code)); + case StreamApiException(): showError(genericFailureCopy); // no Stream code: a proxy's bare status + case StreamNetworkException(isCancelled: true): break; // user navigated away + case StreamNetworkException(): showOfflineBanner(); + case StreamAuthenticationException(): redirectToLogin(); + case StreamClientException(): reportToCrashTracker(failure); + } } } ``` -The root is `sealed`, so a `switch` that misses a category does not compile. If you don't want to -branch, `error.message` is always displayable and `on StreamException` always catches everything -Stream. +The root is `sealed`, so a `switch` over a `StreamException` that misses a category does not +compile. `Failure.error` is typed `Object`, so narrow to the root first, as above, for the check to +apply. If you don't want to branch, `error.message` is always displayable and `on StreamException` +always catches everything Stream. **Bugs are not in this hierarchy.** Misusing the SDK — calling `send()` before `connect()`, using a disposed client — throws Dart's own `StateError`/`ArgumentError`. @@ -228,11 +232,11 @@ The decision runs in order: 3. **Apply the budget**: max attempts, exponential backoff with jitter, a delay cap. -`DisconnectionSource.isReconnectable` is this procedure specialized for the connection (reconnecting -is inherently idempotent), and the interceptor's one-shot token refresh is the code-40 row. It parts -from the table on one row: it treats every 4xx as no-reconnect, 408 included, because a 408 is an -HTTP verdict on a request that arrived too slowly, and nothing on the connection path produces one. -The 408 row is for operation retry, where the status can actually arrive. +`DisconnectionSource.isReconnectable` is this procedure specialized for the connection, and the +interceptor's one-shot token refresh is the code-40 row. It answers three rows differently: an +expired token reconnects, because the reconnect authenticates again and an operation retry does not; +an SDK failure reconnects; and every 4xx, 408 included, does not. This table is the authority for +operations, the `isReconnectable` dartdoc for the connection. What remains for callers is operation retry: steps 1–2 answer *whether* from the error alone (necessary, but not sufficient, since the error cannot know the operation's idempotency), *when* diff --git a/packages/stream_core/doc/web_socket.md b/packages/stream_core/doc/web_socket.md index 5f4178ee..168805f2 100644 --- a/packages/stream_core/doc/web_socket.md +++ b/packages/stream_core/doc/web_socket.md @@ -76,26 +76,35 @@ Creates a [StreamWebSocketClient] instance for real-time WebSocket communication ```dart StreamWebSocketClient({ - required WebSocketOptions options, + required WebSocketOptionsBuilder optionsBuilder, required WebSocketMessageCodec messageCodec, + WebSocketAuthenticator? onAuthenticate, PingRequestBuilder pingRequestBuilder = _defaultPingRequestBuilder, - void Function()? onConnectionEstablished, Iterable>? eventResolvers, + String tag = 'SC:WsClient', }) ``` -The [options] specify connection configuration including URL, protocols, and query parameters. The [messageCodec] handles encoding outgoing requests and decoding incoming events. When [onConnectionEstablished] is provided, it's called when the connection is ready for authentication. +The [optionsBuilder] is called once per attempt, so the URL and query parameters can change between +them. The [messageCodec] handles encoding outgoing requests and decoding incoming events. When +[onAuthenticate] is provided, it is called once the socket is open and is where credentials go out. ### Authentication -When authentication is required, send authentication messages in the [onConnectionEstablished] callback: +When authentication is required, send the credentials from [onAuthenticate]. It is handed a sender +and the error the server closed the previous attempt with, so a refused token can be replaced rather +than resent: ```dart final client = StreamWebSocketClient( - options: WebSocketOptions(url: 'wss://api.example.com'), + optionsBuilder: () => const WebSocketOptions(url: 'wss://api.example.com'), messageCodec: MyMessageCodec(), - onConnectionEstablished: () { - client.send(AuthRequest(token: authToken)); + onAuthenticate: (send, previousError) async { + // A refused token is replaced rather than resent. + if (previousError?.isTokenExpired ?? false) tokenManager.expireToken(); + + final token = await tokenManager.getToken(); + send(AuthRequest(token: token.rawValue)).getOrThrow(); }, ); ``` @@ -157,22 +166,17 @@ For mobile apps, include network and app lifecycle monitoring: final recoveryHandler = ConnectionRecoveryHandler( client: client, networkStateProvider: NetworkStateProvider(), - appLifecycleStateProvider: AppLifecycleStateProvider(), + lifecycleStateProvider: myLifecycleStateProvider, ); ``` ### Reconnection Rules -Automatic reconnection is **enabled** for: -- Server-initiated disconnections (except authentication/client errors) -- System-initiated disconnections (network changes, etc.) -- Unhealthy connections (missing pong responses) +Whether a closure is worth opening again is decided by `DisconnectionSource.isReconnectable`, which +reads the facts the error carries rather than the close code. Its dartdoc lists every case. -Automatic reconnection is **disabled** for: -- User-initiated disconnections -- Server errors with code 1000 (normal closure) -- Token invalid/expired errors -- Client errors (4xx status codes) +Being reconnectable is necessary but not sufficient: `ConnectionRecoveryHandler` recovers only a +connection that was established, and only while the network and the app lifecycle allow it. ## Event Resolvers @@ -227,12 +231,13 @@ client.connectionState.on((state) { ### Sending Messages -Sends a message through the WebSocket connection. +Sends a message over an established connection. Throws a `StateError` when the client has not been +connected. ```dart final result = client.send(MyRequest(data: 'hello')); if (result.isFailure) { - print('Failed to send message: ${result.error}'); + print('Failed to send message: ${result.exceptionOrNull()}'); } ``` @@ -262,13 +267,13 @@ final messageCodec = JsonMessageCodec(); // 2. Create WebSocket client final client = StreamWebSocketClient( - options: WebSocketOptions( + optionsBuilder: () => WebSocketOptions( url: 'wss://api.example.com/ws', queryParameters: {'token': authToken}, ), messageCodec: messageCodec, - onConnectionEstablished: () { - client.send(AuthRequest(token: authToken)); + onAuthenticate: (send, _) async { + send(AuthRequest(token: authToken)).getOrThrow(); }, ); @@ -276,7 +281,7 @@ final client = StreamWebSocketClient( final recoveryHandler = ConnectionRecoveryHandler( client: client, networkStateProvider: NetworkStateProvider(), - appLifecycleStateProvider: AppLifecycleStateProvider(), + lifecycleStateProvider: myLifecycleStateProvider, ); // 4. Listen to events @@ -300,7 +305,7 @@ await client.connect(); // 7. Send messages final result = client.send(ChatMessage(content: 'Hello, World!')); if (result.isFailure) { - print('Send failed: ${result.error}'); + print('Send failed: ${result.exceptionOrNull()}'); } // 8. Clean up when done From ac6e9e122cc05b1e74c5d52545830bbd8cae5af1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:29:30 +0200 Subject: [PATCH 76/78] docs(changelog): record what this round changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three breaking changes were missing: the extension rename alongside its method, `send`'s failure type and its new `StateError`, and `StreamDioException` no longer defaulting its stack trace. `retryAfter` is read from any error response carrying the header, not only a 429 — a 503 populates it too, and only the delta-seconds form is read. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 800d18f1..17473e38 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -11,7 +11,10 @@ - `WebSocketOptions.connectTimeout` is now a non-nullable `Duration`, 30 seconds by default, and is honoured: a connection that does not come up is given up on instead of waited on indefinitely. A connection that drops later is retried for you; a `connect` that times out is not, so call it again - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the `StreamApiException` the server closed the previous attempt with, and throws to say the credentials did not go out - Reworked the error layer around one sealed root: every failure the SDK reports is a `StreamException` of four kinds — `StreamApiException`, `StreamNetworkException`, `StreamAuthenticationException` or `StreamClientException`. See `ERROR_LAYER.md` for the contract -- Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and `DioException.toClientException()` is now `toStreamException()` +- Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and the `StreamDioExceptionExtension` extension is now `DioExceptionMapping`, with `toClientException()` renamed to `toStreamException()` +- `StreamWebSocketClient.send` now fails with a `StreamException` rather than passing the engine's own error through, so a `Failure` that carried a `StateError` or a codec error now carries a `StreamNetworkException` or `StreamClientException`. The `WsRequestSender` handed to a `WebSocketAuthenticator` changed the same way, which matters where its error is propagated into `AuthenticationFailed` +- `StreamWebSocketClient.send` throws a `StateError` when nothing has connected yet, rather than reporting it through the returned `Result` +- `StreamDioException` no longer defaults its `stackTrace` to `StackTrace.current`, leaving Dio to substitute the stack captured where the request was made - `ServerInitiated.error` is typed `StreamException?` rather than `WebSocketEngineException?` - `TokenManager.getToken` fails with a `StreamAuthenticationException` rather than raw errors; a failed provider's own error is preserved as `cause`. A provider failure that is already a `StreamException`, or a `TimeoutException`, keeps its own kind, so a load that failed at the moment stays retriable - Replaced `StreamApiError.isTokenExpiredError`, `isClientError` and `isRateLimitError`: the conditions live on `StreamErrorCode` and `StreamApiException` as `isTokenExpired`, `isTokenNotYetValid`, `isTokenSignatureInvalid`, `isApiKeyInvalid` and `isRateLimited`; `StreamApiError` keeps only `isRateLimited` @@ -36,11 +39,12 @@ - Added `teams` field to `User` class - Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision - Added `DioException.toStreamException()`, mapping a Dio failure to the `StreamException` it represents -- Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on rate-limited responses +- Added `StreamApiException.retryAfter`, the wait the server asked for, read from the `Retry-After` header on any error response carrying one — a 503 populates it as readily as a 429. Only the delta-seconds form is read - Added `StreamErrorCode`, the API's error-code registry as named constants over `int`, tolerant of codes the SDK does not know yet - Added `runApiSafely`, which runs an API call and reports every failure as a `StreamException` - Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again - Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none +- Added `stackTrace` to `ServerInitiated` and `AuthenticationFailed`, where the failure was raised; `null` for a closure the server reported, which arrives as data rather than as something raised - 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 - Added `InFlightCache`, which hands concurrent callers asking for the same key the one call already in flight, and its outcome, success or failure alike @@ -48,6 +52,7 @@ ### 🐛 Bug Fixes - Fixed `StreamApiError` failing to decode when `details` is not a list of numbers, as a moderation rejection's is; such values read as empty +- Fixed an error payload without a `duration` or `more_info` failing to decode, which lost the `code` with it and silently stopped the token refresh a code would have triggered. Both read as empty now - 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 From 3f556378d8c2d664a02c4eddc3446c0442e2d1c9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:48:12 +0200 Subject: [PATCH 77/78] docs(changelog): say that exceptions stopped carrying a trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list recorded the trace arriving on `ServerInitiated` and `AuthenticationFailed`, but never that it left `StreamException` — which is the half a consumer has to act on. Adopting this in feeds took four edits that the notes gave no warning of. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 17473e38..5b2cada8 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -11,6 +11,7 @@ - `WebSocketOptions.connectTimeout` is now a non-nullable `Duration`, 30 seconds by default, and is honoured: a connection that does not come up is given up on instead of waited on indefinitely. A connection that drops later is retried for you; a `connect` that times out is not, so call it again - Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the `StreamApiException` the server closed the previous attempt with, and throws to say the credentials did not go out - Reworked the error layer around one sealed root: every failure the SDK reports is a `StreamException` of four kinds — `StreamApiException`, `StreamNetworkException`, `StreamAuthenticationException` or `StreamClientException`. See `ERROR_LAYER.md` for the contract +- `StreamException` no longer takes or carries a `stackTrace`. A trace records where a failure was raised rather than what it was, so it travels beside the failure: on `Failure`, or on `ServerInitiated` and `AuthenticationFailed` for a connection that closed. Where the exception is built at the throw site, `throw` captures it - Removed `ClientException`, `HttpClientException` and `WebSocketEngineException`, replaced by the kinds above. `StreamDioException.exception` is a `StreamException`, and the `StreamDioExceptionExtension` extension is now `DioExceptionMapping`, with `toClientException()` renamed to `toStreamException()` - `StreamWebSocketClient.send` now fails with a `StreamException` rather than passing the engine's own error through, so a `Failure` that carried a `StateError` or a codec error now carries a `StreamNetworkException` or `StreamClientException`. The `WsRequestSender` handed to a `WebSocketAuthenticator` changed the same way, which matters where its error is propagated into `AuthenticationFailed` - `StreamWebSocketClient.send` throws a `StateError` when nothing has connected yet, rather than reporting it through the returned `Result` From 5730f8ccebd06a51e69ba2351b341da16f0303e0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 1 Sep 2026 17:49:10 +0200 Subject: [PATCH 78/78] docs: say where a stack trace lives in the error layer The contract said what a `StreamException` is and never where the trace went. Adopting this in feeds meant reading the code to find out, four times. Co-Authored-By: Claude Opus 5 (1M context) --- ERROR_LAYER.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ERROR_LAYER.md b/ERROR_LAYER.md index e3a264a7..8adf0a58 100644 --- a/ERROR_LAYER.md +++ b/ERROR_LAYER.md @@ -188,7 +188,7 @@ close code carries almost no signal: auth, token, and permission rejections all exists on WS) and 1012 (server restart) mean anything. Classify from the drained error event's `code`, never from the close code alone. -The two rules you actually need: +The three rules you actually need: 1. **Misuse throws `Error`, conditions become `StreamException`.** Ask: "can this happen to a correct program at runtime?" No → `StateError`/`ArgumentError`, never wrapped. Yes → the @@ -197,6 +197,12 @@ The two rules you actually need: would do the same thing they'd do for an existing category, it is not a new type — it is a field or a `code`. Context (like "which item of a batch failed") travels in the data channel, beside the outcome, never by wrapping one category inside another. +3. **A trace records the raise, not the failure.** `StreamException` carries `message` and `cause` + and no `stackTrace`. Thrown, the language holds it — `rethrow`, or `Error.throwWithStackTrace` + when re-raising something that failed elsewhere. Returned as data, the carrier holds it: + `Failure(error, stackTrace)`, `ServerInitiated(error, stackTrace)`. Where nothing raised it — a + closure decoded off the wire — it stays `null`; `StackTrace.current` there manufactures a trace + pointing at the decoder. Product SDKs (Chat, Video, Feeds) may extend a category — `StreamChatApiException extends StreamApiException` — but never add a fifth top-level kind and never re-map a core exception into an