From 1aed17bd8f4c62b3b86db789ada840f2e68b0b16 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 31 Aug 2026 16:40:17 +0200 Subject: [PATCH] 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));