diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 0fe5ce0f..04191471 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -528,8 +528,12 @@ explaining the purpose of the item. Subsequent paragraphs elaborate. Avoid multi sentence first paragraphs — the first paragraph gets extracted for tables of contents. -When referencing a parameter, use backticks. When referencing a parameter that also -corresponds to a property, use square brackets instead. +When referencing a parameter, use square brackets, as +[Effective Dart](https://dart.dev/effective-dart/documentation#do-use-square-brackets-in-doc-comments-to-refer-to-in-scope-identifiers) +does — `dart doc` resolves them and links to the declaration. + +Reserve backticks for names that are *not* in scope where the comment sits: a private +constructor argument named from a class-level doc, or a type from another package. Avoid using "above" or "below" to reference other dartdoc sections. Dartdoc pages are often viewed in isolation. diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index d71611b1..a37960f4 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -2,10 +2,18 @@ ### 💥 BREAKING CHANGES +- Raised the minimum Dart SDK to `^3.12.0` - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead - `TokenManager.userId` is now nullable, and is `null` until an identity is configured - `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. A mismatch fails to compile in a const context, and throws in debug mode otherwise +- `StreamWebSocketClient` now takes an `optionsBuilder` instead of `options`, called once per connection attempt +- Renamed `StreamWebSocketClient.onConnectionEstablished` to `onAuthenticate`, now a `WebSocketAuthenticator`. It is handed a `WsRequestSender` and the error the server closed the previous attempt with, and throws to say the credentials did not go out +- Removed `WebSocketEngineException.stopErrorCode`, use `CloseCode.normalClosure` +- `AuthInterceptor` extends `Interceptor` rather than `QueuedInterceptor`, so requests are no longer serialised against one another +- `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix +- `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range +- `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` ### ✨ Features @@ -16,19 +24,32 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does, and `TokenManager.reset`, which drops the configured identity and its cached token - Added `teams` field to `User` class +- Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else +- Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again +- Added `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` +- Added `StreamWebSocketClient.dispose`, which closes the connection along with `events` and `connectionState`; the client is now `Disposable`, and `connect` throws a `StateError` afterwards +- `StreamWebSocketClient` now honours `WebSocketOptions.connectTimeout`, no longer nullable and 30 seconds by default, so an attempt that never becomes usable is abandoned ### 🐛 Bug Fixes - Fixed three faults in `TokenManager`'s token cache: `getToken` contacted the provider on every call instead of returning the cached token, handed out a token that had already expired rather than replacing it, and cached one that finished loading after `expireToken` or `setTokenProvider` had invalidated it. A static provider is left alone, having nothing fresher to give - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested +- Fixed several faults in the token-expired retry: it was skipped when the response carried no JSON content type, never completed at all when the replacement was refused too, re-sent a multipart body whose streams the refused attempt had consumed, and expired a token another request had already replaced +- `StreamWebSocketClient` no longer prints to the console +- Fixed a connection that could be left open, or left disconnecting for good: `connect` leaked the socket of a failed handshake, `disconnect` completed before the socket had closed, and a close that failed or found no socket reported no closure at all +- Fixed reconnection eligibility: the deliberate-close and client-error checks never matched, and a rate limit was treated as permanent when it clears on its own +- Fixed `ConnectionRecoveryHandler` retrying a first connection attempt, which reconnected behind the caller of `connect`; only established connections are recovered now +- Fixed a health check arriving while disconnecting reporting the connection as established again, turning a deliberate disconnect into a reconnect ### 🔄 Changed -- Raised the minimum Dart SDK to `^3.12.0` +- `ConnectUserDetailsRequest` leaves its unset fields out of the JSON it serialises, rather than sending them as nulls - Anonymous requests now always send `user_id=!anon`, rather than whatever id the `TokenManager` was configured with - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user - `TokenManager.getToken` fails when `reset` runs while the token is loading, and rejects a token whose `user_id` is not the user it was loading for; a `setTokenProvider` during a load still serves the caller that started it -- `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token +- `AuthInterceptor` no longer refreshes a token when the manager has no identity, so the original error is surfaced, and no longer retries a request signed for a user it has since been pointed away from, which would have performed one user's request as another +- `StreamWebSocketEngine.open` fails when a connection is already open, rather than closing it to make room ## 0.4.0 diff --git a/packages/stream_core/dart_test.yaml b/packages/stream_core/dart_test.yaml new file mode 100644 index 00000000..bf85d84d --- /dev/null +++ b/packages/stream_core/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + # Tests that drive a fully wired `StreamWebSocketClient` against a fake server. + ws-client: 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 96eb5343..4b1a53a1 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 @@ -3,7 +3,7 @@ import 'package:dio/dio.dart'; import '../stream_core_dio_error.dart'; class ApiErrorInterceptor extends Interceptor { - /// Initializes a new instance of [ApiErrorInterceptor]. + /// Creates a new [ApiErrorInterceptor]. const ApiErrorInterceptor(); @override diff --git a/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart index 06975de7..a9113e3f 100644 --- a/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; class ApiKeyInterceptor extends Interceptor { - /// Initialize a new API key interceptor + /// Creates a new [ApiKeyInterceptor]. const ApiKeyInterceptor(this.apiKey); /// The API key to be added to the request headers 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 ba19df0f..4e5697fb 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -4,17 +4,21 @@ import '../../errors.dart'; import '../../user.dart'; import '../stream_core_dio_error.dart'; -/// Authentication interceptor that refreshes the token if -/// an auth error is received -class AuthInterceptor extends QueuedInterceptor { - /// Initialize a new auth interceptor +/// Interceptor that signs every request with the caller's token. +/// +/// A request the server refuses for an expired token is retried once, carrying a replacement. +class AuthInterceptor extends Interceptor { + /// Creates a new [AuthInterceptor]. AuthInterceptor(this._dio, this._tokenManager); final Dio _dio; - - /// The token manager used in the client final TokenManager _tokenManager; + // Not a `QueuedInterceptor`: it frees a slot only once a handler completes, so the retry sent from + // `onError` would wait behind the request holding it. `TokenManager` serialises the token loads. + + static const _retriedKey = 'stream_core.auth_token_retried'; + @override Future onRequest( RequestOptions options, @@ -38,7 +42,7 @@ class AuthInterceptor extends QueuedInterceptor { final dioError = StreamDioException( exception: error, requestOptions: options, - stackTrace: StackTrace.current, + stackTrace: stackTrace, ); return handler.reject(dioError, true); @@ -50,30 +54,36 @@ class AuthInterceptor extends QueuedInterceptor { DioException err, ErrorInterceptorHandler handler, ) async { - final data = err.response?.data; - if (data == null || data is! Map) { - return handler.next(err); - } + final error = err.apiError; + if (error == null || !error.isTokenExpiredError) return handler.next(err); - final error = StreamApiError.fromJson(data); - if (error.isTokenExpiredError) { - // Don't try to refresh the token when there is no user to load one for, - // or when the provider would return the same token again. - final canRefresh = _tokenManager.userId != null && !_tokenManager.usesStaticProvider; - if (!canRefresh) return handler.next(err); - // Otherwise, mark the current token as expired. - _tokenManager.expireToken(); + final options = err.requestOptions; - try { - final options = err.requestOptions; - // ignore: inference_failure_on_function_invocation - final response = await _dio.fetch(options); - return handler.resolve(response); - } on DioException catch (exception) { - return handler.next(exception); - } + // A retry after a user switch would perform this request as the new user. + final signedFor = options.queryParameters['user_id']; + final canRefresh = signedFor == _tokenManager.userId && !_tokenManager.usesStaticProvider; + if (!canRefresh) return handler.next(err); + + if (options.extra[_retriedKey] == true) return handler.next(err); + + // Another request may have replaced it already, and expiring that would discard a valid token. + if (options.headers['Authorization'] == _tokenManager.peekToken()?.rawValue) { + _tokenManager.expireToken(); } - return handler.next(err); + // The multipart body is cloned because the refused attempt already consumed its streams. + final data = options.data; + final retry = options.copyWith( + extra: {...options.extra, _retriedKey: true}, + data: data is FormData ? data.clone() : data, + ); + + try { + // ignore: inference_failure_on_function_invocation + final response = await _dio.fetch(retry); + return handler.resolve(response); + } on DioException catch (exception) { + return handler.reject(exception); + } } } diff --git a/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart index fcb436a9..b2f72535 100644 --- a/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart @@ -2,9 +2,9 @@ import 'package:dio/dio.dart'; typedef ConnectionIdGetter = String? Function(); -/// Interceptor that injects the connection id in the request params +/// Interceptor that injects the connection id into the request params. class ConnectionIdInterceptor extends Interceptor { - /// Initialize a new [ConnectionIdInterceptor]. + /// Creates a new [ConnectionIdInterceptor]. const ConnectionIdInterceptor(this._connectionId); /// The getter for the connection id. diff --git a/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart index f2b94872..7e951f7b 100644 --- a/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart @@ -4,7 +4,7 @@ import '../system_environment_manager.dart'; /// Interceptor that sets additional headers for all requests. class HeadersInterceptor extends Interceptor { - /// Initialize a new [HeadersInterceptor]. + /// Creates a new [HeadersInterceptor]. const HeadersInterceptor(this._systemEnvironmentManager); final SystemEnvironmentManager _systemEnvironmentManager; diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index f74b7844..46d14e98 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -23,7 +23,7 @@ void _defaultLogPrint(InterceptStep step, Object object) => print(object); /// Interceptor dedicated to logging class LoggingInterceptor extends Interceptor { - /// Initialize a new logging interceptor + /// Creates a new [LoggingInterceptor]. LoggingInterceptor({ this.request = true, this.requestHeader = false, 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 1767ea83..018fc8db 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 @@ -2,9 +2,9 @@ import 'dart:convert'; import '../../stream_core.dart'; -/// Error class specific to StreamChat and Dio +/// A [DioException] carrying the Stream [ClientException] that caused it. class StreamDioException extends DioException { - /// Initialize a stream chat dio error + /// Creates a [StreamDioException] for [exception]. StreamDioException({ required this.exception, required super.requestOptions, @@ -21,16 +21,25 @@ class StreamDioException extends DioException { } extension StreamDioExceptionExtension on DioException { - HttpClientException toClientException() { - final apiErrorResult = runSafelySync( - () => switch (response?.data) { - final Map data => StreamApiError.fromJson(data), - final String data => StreamApiError.fromJson(jsonDecode(data) as Map), - _ => null, - }, - ); + /// The Stream API error this exception's response carried, or `null` when it carried something else. + /// + /// A Stream error is recognised whether the server sent it as JSON or as plain text. A body that is + /// not one — a proxy or gateway answering with an error of its own — reads as `null` rather than + /// throwing. + StreamApiError? get apiError => runSafelySync(() { + final data = response?.data; + final json = data is String ? jsonDecode(data) : data; + if (json is! Map) return null; - final apiError = apiErrorResult.getOrNull(); + return StreamApiError.fromJson(json); + }).getOrNull(); + + /// This exception as an [HttpClientException]. + /// + /// Takes its message, status code and cause from [apiError] when the response carried one, and + /// from what the transport reported otherwise. A request the caller cancelled is marked as such. + HttpClientException toClientException() { + final apiError = this.apiError; return HttpClientException( message: apiError?.message ?? response?.statusMessage ?? message ?? '', 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 19c2fe05..0c8c2be0 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -69,16 +69,32 @@ class StreamApiError extends Equatable { ]; } -final _tokenInvalidErrorCodes = _range(40, 42); -final _clientErrorCodes = _range(400, 499); +// The token this was issued for has expired; another one is accepted. +const _expiredTokenCode = 40; + +// The token cannot be accepted for a reason another token does not fix: not +// valid yet, used before it was issued, or signed with the wrong secret. +final _invalidTokenCodes = _range(41, 43); + +// The API key itself is wrong, which no token repairs either. +const _accessKeyErrorCode = 2; + +final _clientErrorStatusCodes = _range(400, 499); /// Extension methods for [StreamApiError] to provide convenient error type checks. extension StreamApiErrorExtension on StreamApiError { - /// Whether this error indicates an expired or invalid token. - bool get isTokenExpiredError => _tokenInvalidErrorCodes.contains(code); + /// Whether the token has expired (error code 40). + /// + /// Distinct from [isInvalidTokenError]: an expired token is fixed by loading another one, whereas + /// an invalid token is a configuration problem that a fresh token reproduces. + bool get isTokenExpiredError => code == _expiredTokenCode; + + /// Whether the token, or the API key it was signed with, cannot be accepted + /// (error codes 41 to 43, and 2). + bool get isInvalidTokenError => _invalidTokenCodes.contains(code) || code == _accessKeyErrorCode; /// Whether this error is a client-side error (4xx status codes). - bool get isClientError => _clientErrorCodes.contains(code); + bool get isClientError => _clientErrorStatusCodes.contains(statusCode); /// Whether this error indicates rate limiting (429 status code). bool get isRateLimitError => statusCode == 429; diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.dart b/packages/stream_core/lib/src/user/connect_user_details_request.dart index 553ba9d2..6bfea720 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.dart @@ -1,8 +1,11 @@ import 'package:json_annotation/json_annotation.dart'; +import '../utils/standard.dart'; +import 'user.dart'; + part 'connect_user_details_request.g.dart'; -@JsonSerializable(createFactory: false) +@JsonSerializable(createFactory: false, includeIfNull: false) class ConnectUserDetailsRequest { const ConnectUserDetailsRequest({ required this.id, @@ -13,6 +16,27 @@ class ConnectUserDetailsRequest { this.custom, }); + /// Creates the details to send when connecting as [user]. + /// + /// Pass [includeDetails] as `false` to send the id alone. + /// + /// The name comes from [User.originalName] rather than [User.name], so a user who was never given + /// one does not have their id sent as their name. [User.role] and [User.teams] are left out + /// deliberately: the server assigns both and ignores whatever a client claims for them. + factory ConnectUserDetailsRequest.fromUser( + User user, { + bool includeDetails = true, + }) { + final details = user.takeIf((_) => includeDetails); + + return ConnectUserDetailsRequest( + id: user.id, + name: details?.originalName, + image: details?.image, + custom: details?.custom, + ); + } + final String id; final String? image; final bool? invisible; diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.g.dart b/packages/stream_core/lib/src/user/connect_user_details_request.g.dart index 1d97036f..7ec0d0af 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.g.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.g.dart @@ -10,9 +10,9 @@ Map _$ConnectUserDetailsRequestToJson( ConnectUserDetailsRequest instance, ) => { 'id': instance.id, - 'image': instance.image, - 'invisible': instance.invisible, - 'language': instance.language, - 'name': instance.name, - 'custom': instance.custom, + 'image': ?instance.image, + 'invisible': ?instance.invisible, + 'language': ?instance.language, + 'name': ?instance.name, + 'custom': ?instance.custom, }; diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart index 07c66ca7..f1d000e0 100644 --- a/packages/stream_core/lib/src/utils/result.dart +++ b/packages/stream_core/lib/src/utils/result.dart @@ -58,8 +58,8 @@ extension PatternMatching on Result { /// Returns the encapsulated value if this instance represents [Success] or `null` /// if it is [Failure]. /// - /// This function is a shorthand for `getOrElse(() => null)` or - /// `fold(onSuccess: (it) => it, onFailure: (_) => null)`. + /// This function is a shorthand for + /// `fold(onSuccess: (it) => it, onFailure: (_, _) => null)`. T? getOrNull() { return switch (this) { Success(:final data) => data, @@ -90,7 +90,7 @@ extension PatternMatching on Result { /// Returns the encapsulated value if this instance represents [Success] or throws the encapsulated error /// if it is [Failure]. /// - /// This function is a shorthand for `getOrElse((error) => throw error)`. + /// This function is a shorthand for `getOrElse((error, _) => throw error)`. T getOrThrow() { return switch (this) { Success(:final data) => data, @@ -107,9 +107,13 @@ extension PatternMatching on Result { /// Note, that this function rethrows any error thrown by [onFailure] function. /// /// This function is a shorthand for `fold(onSuccess: (it) => it, onFailure: onFailure)`. - R getOrElse(R Function(Object error, StackTrace? stackTrace) onFailure) { + /// + /// [onFailure] returns this result's own type. To fall back to a supertype, widen the result first + /// (`Result widened = intResult`), or use [fold], which takes its return type from both + /// branches. + T getOrElse(T Function(Object error, StackTrace? stackTrace) onFailure) { return switch (this) { - Success(:final data) => data as R, + Success(:final data) => data, Failure(:final error, :final stackTrace) => onFailure(error, stackTrace), }; } @@ -117,10 +121,13 @@ extension PatternMatching on Result { /// Returns the encapsulated value if this instance represents [Success] or the /// [defaultValue] if it is [Failure]. /// - /// This function is a shorthand for `getOrElse((_) => defaultValue)`. - R getOrDefault(R defaultValue) { + /// This function is a shorthand for `getOrElse((_, _) => defaultValue)`. + /// + /// [defaultValue] is of this result's own type; widen the result to fall back + /// to a supertype. + T getOrDefault(T defaultValue) { return switch (this) { - Success(:final data) => data as R, + Success(:final data) => data, Failure() => defaultValue, }; } @@ -180,11 +187,13 @@ extension PatternMatching on Result { /// /// Note, that this function rethrows any error thrown by [transform] function. /// See [recoverCatching] for an alternative that encapsulates errors. - Result recover( - R Function(Object error, StackTrace? stackTrace) transform, + /// + /// [transform] returns this result's own type. To recover to a supertype, widen the result first. + Result recover( + T Function(Object error, StackTrace? stackTrace) transform, ) { return switch (this) { - Success(:final data) => Result.success(data as R), + Success() => this, Failure(:final error, :final stackTrace) => Result.success( transform(error, stackTrace), ), @@ -196,11 +205,13 @@ extension PatternMatching on Result { /// /// This function catches any error thrown by [transform] function and encapsulates it as a failure. /// See [recover] for an alternative that rethrows errors. - Result recoverCatching( - R Function(Object error, StackTrace? stackTrace) transform, + /// + /// [transform] returns this result's own type. To recover to a supertype, widen the result first. + Result recoverCatching( + T Function(Object error, StackTrace? stackTrace) transform, ) { return switch (this) { - Success(:final data) => Result.success(data as R), + Success() => this, Failure(:final error, :final stackTrace) => runSafelySync( () => transform(error, stackTrace), ), diff --git a/packages/stream_core/lib/src/ws.dart b/packages/stream_core/lib/src/ws.dart index bae54ddf..ceecd379 100644 --- a/packages/stream_core/lib/src/ws.dart +++ b/packages/stream_core/lib/src/ws.dart @@ -5,6 +5,7 @@ export 'ws/client/reconnect/automatic_reconnection_policy.dart'; export 'ws/client/reconnect/connection_recovery_handler.dart'; export 'ws/client/reconnect/retry_strategy.dart'; export 'ws/client/stream_web_socket_client.dart'; +export 'ws/client/web_socket_authentication_handler.dart' show WebSocketAuthenticator, WsRequestSender; export 'ws/client/web_socket_connection_state.dart'; export 'ws/client/web_socket_health_monitor.dart'; export 'ws/events/ws_event.dart'; 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 f702ff6f..a908bdc8 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 @@ -48,24 +48,29 @@ class StreamWebSocketEngine implements WebSocketEngine { WebSocketEngineListener? _listener; WebSocketChannel? _ws; + // ignore: cancel_subscriptions StreamSubscription? _wsSubscription; @override Future> open(WebSocketOptions options) { return runSafely(() async { - // Close any existing connection first. - if (_ws != null) await close(); + if (_ws != null) { + throw StateError('WebSocket is already open. Call close() first.'); + } // Create a new WebSocket connection. - _ws = _wsProvider.call(options); - _wsSubscription = _ws?.stream.listen( + final ws = _ws = _wsProvider.call(options); + _wsSubscription = ws.stream.listen( _onData, onDone: _onDone, cancelOnError: false, onError: _listener?.onError, ); - return _ws?.ready.then((_) => _listener?.onOpen()); + 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(); }); } @@ -98,16 +103,17 @@ class StreamWebSocketEngine implements WebSocketEngine { String? closeReason = 'Closed by client', ]) { return runSafely(() async { - if (_ws == null) return; + final ws = _ws; + final subscription = _wsSubscription; - await _ws?.sink.close(closeCode, closeReason); _ws = null; - - await _wsSubscription?.cancel(); _wsSubscription = null; - // Notify the listener about the closure. - _listener?.onClose(closeCode, closeReason); + 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); }); } 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 7abf7513..aa0742b8 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 @@ -19,6 +19,8 @@ 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. Future> open(WebSocketOptions options); @@ -26,6 +28,9 @@ abstract interface class WebSocketEngine { /// /// Closes the active WebSocket connection with the specified [closeCode] and [closeReason]. /// + /// Notifies the listener that the connection closed before completing, including when there was no + /// connection to close. A close that fails notifies nothing and reports the failure instead. + /// /// Returns a [Result] indicating success or failure of the close operation. Future> close([int? closeCode, String? closeReason]); @@ -195,8 +200,6 @@ class WebSocketEngineException extends Equatable implements Exception { return null; } - static const stopErrorCode = 1000; - @override List get props => [reason, code, error]; } diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart index 0d2f86c0..6b087dc8 100644 --- a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart @@ -22,7 +22,7 @@ class WebSocketOptions { /// Creates a new instance of [WebSocketOptions]. const WebSocketOptions({ required this.url, - this.connectTimeout, + this.connectTimeout = defaultConnectTimeout, this.protocols, this.queryParameters, }); @@ -35,9 +35,14 @@ class WebSocketOptions { /// Maximum time allowed for establishing the WebSocket connection. /// - /// When specified, the connection attempt will timeout if not completed - /// within this duration. If `null`, uses the platform default timeout. - final Duration? connectTimeout; + /// Covers the whole attempt, not just opening the socket: a connection that opens but is never + /// established is abandoned once this elapses. + /// + /// Defaults to [defaultConnectTimeout]. + final Duration connectTimeout; + + /// The [connectTimeout] used when none is given, thirty seconds. + static const defaultConnectTimeout = Duration(seconds: 30); /// WebSocket sub-protocols to negotiate during the handshake. /// 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 55c4a0af..714fa916 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 @@ -18,6 +18,11 @@ import 'retry_strategy.dart'; /// when reconnection should occur, implementing exponential backoff with jitter for optimal /// retry behavior. /// +/// Only connections that were established are recovered. A first attempt that fails is reported +/// through [StreamWebSocketClient.connectionState] and left there, so it is not retried here, not +/// even when the network returns; making another belongs to whoever called +/// [StreamWebSocketClient.connect]. +/// /// ## Built-in Policies /// /// The handler automatically includes several reconnection policies: @@ -79,6 +84,10 @@ class ConnectionRecoveryHandler extends Disposable { late final _subscriptions = CompositeSubscription(); + // True once a connection has been established, and false again if one closes for a reason this + // handler will not act on. Tells a drop worth recovering apart from an attempt that never landed. + var _hasEstablishedConnection = false; + /// Attempts reconnection if policies allow it. /// /// Evaluates all configured policies and initiates reconnection when conditions are met. @@ -117,7 +126,10 @@ class ConnectionRecoveryHandler extends Disposable { _reconnectionTimer = null; } - bool _canBeReconnected() => _policies.every((it) => it.canBeReconnected()); + bool _canBeReconnected() { + if (!_hasEstablishedConnection) return false; + return _policies.every((it) => it.canBeReconnected()); + } bool _canBeDisconnected() { return switch (_client.connectionState.value) { @@ -147,13 +159,29 @@ class ConnectionRecoveryHandler extends Disposable { void _onConnectionStateChanged(WebSocketConnectionState state) { return switch (state) { Connecting() => _cancelReconnection(), - Connected() => _reconnectStrategy.resetConsecutiveFailures(), - Disconnected() => _scheduleReconnectionIfNeeded(), + Connected() => _onConnectionEstablished(), + Disconnected(:final source) => _onConnectionLost(source), // These states do not require any action. Initialized() || Authenticating() || Disconnecting() => () {}, }; } + void _onConnectionEstablished() { + _hasEstablishedConnection = true; + return _reconnectStrategy.resetConsecutiveFailures(); + } + + // Only the source matters here. The network and lifecycle are checked later, when a reconnect is + // actually attempted, so a drop during an outage still counts as one worth recovering. + void _onConnectionLost(DisconnectionSource source) { + if (!source.isReconnectable) { + _hasEstablishedConnection = false; + return _cancelReconnection(); + } + + return _scheduleReconnectionIfNeeded(); + } + @override Future dispose() async { _cancelReconnection(); 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 3e6c4ec4..e9997f4a 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 @@ -5,6 +5,7 @@ import '../events/ws_event.dart'; import '../events/ws_request.dart'; import 'engine/stream_web_socket_engine.dart'; import 'engine/web_socket_engine.dart'; +import 'web_socket_authentication_handler.dart'; import 'web_socket_connection_state.dart'; import 'web_socket_health_monitor.dart'; @@ -18,6 +19,11 @@ WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { return HealthCheckPingEvent(connectionId: info?.connectionId); } +/// A function that builds the options for a connection attempt. +/// +/// Called once per attempt, so the options can change between attempts. +typedef WebSocketOptionsBuilder = WebSocketOptions Function(); + /// A WebSocket client with connection management and event handling. /// /// The primary interface for WebSocket connections in the Stream Core SDK that provides @@ -31,21 +37,23 @@ WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { /// ## Example /// ```dart /// final client = StreamWebSocketClient( -/// options: WebSocketOptions(url: 'wss://api.example.com'), -/// messageCodec: JsonMessageCodec(), -/// onConnectionEstablished: () { -/// client.send(AuthRequest(token: authToken)); +/// optionsBuilder: () => const WebSocketOptions(url: 'wss://api.example.com'), +/// // A WebSocketMessageCodec for the event and request types this SDK puts on the wire. +/// messageCodec: const AppWsCodec(), +/// onAuthenticate: (send, _) async { +/// final token = await tokenManager.getToken(); +/// send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); /// }, /// ); /// /// await client.connect(); /// ``` -class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineListener { +class StreamWebSocketClient with Disposable implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. StreamWebSocketClient({ - required this.options, - this.onConnectionEstablished, + required this.optionsBuilder, WebSocketProvider? wsProvider, + WebSocketAuthenticator? onAuthenticate, this.pingRequestBuilder = _defaultPingRequestBuilder, required WebSocketMessageCodec messageCodec, Iterable>? eventResolvers, @@ -56,20 +64,42 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL wsProvider: wsProvider, messageCodec: messageCodec, ); + + _authenticationHandler = WebSocketAuthenticationHandler( + send: send, + authenticator: onAuthenticate, + onFailure: (error) => disconnect( + source: .authenticationFailed(error: error), + ), + ); } - /// The WebSocket connection options including URL and configuration. - final WebSocketOptions options; + /// The function used to build the connection options for each attempt. + final WebSocketOptionsBuilder optionsBuilder; /// The function used to build ping requests for health checks. final PingRequestBuilder pingRequestBuilder; - /// Called when the WebSocket connection is established and ready for authentication. - final void Function()? onConnectionEstablished; - late final StreamWebSocketEngine _engine; + late final WebSocketAuthenticationHandler _authenticationHandler; late final _healthMonitor = WebSocketHealthMonitor(listener: this); + // Bounds an attempt while `Connecting` or `Authenticating`; the health monitor takes over after. + Timer? _connectTimeoutTimer; + + void _startConnectTimeout(Duration timeout) { + _connectTimeoutTimer?.cancel(); + _connectTimeoutTimer = Timer(timeout, () { + const source = DisconnectionSource.connectTimeout(); + unawaited(disconnect(source: source)); + }); + } + + void _cancelConnectTimeout() { + _connectTimeoutTimer?.cancel(); + _connectTimeoutTimer = null; + } + /// The event emitter for WebSocket events. /// /// Use this to listen to incoming WebSocket events with type-safe event handling. @@ -80,17 +110,17 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL /// /// Emits state changes as the WebSocket transitions through different connection states. ConnectionStateEmitter get connectionState => _connectionStateEmitter; - late final _connectionStateEmitter = MutableConnectionStateEmitter( - const WebSocketConnectionState.initialized(), - ); + late final _connectionStateEmitter = MutableConnectionStateEmitter(const .initialized()); set _connectionState(WebSocketConnectionState connectionState) { + if (_connectionStateEmitter.isClosed) return; + // Return early if the state hasn't changed. if (_connectionStateEmitter.value == connectionState) return; - print('WebSocketClient: Connection state changed to $connectionState'); _connectionStateEmitter.value = connectionState; _healthMonitor.onConnectionStateChanged(connectionState); + _authenticationHandler.onConnectionStateChanged(connectionState); } /// Sends a message through the WebSocket connection. @@ -102,25 +132,43 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL /// Establishes a WebSocket connection. /// - /// The connection state can be monitored through [connectionState] for real-time updates. - /// If the connection is already established or in progress, this method returns immediately. + /// If the connection is already established or in progress, this method returns immediately, + /// as it does while a previous connection is still closing. /// - /// Returns a [Future] that completes when the connection attempt finishes. + /// Returns a [Future] that completes once the socket is open, before the connection is + /// authenticated and well before it is [Connected]. Watch [connectionState] to know when it + /// is usable, and to see an attempt that failed: a failure is reported there rather than thrown, + /// so an attempt that never lands leaves the state [Disconnected] and nothing else. + /// + /// Throws a [StateError] once [dispose] has been called. Future connect() async { + // A socket opened here would be invisible: a disposed client reports no state and closes nothing. + if (isDisposed) throw StateError('Cannot connect a disposed StreamWebSocketClient'); + // If the connection is already established or in the process of connecting, // do not initiate a new connection. if (connectionState.value is Connecting) return; if (connectionState.value is Authenticating) return; if (connectionState.value is Connected) return; + if (connectionState.value is Disconnecting) return; // Update the connection state to 'connecting'. _connectionState = const WebSocketConnectionState.connecting(); - // Open the connection using the engine. + // Open the connection using the engine, with options built for this attempt. + final options = optionsBuilder.call(); + + // Bound the attempt, so one that never becomes usable is not waited on forever. + _startConnectTimeout(options.connectTimeout); final result = await _engine.open(options); - // If some failure occurs, disconnect and rethrow the error. - return result.recover((_, _) => onClose()).getOrThrow(); + // 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)), + ), + ); } /// Closes the WebSocket connection. @@ -128,19 +176,33 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL /// When [closeCode] is provided, uses the specified close code for the disconnection. /// The [source] indicates the reason for disconnection and affects reconnection behavior. /// + /// A [UserInitiated] or [AuthenticationFailed] disconnection takes effect even on a connection + /// that is already down or on its way down, which is what calls off a reconnection waiting to be + /// made. Every other source leaves a closure already recorded as it is. + /// /// Returns a [Future] that completes when the disconnection finishes. Future disconnect({ CloseCode closeCode = CloseCode.normalClosure, DisconnectionSource source = const UserInitiated(), }) async { - // If the connection is already disconnected, do nothing. - if (connectionState.value is Disconnected) return; + _cancelConnectTimeout(); + + if (connectionState.value case Initialized()) return; + + // A source that blocks reconnection overwrites one already recorded, or a pending reconnect + // fires past it. + final forceDisconnect = source is UserInitiated || source is AuthenticationFailed; + if (connectionState.value case Disconnecting() when !forceDisconnect) return; + if (connectionState.value case Disconnected() when !forceDisconnect) return; // Update the connection state to 'disconnecting'. _connectionState = WebSocketConnectionState.disconnecting(source: source); // Close the connection using the engine. - unawaited(_engine.close(closeCode, source.closeReason)); + final result = await _engine.close(closeCode, source.closeReason); + + // The engine announces nothing when a close fails, which would leave this stuck disconnecting. + return result.getOrElse((_, _) => onClose(closeCode, source.closeReason)); } @override @@ -148,9 +210,8 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Update the connection state to 'authenticating'. _connectionState = const WebSocketConnectionState.authenticating(); - // Notify that the connection has been established and we are ready - // to authenticate. - onConnectionEstablished?.call(); + // The socket is open but not yet usable: the credentials go out before the server will serve it. + unawaited(_authenticationHandler.authenticate()); } @override @@ -161,17 +222,15 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Any active state that wasn’t user/system initiated becomes server initiated. Connecting() || Authenticating() || Connected() => ServerInitiated( - error: WebSocketEngineException( - code: closeCode, - reason: closeReason, - ), + error: WebSocketEngineException(code: closeCode, reason: closeReason), ), - // Not meaningful to transition from these; just log and bail. + // Not meaningful to transition from these. Initialized() || Disconnected() => null, }; if (source == null) return; + _cancelConnectTimeout(); // Update the connection state to 'disconnected' with the source. _connectionState = WebSocketConnectionState.disconnected(source: source); @@ -185,8 +244,7 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Update the connection state to 'disconnecting' with the source. // - // Note: We don't have to use `Disconnected` state here because the socket - // automatically closes the connection after sending the error. + // The socket closes itself after an error, so the closure that follows records the disconnection. _connectionState = WebSocketConnectionState.disconnecting(source: source); } @@ -215,7 +273,14 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL } void _handleHealthCheckEvent(WsEvent event, HealthCheckInfo info) { - print('WebSocketClient: Health check pong received: $info'); + // A pong counts only once credentials have gone out. Earlier it would report a connection + // established before it was authenticated; later it would overwrite why the connection closed. + if (connectionState.value case Connecting()) return; + if (connectionState.value case Disconnecting()) return; + if (connectionState.value case Disconnected()) return; + + // The connection is established, so the attempt is no longer being timed. + _cancelConnectTimeout(); // Update the connection state with health check info. _connectionState = WebSocketConnectionState.connected(healthCheck: info); @@ -225,8 +290,7 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Emit the health check event. // - // Note: We send the event even after handling it to allow - // listeners to react to it if needed. + // Emitted as well as handled, so a listener can react to it too. _events.emit(event); } @@ -238,7 +302,6 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL // Send the ping request. send(pingRequest); - print('WebSocketClient: Ping request sent: $pingRequest'); } } @@ -248,4 +311,23 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineL const source = DisconnectionSource.unHealthyConnection(); return unawaited(disconnect(source: source)); } + + /// Releases every resource held by this client. + /// + /// Closes the connection along with [events] and [connectionState], + /// after which this client cannot be connected again. + /// For a connection that may be opened again, consider [disconnect]. + /// + /// Returns a [Future] that completes once everything is closed. + @override + Future dispose() async { + await disconnect(); + + _healthMonitor.stop(); + + await _events.close(); + await _connectionStateEmitter.close(); + + return super.dispose(); + } } 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 new file mode 100644 index 00000000..499ed3e1 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -0,0 +1,106 @@ +import '../../errors.dart' show StreamApiError; +import '../../utils.dart'; +import '../events/ws_request.dart'; +import 'web_socket_connection_state.dart'; + +/// A function that sends a request over a connection that is not usable yet. +/// +/// Fails once the connection attempt it was handed to is no longer the one in flight, so +/// credentials loaded for an abandoned attempt are not sent over the connection that replaced it. +typedef WsRequestSender = Result Function(WsRequest request); + +/// A function that authenticates a newly opened connection. +/// +/// Called while the state is [Authenticating], to send the credentials the connection requires. +/// +/// `previousError` is the error the server closed the previous attempt with, and null when there was +/// none, once a connection has been established, or once the caller has disconnected. Only the next +/// 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. +typedef WebSocketAuthenticator = Future Function(WsRequestSender send, StreamApiError? previousError); + +/// A handler that authenticates newly opened connections and remembers why the server refused the +/// last one. +/// +/// Driven by a WebSocket client, which feeds it connection state changes. +class WebSocketAuthenticationHandler { + /// Creates a [WebSocketAuthenticationHandler]. + /// + /// `authenticator` may be null, for a connection that needs nothing sent. + WebSocketAuthenticationHandler({ + required this._authenticator, + required this._send, + required this._onFailure, + }); + + final WebSocketAuthenticator? _authenticator; + final WsRequestSender _send; + final void Function(Object error) _onFailure; + + // Identifies the attempt in flight: an authenticator can outlive the one that started it. + var _attempt = 0; + + /// The error the server closed the previous attempt with, if it sent one. + /// + /// 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; + + /// Takes in a connection state change. + /// + /// A [Connecting] state begins an attempt, after which an authenticator still running for an + /// earlier one can neither send nor report a failure. Every other state only updates + /// [previousError], which is left alone unless the server refused or the caller took over. + void onConnectionStateChanged(WebSocketConnectionState state) { + if (state case Connecting()) _attempt++; + + _previousError = switch (state) { + 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, + _ => _previousError, + }; + } + + /// Authenticates a socket that has just opened. + /// + /// Does nothing when there is no authenticator. Passes [previousError] to the authenticator, and + /// spends it once this attempt finishes, so a later attempt does not see a refusal that was not + /// about it. + /// + /// An error the authenticator throws is passed to `onFailure` instead of escaping, unless the + /// attempt has since been abandoned, which leaves nothing to report it against. + Future authenticate() async { + final authenticate = _authenticator; + if (authenticate == null) return; + + final attempt = _attempt; + final previousError = _previousError; + + // Guarded because nothing awaits this: an error thrown here would go unhandled. + final result = await runSafely(() => authenticate(_senderFor(attempt), previousError)); + + // Stale: its failure would close the connection that replaced it, and never be reconnected. + if (attempt != _attempt) return; + + // Spent, unless the server refused something newer while the authenticator ran. By identity, + // not equality: a newer refusal of the same kind compares equal to this one. + if (identical(_previousError, previousError)) _previousError = null; + + if (result case Failure(:final error)) return _onFailure(error); + } + + // The sender is held across the authenticator's own awaits, so the attempt is checked on each send. + 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); + }; +} 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 87a6cac2..11750df0 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 @@ -88,37 +88,14 @@ sealed class WebSocketConnectionState extends Equatable { /// Whether automatic reconnection is enabled for this connection state. /// - /// Determines if the connection should automatically attempt to reconnect based on - /// the current state and disconnection source. Only applies to [Disconnected] states. + /// `false` for every state but [Disconnected], where it is the source's + /// [DisconnectionSource.isReconnectable] and nothing more. /// - /// ## Reconnection is enabled for: - /// - Server-initiated disconnections (except authentication and client errors) - /// - System-initiated disconnections (network changes, app lifecycle, etc.) - /// - Unhealthy connection disconnections (missing pong responses) - /// - /// ## Reconnection is disabled for: - /// - User-initiated disconnections (explicit disconnect calls) - /// - Server errors with code 1000 (normal closure) - /// - Token expired/invalid errors - /// - Client errors (4xx status codes) - /// - /// Returns `true` if automatic reconnection should be attempted. - bool get isAutomaticReconnectionEnabled { - return switch (this) { - Disconnected(:final source) => switch (source) { - ServerInitiated() => switch (source.error?.apiError) { - final error? when error.code == 1000 => false, - final error? when error.isTokenExpiredError => false, - final error? when error.isClientError => false, - _ => true, // Reconnect on other server initiated disconnections - }, - UnHealthyConnection() => true, - SystemInitiated() => true, - UserInitiated() => false, - }, - _ => false, // No automatic reconnection for other states - }; - } + /// {@macro webSocketReconnectionRules} + bool get isAutomaticReconnectionEnabled => switch (this) { + Disconnected(:final source) => source.isReconnectable, + _ => false, // No automatic reconnection for other states + }; @override List get props => []; @@ -223,6 +200,8 @@ final class Disconnected extends WebSocketConnectionState { /// - [ServerInitiated]: Server closed the connection, possibly with an error /// - [SystemInitiated]: System-level disconnection (network, app lifecycle) /// - [UnHealthyConnection]: Connection closed due to failed health checks +/// - [ConnectTimeout]: Attempt abandoned before the connection was established +/// - [AuthenticationFailed]: Socket opened, but its credentials never went out sealed class DisconnectionSource extends Equatable { const DisconnectionSource(); @@ -252,6 +231,18 @@ sealed class DisconnectionSource extends Equatable { /// typically when ping requests do not receive pong responses. const factory DisconnectionSource.unHealthyConnection() = UnHealthyConnection; + /// Creates a [ConnectTimeout] disconnection source. + /// + /// Indicates that the connection never became usable within the allotted + /// time, so it was abandoned before it was ever established. + const factory DisconnectionSource.connectTimeout() = ConnectTimeout; + + /// Creates an [AuthenticationFailed] disconnection source. + /// + /// 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; + /// A human-readable description of the disconnection source. /// /// Provides a descriptive string that explains why the connection was closed. @@ -264,9 +255,52 @@ sealed class DisconnectionSource extends Equatable { ServerInitiated() => 'Server initiated disconnection', SystemInitiated() => 'System initiated disconnection', UnHealthyConnection() => 'Unhealthy connection (no pong received)', + ConnectTimeout() => 'Timed out before the connection was established', + AuthenticationFailed() => 'Authentication failed', + }; + } + + /// 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. + Object? get cause { + return switch (this) { + ServerInitiated(:final error) => error?.error ?? error, + AuthenticationFailed(:final error) => error, + UserInitiated() || SystemInitiated() || UnHealthyConnection() || ConnectTimeout() => null, }; } + /// Whether a connection closed for this reason is worth opening again. + /// + /// {@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. + /// + /// 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, + /// and only while the network and the app lifecycle allow it — so a first connection that times + /// 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, + }; + @override List get props => []; } @@ -320,3 +354,28 @@ final class UnHealthyConnection extends DisconnectionSource { /// Creates an [UnHealthyConnection] disconnection source. const UnHealthyConnection(); } + +/// A disconnection caused by the connection not becoming usable in time. +/// +/// This source indicates that the connection was abandoned while it was still +/// being established, so it was never usable. +final class ConnectTimeout extends DisconnectionSource { + /// Creates a [ConnectTimeout] disconnection source. + const ConnectTimeout(); +} + +/// A disconnection caused by the connection failing to authenticate. +/// +/// This source indicates that the socket opened but the credentials could not be loaded or sent, so +/// the connection was never usable. A server rejecting credentials it did receive is reported as an +/// error event instead. +final class AuthenticationFailed extends DisconnectionSource { + /// Creates an [AuthenticationFailed] disconnection source. + const AuthenticationFailed({this.error}); + + /// The error that prevented the connection from authenticating. + final Object? error; + + @override + List get props => [error]; +} 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 5117099b..72725aa1 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -6,42 +6,73 @@ import 'package:test/test.dart'; import '../../helpers/user_token.dart'; -// A minimal HttpClientAdapter that captures the outgoing RequestOptions and -// always responds with an empty successful response. -class _CapturingHttpClientAdapter implements HttpClientAdapter { - RequestOptions? lastRequest; +/// The body the API returns when the token it was given has run out. +Map _expiredTokenBody() => { + 'code': 40, + 'details': [], + 'duration': '0ms', + 'message': 'token expired', + 'more_info': '', + 'StatusCode': 401, +}; + +ResponseBody _json(Object? body, int statusCode) => ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, +); + +/// A backend a test drives, in place of a real one. +/// +/// Answers the first [refusals] requests with an expired-token error and accepts every one after +/// that, so a refresh that works and one that never does share a harness. Set [reply] to answer +/// something else entirely, or [overlap] to answer nothing until that many requests are in flight +/// at once. +class _FakeApi implements HttpClientAdapter { + _FakeApi({ + this.refusals = 0, + this.refusalDelay, + this.onRequest, + this.reply, + this.overlap, + }); - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - lastRequest = options; - return ResponseBody.fromString( - '{}', - 200, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); - } + /// How many of the first requests are refused. + final int refusals; - @override - void close({bool force = false}) {} -} + /// How long the nth refusal is held, for a rejection that has to land after an earlier one has + /// already replaced the token. + final Duration? Function(int attempt)? refusalDelay; + + /// Called as each request is dispatched, for a test that moves the manager mid-flight. + final void Function()? onRequest; + + /// Replaces the reply entirely, for a backend answering something other than a Stream error. + final ResponseBody Function(int attempt)? reply; + + /// Holds every request until this many are in flight at once. + /// + /// A caller that runs its requests one at a time never gets there, and the wait never finishes, + /// which is what makes serialising them visible. + final int? overlap; -// An adapter that always responds with a token-expired API error (code 40), -// counting how many times it is hit so a retry can be detected. [onFetch], if -// provided, runs when the request is dispatched — used to simulate a token -// manager being swapped in mid-flight. -class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { - _TokenExpiredHttpClientAdapter({this.onFetch}); + /// The most requests that were in flight at once. + int get peakInFlight => _peakInFlight; + var _peakInFlight = 0; - final void Function()? onFetch; + final _reachedOverlap = Completer(); + var _inFlight = 0; - var _requestCount = 0; - int get requestCount => _requestCount; + /// Every request that reached this backend, in order. + final requests = []; + + /// The `Authorization` header each request carried, in order. + final sentTokens = []; + + int get count => requests.length; + RequestOptions? get lastRequest => requests.isEmpty ? null : requests.last; @override Future fetch( @@ -49,285 +80,470 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { Stream? requestStream, Future? cancelFuture, ) async { - _requestCount++; - onFetch?.call(); - return ResponseBody.fromString( - jsonEncode({ - 'code': 40, // token expired - 'details': [], - 'duration': '0ms', - 'message': 'token expired', - 'more_info': '', - 'StatusCode': 401, - }), - 401, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); + requests.add(options); + sentTokens.add(options.headers['Authorization'] as String?); + final attempt = requests.length; + + onRequest?.call(); + + if (reply case final reply?) return reply(attempt); + + if (attempt > refusals) { + if (overlap case final overlap?) await _awaitOverlap(overlap); + return _json(const {}, 200); + } + + if (refusalDelay?.call(attempt) case final delay?) { + await Future.delayed(delay); + } + + return _json(_expiredTokenBody(), 401); + } + + Future _awaitOverlap(int overlap) async { + _inFlight++; + if (_inFlight > _peakInFlight) _peakInFlight = _inFlight; + if (_inFlight >= overlap && !_reachedOverlap.isCompleted) _reachedOverlap.complete(); + + await _reachedOverlap.future; + _inFlight--; } @override void close({bool force = false}) {} } +/// Builds a Dio carrying the interceptor under test, over a backend the test drives. +/// +/// Defaults to the dynamic provider a real app has, issuing a token that can be told apart from the +/// last one. Pass [tokenProvider] for a static one, or [loader] to control when a load finishes and +/// what it returns; [loader] is given the number of the load, counting from one. +({ + Dio dio, + _FakeApi api, + TokenManager tokens, + int Function() loads, +}) +_subject({ + String userId = 'user-1', + TokenProvider? tokenProvider, + Future Function(String userId, int load)? loader, + _FakeApi? api, +}) { + var loads = 0; + + final tokens = TokenManager( + userId: userId, + tokenProvider: + tokenProvider ?? + TokenProvider.dynamic((id) { + loads++; + return loader?.call(id, loads) ?? Future.value(generateTestUserToken(id, nonce: '$loads')); + }), + ); + + final backend = api ?? _FakeApi(); + final dio = Dio(BaseOptions(baseUrl: 'https://example.com'))..httpClientAdapter = backend; + dio.interceptors.add(AuthInterceptor(dio, tokens)); + + return (dio: dio, api: backend, tokens: tokens, loads: () => loads); +} + +/// Matches the token-expired error the backend refuses with, as the caller receives it. +final _expiredTokenError = isA() + .having((it) => it.response?.statusCode, 'response.statusCode', 401) + .having((it) => (it.response?.data as Map?)?['code'], 'response.data.code', 40); + void main() { group('AuthInterceptor', () { - test( - 'sets the Authorization header, the auth type, and the user_id query ' - 'parameter', - () async { - final tokenManager = TokenManager( - userId: 'user-123', - tokenProvider: TokenProvider.static( - generateTestUserToken('user-123'), + test('sends the token, its auth type and its user as the request credentials', () async { + final token = generateTestUserToken('user-123'); + final (:dio, :api, tokens: _, loads: _) = _subject( + userId: 'user-123', + tokenProvider: TokenProvider.static(token), + ); + + await dio.get('/test'); + + // The exact values, not merely that something was set: a header carrying the wrong token + // authenticates as the wrong user, or not at all. + expect(api.lastRequest?.headers['Authorization'], token.rawValue); + expect(api.lastRequest?.headers['stream-auth-type'], AuthType.jwt.headerValue); + expect(api.lastRequest?.queryParameters['user_id'], 'user-123'); + }); + + test('sends an anonymous token as an empty Authorization header with the anonymous auth type', () async { + final (:dio, :api, tokens: _, loads: _) = _subject( + userId: User.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), + ); + + await dio.get('/test'); + + expect(api.lastRequest?.headers['Authorization'], isEmpty); + expect(api.lastRequest?.headers['stream-auth-type'], AuthType.anonymous.headerValue); + expect(api.lastRequest?.queryParameters['user_id'], User.anonymousUserId); + }); + + test('sends a restricted anonymous token as the Authorization header', () async { + final restricted = generateTestUserToken(User.anonymousUserId); + final (:dio, :api, tokens: _, loads: _) = _subject( + userId: User.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous(rawValue: restricted.rawValue)), + ); + + await dio.get('/test'); + + // An anonymous token may still carry a JWT granting restricted access, which has to go out. + expect(api.lastRequest?.headers['Authorization'], restricted.rawValue); + expect(api.lastRequest?.headers['stream-auth-type'], AuthType.anonymous.headerValue); + }); + + test('sends the user id of the token it actually sent', () async { + // A load already running for one user can finish after the manager has moved to another, and + // its token still goes to the request that triggered it. Taking `user_id` from the token keeps + // the pair consistent. + final slowLoad = Completer(); + final (:dio, :api, :tokens, loads: _) = _subject(loader: (_, _) => slowLoad.future); + + final pending = dio.get('/test'); + await pumpEventQueue(); + + final userOneToken = generateTestUserToken('user-1'); + tokens.setTokenProvider('user-2', tokenProvider: TokenProvider.static(generateTestUserToken('user-2'))); + slowLoad.complete(userOneToken); + await pending; + + expect(api.lastRequest?.queryParameters['user_id'], 'user-1'); + expect(api.lastRequest?.headers['Authorization'], userOneToken.rawValue); + }); + + test('fails a request whose token could not be loaded, rather than sending it unauthenticated', () async { + final (:dio, :api, tokens: _, loads: _) = _subject( + tokenProvider: TokenProvider.dynamic((_) async => throw StateError('token endpoint down')), + ); + + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (it) => it.error, + 'error', + isA() + .having((it) => it.message, 'message', 'Failed to load auth token') + .having((it) => it.underlyingError, 'underlyingError', isStateError), ), - ); + ), + ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + // Sent without credentials it would come back 401, which reads as a token the server refused + // rather than one that was never loaded. + expect(api.count, 0); + }); - await dio.get('/test'); + group('the refresh-and-retry', () { + test('retries a token-expired response once with the token the provider issued next', () async { + final (:dio, :api, tokens: _, :loads) = _subject(api: _FakeApi(refusals: 1)); - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-123'); - expect( - adapter.lastRequest?.headers['Authorization'], - isNotNull, - ); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - isNotNull, - ); - }, - ); - - test( - 'sends the user id a guest exchange returned, once the token manager is ' - 'pointed at it', - () async { - // Simulates the guest flow: the exchange is authenticated anonymously, - // then the manager is pointed at the id the exchange returned before - // the next request goes out, so nothing is in flight across the swap. - const serverId = 'server-assigned-id'; - - final tokenManager = TokenManager( - userId: User.anonymousUserId, - tokenProvider: TokenProvider.static(UserToken.anonymous()), - ); + final response = await dio.get('/test'); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + expect(response.statusCode, 200); + expect(api.count, 2); + expect(loads(), 2); + expect(api.sentTokens.first, isNot(api.sentTokens.last)); + }); - tokenManager.setTokenProvider( - serverId, - tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), - ); + test('retries a multipart request without re-sending a consumed body', () async { + final (:dio, :api, tokens: _, loads: _) = _subject(api: _FakeApi(refusals: 1)); - await dio.get('/test'); + // A file's bytes are a stream, and the refused attempt has already read it. Without a clone + // the retry fails with "The FormData has already been finalized". + final body = FormData()..files.add(MapEntry('file', MultipartFile.fromString('contents'))); - expect(adapter.lastRequest?.queryParameters['user_id'], serverId); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - AuthType.jwt.headerValue, - ); - }, - ); - - test( - 'sends an anonymous token as an empty Authorization header with the ' - 'anonymous auth type', - () async { - final tokenManager = TokenManager( - userId: User.anonymousUserId, - tokenProvider: TokenProvider.static(UserToken.anonymous()), - ); + final response = await dio.post('/upload', data: body); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + expect(response.statusCode, 200); + expect(api.count, 2); + }); - await dio.get('/test'); + test('reports a replacement the server refuses too, rather than leaving the caller waiting', () async { + // The provider keeps issuing tokens that are refused. Retrying the retry would re-enter this + // interceptor from inside itself, so the retry happens exactly once. + final (:dio, :api, tokens: _, :loads) = _subject(api: _FakeApi(refusals: 10)); - expect(adapter.lastRequest?.headers['Authorization'], isEmpty); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - AuthType.anonymous.headerValue, - ); - expect( - adapter.lastRequest?.queryParameters['user_id'], - User.anonymousUserId, + await expectLater( + dio.get('/test').timeout(const Duration(seconds: 5)), + throwsA(_expiredTokenError), ); - }, - ); - - test( - 'sends a restricted anonymous token as the Authorization header', - () async { - final restricted = generateTestUserToken(User.anonymousUserId); - final tokenManager = TokenManager( - userId: User.anonymousUserId, - tokenProvider: TokenProvider.static( - UserToken.anonymous(rawValue: restricted.rawValue), + + expect(api.count, 2); + expect(loads(), 2); + }); + + test('leaves the token alone for a rejection that arrives after it was already replaced', () async { + // Both requests carry the same token. The first is refused and replaces it, so by the time + // the second is refused it is no longer the cached one, and expiring then would discard a + // good replacement. + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi( + refusals: 2, + refusalDelay: (attempt) => attempt == 2 ? const Duration(milliseconds: 200) : null, ), ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + await Future.wait([dio.get('/a'), dio.get('/b')]); + + expect(loads(), 2); + expect(api.count, 4); + expect(api.sentTokens.toSet(), hasLength(2)); + }); + + test('sends a burst of rejected requests to the provider once between them', () async { + // Every request in flight is rejected carrying the same spent token, and each expires it in + // turn. The manager serialises the loads, so all three share one replacement. + final replacementLoading = Completer(); + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi(refusals: 3), + loader: (id, load) async { + // Held so all three refusals are handled at once. + if (load > 1) await replacementLoading.future; + return generateTestUserToken(id, nonce: '$load'); + }, + ); - await dio.get('/test'); + final requests = Future.wait([dio.get('/a'), dio.get('/b'), dio.get('/c')]); - expect(adapter.lastRequest?.headers['Authorization'], restricted.rawValue); - expect( - adapter.lastRequest?.headers['stream-auth-type'], - AuthType.anonymous.headerValue, + await pumpEventQueue(); + replacementLoading.complete(); + await requests; + + expect(loads(), 2); + expect(api.count, 6); + expect(api.sentTokens.skip(3).toSet(), hasLength(1)); + }); + }); + + group('while a token load is in flight', () { + test('holds a burst of requests until the first load finishes', () async { + final loadStarted = Completer(); + final loadGate = Completer(); + final (:dio, :api, tokens: _, :loads) = _subject( + loader: (id, load) async { + if (!loadStarted.isCompleted) loadStarted.complete(); + await loadGate.future; + return generateTestUserToken(id, nonce: '$load'); + }, ); - }, - ); - - test( - 'sends the user id of the token it actually sent', - () async { - // The two can disagree: a load already running for one user finishes - // after the manager has moved to another, and that token is still - // handed to the request that triggered it. Deriving `user_id` from the - // token keeps the pair self-consistent, so the request is accepted as - // the token's owner rather than rejected for a mismatch. - final slowLoad = Completer(); - final tokenManager = TokenManager( - userId: 'user-1', - tokenProvider: TokenProvider.dynamic((_) => slowLoad.future), + + final requests = Future.wait(List.generate(10, (i) => dio.get('/r$i'))); + + await loadStarted.future; + await pumpEventQueue(); + expect(api.count, 0); + expect(loads(), 1); + + loadGate.complete(); + await requests; + + // One load shared by all ten, rather than ten loads racing each other. + expect(loads(), 1); + expect(api.count, 10); + expect(api.sentTokens.toSet(), hasLength(1)); + }); + + test('does not hold requests behind one another', () async { + // The reason this is an `Interceptor` and not a `QueuedInterceptor`: a queue would run these + // one at a time, and the retry sent from `onError` would wait behind the request still + // holding the handler, so neither would ever finish. + final (:dio, :api, tokens: _, loads: _) = _subject(api: _FakeApi(overlap: 5)); + + final requests = Future.wait(List.generate(5, (i) => dio.get('/r$i'))); + await requests.timeout(const Duration(seconds: 5)); + + expect(api.peakInFlight, 5); + }); + + test('does not hold the retries of a burst that was all refused behind one another', () async { + // Every request is refused for its token, so every one retries. The token load is shared, + // but the retries themselves must still go out together. + final (:dio, :api, tokens: _, :loads) = _subject(api: _FakeApi(refusals: 3, overlap: 3)); + + final requests = Future.wait([dio.get('/a'), dio.get('/b'), dio.get('/c')]); + await requests.timeout(const Duration(seconds: 5)); + + expect(api.peakInFlight, 3); + expect(loads(), 2); + }); + + test('holds a new request until an in-flight refresh finishes', () async { + final refreshGate = Completer(); + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi(refusals: 1), + loader: (id, load) async { + // Hold the replacement, not the token the first request carries. + if (load > 1) await refreshGate.future; + return generateTestUserToken(id, nonce: '$load'); + }, ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + final first = dio.get('/a'); + await pumpEventQueue(); + expect(loads(), 2); + expect(api.count, 1); - final pending = dio.get('/test'); + // A request arriving during the refresh waits for the replacement rather than going out with + // the token that was just refused. + final second = dio.get('/b'); await pumpEventQueue(); + expect(api.count, 1); - // The load is already running for user-1 when the manager moves on. - final userOneToken = generateTestUserToken('user-1'); - tokenManager.setTokenProvider( - 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), - ); - slowLoad.complete(userOneToken); - await pending; + refreshGate.complete(); + await Future.wait([first, second]); - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-1'); - expect( - adapter.lastRequest?.headers['Authorization'], - userOneToken.rawValue, - ); - }, - ); - - test( - 'does not retry a token-expired response when using a static provider ' - '(e.g. a guest token): the error is surfaced to the caller instead of ' - 'silently re-minting the token', - () async { - final tokenManager = TokenManager( + expect(loads(), 2); + expect(api.sentTokens.first, isNot(api.sentTokens.last)); + expect(api.sentTokens.skip(1).toSet(), hasLength(1)); + }); + }); + + group('when no other token could be presented', () { + test('does not retry when the provider has only the token that was refused', () async { + final (:dio, :api, tokens: _, loads: _) = _subject( userId: 'guest-1', tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), + api: _FakeApi(refusals: 10), ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _TokenExpiredHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + // A static provider reissues what was refused, so the retry would present it again. + await expectLater(dio.get('/test'), throwsA(_expiredTokenError)); - await expectLater( - dio.get('/test'), - throwsA(isA()), - ); + expect(api.count, 1); + }); - // A static provider must not trigger the refresh-and-retry path, so - // the request is attempted exactly once. - expect(adapter.requestCount, 1); - }, - ); - - test( - 'forwards a token-expired error without retrying when the manager has no ' - 'identity left to load a token for', - () async { - final tokenManager = TokenManager( - userId: 'user-123', - tokenProvider: TokenProvider.dynamic( - (userId) async => generateTestUserToken(userId), + test('does not retry when a static provider is adopted mid-flight', () async { + // Starts on a dynamic provider and adopts a static one carrying the exchanged id once the + // request is in flight, mirroring the guest flow. `onError` sees the static provider and + // must forward the error rather than expire and retry. + late TokenManager tokens; + final subject = _subject( + userId: 'requested-id', + api: _FakeApi( + refusals: 10, + onRequest: () => tokens.setTokenProvider( + 'server-assigned-id', + tokenProvider: TokenProvider.static(generateTestUserToken('server-assigned-id')), + ), ), ); - - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _TokenExpiredHttpClientAdapter(onFetch: tokenManager.reset); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); - - await expectLater( - dio.get('/test'), - throwsA( - // Retrying would replace this with the failure to load a token for - // a user the manager no longer has, which says less. - isA().having( - (it) => (it.response?.data as Map?)?['code'], - 'the original token-expired error', - 40, + tokens = subject.tokens; + + await expectLater(subject.dio.get('/test'), throwsA(_expiredTokenError)); + + expect(subject.api.count, 1); + }); + + test('does not retry a request signed for a user the manager has moved on from', () async { + // The manager is pointed at another user while the request is in flight. Retrying would sign + // it with their token and perform one user's request as another, and answer the caller as + // though their own had succeeded. + late TokenManager tokens; + final subject = _subject( + userId: 'user-a', + api: _FakeApi( + refusals: 10, + onRequest: () => tokens.setTokenProvider( + 'user-b', + tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), ), ), ); + tokens = subject.tokens; + + await expectLater(subject.dio.get('/test'), throwsA(_expiredTokenError)); + + expect(subject.api.count, 1); + expect(subject.api.sentTokens.single, isNot(contains('user-b'))); + }); + + test('forwards the error when the manager has no identity left to load a token for', () async { + late TokenManager tokens; + final subject = _subject(api: _FakeApi(refusals: 10, onRequest: () => tokens.reset())); + tokens = subject.tokens; + + // Retrying would replace this with the less useful failure to load a token for a user the + // manager no longer has. + await expectLater(subject.dio.get('/test'), throwsA(_expiredTokenError)); + + expect(subject.api.count, 1); + }); + }); + + test('retries a token-expired response the server sent as text rather than JSON', () async { + // A proxy or gateway can answer without a JSON content type, and Dio then hands the body over + // as a string. It is the same refusal, so it deserves the same replacement. + var attempt = 0; + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi( + reply: (_) => switch (++attempt) { + 1 => ResponseBody.fromString( + jsonEncode(_expiredTokenBody()), + 401, + headers: { + Headers.contentTypeHeader: ['text/plain'], + }, + ), + _ => _json(const {}, 200), + }, + ), + ); + + final response = await dio.get('/test'); + + expect(response.statusCode, 200); + expect(api.count, 2); + expect(loads(), 2); + }); + + group('an error that is not a Stream token error', () { + test('is forwarded when its JSON body belongs to something else', () async { + // A proxy or gateway answers with a JSON body of its own. Parsing it as a Stream error + // throws, and an error thrown in `onError` never reaches the caller: the request used to + // hang for good instead of failing. + final (:dio, :api, tokens: _, loads: _) = _subject( + api: _FakeApi(reply: (_) => _json({'error': 'gateway timeout'}, 504)), + ); - expect(adapter.requestCount, 1); - }, - ); - - test( - 'forwards a token-expired error without retrying when the token manager ' - 'is pointed at a static provider after the request was dispatched ' - '(guest exchange resolving mid-flight)', - () async { - // Starts on a dynamic provider and adopts a static one carrying the - // exchanged id once the request is already in flight, mirroring the - // guest flow. onError sees the static provider and must forward the - // error rather than expire + retry. - final tokenManager = TokenManager( - userId: 'requested-id', - tokenProvider: TokenProvider.dynamic( - (_) async => generateTestUserToken('requested-id'), - ), + await expectLater( + dio.get('/test'), + throwsA(isA().having((it) => it.response?.statusCode, 'response.statusCode', 504)), ); - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _TokenExpiredHttpClientAdapter( - onFetch: () { - tokenManager.setTokenProvider( - 'server-assigned-id', - tokenProvider: TokenProvider.static( - generateTestUserToken('server-assigned-id'), - ), - ); - }, + expect(api.count, 1); + }); + + test('is forwarded when it carries no JSON at all', () async { + // A gateway answering HTML, which is the shape of most failures that are not the API's. + final (:dio, :api, tokens: _, :loads) = _subject( + api: _FakeApi( + reply: (_) => ResponseBody.fromString( + '502 Bad Gateway', + 502, + headers: { + Headers.contentTypeHeader: ['text/html'], + }, + ), + ), ); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor(dio, tokenManager)); await expectLater( dio.get('/test'), - throwsA(isA()), + throwsA(isA().having((it) => it.response?.statusCode, 'response.statusCode', 502)), ); - // The adopted provider is static, so the error is surfaced without a - // refresh-and-retry: the request is attempted exactly once. - expect(adapter.requestCount, 1); - }, - ); + // Nothing here says the token is spent, so it is neither expired nor reloaded. + expect(api.count, 1); + expect(loads(), 1); + }); + }); }); } 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 new file mode 100644 index 00000000..d07fcbd1 --- /dev/null +++ b/packages/stream_core/test/api/stream_core_dio_error_test.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +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'}) => { + 'code': code, + 'details': [], + 'duration': '0ms', + 'message': message, + 'more_info': '', + 'StatusCode': statusCode, +}; + +DioException _failure({ + Object? body, + int? statusCode, + String? statusMessage, + String? message, + DioExceptionType type = DioExceptionType.badResponse, +}) { + final options = RequestOptions(path: '/test'); + return DioException( + requestOptions: options, + type: type, + message: message, + response: statusCode == null + ? null + : Response( + requestOptions: options, + statusCode: statusCode, + statusMessage: statusMessage, + data: body, + ), + ); +} + +void main() { + group('DioException.apiError', () { + 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); + }); + + 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; + + expect(error?.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('falls back to what the transport reported when the response carried no Stream error', () { + final dioException = _failure( + body: {'error': 'gateway timeout'}, + statusCode: 504, + statusMessage: 'Gateway Timeout', + ); + + final exception = dioException.toClientException(); + + 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('falls back to the exception message when there is no response at all', () { + final exception = _failure(message: 'connection refused').toClientException(); + + expect(exception.message, 'connection refused'); + expect(exception.statusCode, isNull); + }); + + test('never leaves the message null, so a caller always has something to show', () { + final exception = _failure().toClientException(); + + expect(exception.message, isEmpty); + }); + + test('marks a request the caller cancelled as such', () { + final cancelled = _failure(type: DioExceptionType.cancel).toClientException(); + final refused = _failure(body: _errorBody(), statusCode: 401).toClientException(); + + // A caller that called the request off should not be shown it as a failure. + expect(cancelled.isRequestCancelledError, isTrue); + expect(refused.isRequestCancelledError, isFalse); + }); + }); +} diff --git a/packages/stream_core/test/helpers/fake_server.dart b/packages/stream_core/test/helpers/fake_server.dart new file mode 100644 index 00000000..5ad02349 --- /dev/null +++ b/packages/stream_core/test/helpers/fake_server.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; + +import 'web_socket.dart'; + +/// The events the fake server sends, decoded from the wire the way a real client decodes them. +/// +/// A consumer of `stream_core` brings its own event types, so these stand in for them: the shapes +/// the client itself reacts to, and nothing else. +sealed class TestEvent extends WsEvent { + const TestEvent(); + + /// Decodes a frame the server sent. + /// + /// Anything unrecognised becomes a [PlainEvent], which the client passes through to its + /// listeners untouched. + factory TestEvent.fromJson(Map json) { + return switch (json['type']) { + 'connection.ok' || 'health.check' => HealthCheck( + connectionId: json['connection_id'] as String?, + ), + 'connection.error' => ConnectionError( + StreamApiError.fromJson(json['error']! as Map), + ), + _ => PlainEvent(json['type']! as String), + }; + } +} + +/// The reply that establishes a connection, and the pong that keeps it alive. +final class HealthCheck extends TestEvent { + const HealthCheck({this.connectionId = 'connection-id'}); + + final String? connectionId; + + @override + HealthCheckInfo? get healthCheckInfo => HealthCheckInfo(connectionId: connectionId); +} + +/// The refusal a server sends before closing a connection it will not serve. +final class ConnectionError extends TestEvent { + const ConnectionError(this.apiError); + + final StreamApiError apiError; + + @override + Object? get error => apiError; +} + +/// Anything the client has no special handling for, and emits to its listeners. +final class PlainEvent extends TestEvent { + const PlainEvent(this.type); + + final String type; +} + +/// The codec a client uses on the wire, so requests are really serialised and events really parsed. +class JsonCodec implements WebSocketMessageCodec { + const JsonCodec(); + + @override + Object encode(WsRequest message) => jsonEncode(message.toJson()); + + @override + WsEvent decode(Object message) { + final json = jsonDecode(message as String) as Map; + return TestEvent.fromJson(json); + } +} + +/// Builds the error payload a server sends, in the shape the API returns it. +Map connectionErrorFrame({required int code, int statusCode = 401}) { + return { + 'type': 'connection.error', + 'connection_id': 'connection-id', + 'error': { + 'code': code, + 'message': 'error $code', + 'StatusCode': statusCode, + 'details': [], + 'duration': '0ms', + 'more_info': '', + }, + }; +} + +/// The refusal that another token repairs, which is why it is reconnected. +Map expiredTokenFrame() => connectionErrorFrame(code: 40); + +/// The refusal no other token repairs, which is why it is not. +Map invalidSignatureFrame() => connectionErrorFrame(code: 43); + +/// A server a test drives, which answers what the client actually sent. +/// +/// Every frame the client puts on the socket is decoded and handed to [onFrame], whose reply goes +/// back over the same socket. Nothing is scripted in advance: a handshake succeeds because the +/// server accepted the credentials it was given, and a ping is answered because a ping arrived. +/// +/// The default behaviour is a healthy server for [user]: it accepts a token issued to them, +/// refuses anyone else's with an invalid-signature error, and answers every ping. +class FakeServer { + FakeServer({this.user = 'luke_skywalker'}); + + /// The user whose token this server accepts. + final String user; + + /// Called with each decoded frame the client sent, in place of the default behaviour. + /// + /// Return the frames to reply with, or an empty list to say nothing. Set this to model a server + /// that refuses, goes quiet, or answers out of order. + List> Function(Map frame)? onFrame; + + /// Every frame the client has sent, decoded, in order. + final received = >[]; + + /// The socket of the attempt in flight, which is the one a reply is sent over. + FakeWebSocketChannel get socket => _socket!; + FakeWebSocketChannel? _socket; + + /// Every socket this server has handed out, in the order they were opened. + final sockets = []; + + /// Hands out a socket wired to this server, for a client's `wsProvider`. + FakeWebSocketChannel connect({ + bool handshakeFails = false, + bool handshakeHangs = false, + bool holdClose = false, + Object? closeError, + }) { + final socket = FakeWebSocketChannel( + holdClose: holdClose, + closeError: closeError, + readyError: handshakeFails ? Exception('upgrade refused') : null, + holdReady: handshakeHangs, + ); + + socket.sink.onSent = _onSent; + sockets.add(socket); + return _socket = socket; + } + + void _onSent(Object? frame) { + final json = jsonDecode(frame! as String) as Map; + received.add(json); + + final replies = onFrame?.call(json) ?? _defaultReply(json); + replies.forEach(send); + } + + List> _defaultReply(Map frame) { + // A ping is answered whatever else is going on: a live connection is one that keeps answering. + if (frame['type'] == 'health.check') return [_connectionOk()]; + + // Anything carrying a token is a client presenting credentials. + if (frame['token'] case final String token) { + if (_userOf(token) != user) return [invalidSignatureFrame()]; + return [_connectionOk()]; + } + + return const []; + } + + Map _connectionOk() { + return {'type': 'connection.ok', 'connection_id': 'connection-id'}; + } + + /// Sends [frame] to the client, as a server pushing an event does. + void send(Map frame) => _socket?.emit(jsonEncode(frame)); + + /// Hangs up without saying why, as a server dropping a connection does. + void hangUp() => _socket?.endStream(); + + /// Breaks the connection with a socket error, as a network failing mid-stream does. + /// + /// Distinct from [send]ing a `connection.error`: that is the server explaining itself over a + /// working socket, this is the socket itself giving out. + void fail(Object error) => _socket?.emitError(error); + + /// Closes every socket this server handed out. + void dispose() { + for (final socket in sockets) { + socket.endStream(); + } + } +} + +// Reads the user a token was issued to, or null for anything that is not a readable JWT — which a +// real server treats the same way it treats a token naming the wrong user. +String? _userOf(String token) { + final parts = token.split('.'); + if (parts.length < 2) return null; + + try { + final payload = parts[1]; + final padded = payload.padRight(payload.length + (4 - payload.length % 4) % 4, '='); + final claims = jsonDecode(utf8.decode(base64Url.decode(padded))) as Map; + return claims['user_id'] as String?; + } on Object { + return null; + } +} diff --git a/packages/stream_core/test/helpers/web_socket.dart b/packages/stream_core/test/helpers/web_socket.dart new file mode 100644 index 00000000..b767c2fb --- /dev/null +++ b/packages/stream_core/test/helpers/web_socket.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +// ignore: depend_on_referenced_packages +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// A socket a test drives, recording what was sent to the peer. +/// +/// Closing it ends [FakeWebSocketChannel.stream], as a real socket does. Pass `holdClose` for a +/// close that only finishes once [completeClose] is called, and `closeError` for one that refuses to +/// close at all. +class FakeWebSocketSink implements WebSocketSink { + FakeWebSocketSink({this.holdClose = false, this.closeError, this.onSent, this._onClosed}); + + /// Whether [close] waits for [completeClose] before finishing. + final bool holdClose; + + /// Thrown by [close], for a socket that refuses to close. + final Object? closeError; + + /// Called with each frame sent, for a peer that answers. + void Function(Object? frame)? onSent; + + final void Function()? _onClosed; + + /// Everything sent to the peer, in order. + final sent = []; + + /// The code and reason [close] was called with, or `null` while still open. + ({int? code, String? reason})? closedWith; + + final _held = Completer(); + final _done = Completer(); + + /// Lets a close held by `holdClose` finish. + void completeClose() { + if (!_held.isCompleted) _held.complete(); + } + + @override + void add(Object? data) { + sent.add(data); + onSent?.call(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream stream) => stream.forEach(add); + + @override + Future close([int? closeCode, String? closeReason]) async { + closedWith = (code: closeCode, reason: closeReason); + if (holdClose) await _held.future; + if (closeError case final error?) throw error; // ignore: only_throw_errors + + _onClosed?.call(); + if (!_done.isCompleted) _done.complete(); + } + + @override + Future get done => _done.future; +} + +/// A WebSocket a test drives, in place of a real connection. +/// +/// Incoming frames arrive through [emit], and [endStream] is the peer hanging up. Closing [sink] +/// ends [stream], which is the guarantee `StreamChannel` requires of a real channel. +class FakeWebSocketChannel extends StreamChannelMixin implements WebSocketChannel { + FakeWebSocketChannel({ + bool holdClose = false, + Object? closeError, + this.readyError, + this.holdReady = false, + }) { + sink = FakeWebSocketSink( + holdClose: holdClose, + closeError: closeError, + onClosed: endStream, + ); + } + + /// Thrown by [ready], for a socket that never opens. + final Object? readyError; + + /// Whether [ready] never completes, for a handshake that hangs. + final bool holdReady; + + @override + // ignore: close_sinks + late final FakeWebSocketSink sink; + + // A controller with no `onCancel` has nothing to wait for, so `cancel` hands back a future that + // belongs to the root zone. A `fakeAsync` test never drives that zone, so awaiting the cancel there + // stalls for good, while against a real socket it completes. Naming an `onCancel` is what makes the + // controller hand back a future of its own instead. + final _incoming = StreamController(onCancel: Future.value); + + /// Delivers an incoming frame. + void emit(Object? frame) { + if (!_incoming.isClosed) _incoming.add(frame); + } + + /// Delivers a socket error, as a connection failing mid-stream does. + /// + /// A real socket closes itself after reporting one, so [endStream] follows. + void emitError(Object error) { + if (_incoming.isClosed) return; + + _incoming.addError(error); + endStream(); + } + + /// Ends the incoming stream, as a peer hanging up does. + void endStream() { + if (!_incoming.isClosed) _incoming.close().ignore(); + } + + @override + Stream get stream => _incoming.stream; + + final _ready = Completer(); + + /// Lets a handshake held by `holdReady` finish, for one that lands late. + void completeReady() { + if (!_ready.isCompleted) _ready.complete(); + } + + @override + Future get ready { + if (readyError case final error?) return Future.error(error); + if (holdReady) return _ready.future; + return Future.value(); + } + + @override + String? get protocol => null; + + @override + int? get closeCode => sink.closedWith?.code; + + @override + String? get closeReason => sink.closedWith?.reason; +} diff --git a/packages/stream_core/test/helpers/ws_client_tester.dart b/packages/stream_core/test/helpers/ws_client_tester.dart new file mode 100644 index 00000000..57709269 --- /dev/null +++ b/packages/stream_core/test/helpers/ws_client_tester.dart @@ -0,0 +1,290 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart' as test; + +import 'fake_server.dart'; +import 'user_token.dart'; + +/// A network provider a test drives, in place of one watching a real interface. +class TestNetworkStateProvider implements NetworkStateProvider { + TestNetworkStateProvider([NetworkState initial = NetworkState.connected]) : _state = MutableStateEmitter(initial); + + @override + NetworkStateEmitter get state => _state; + final MutableStateEmitter _state; + + /// Reports the network as available, as regaining a connection does. + void connect() => _state.value = NetworkState.connected; + + /// Reports the network as gone, as losing a connection does. + void disconnect() => _state.value = NetworkState.disconnected; + + Future close() => _state.close(); +} + +/// A lifecycle provider a test drives, in place of one watching the real app. +class TestLifecycleStateProvider implements LifecycleStateProvider { + TestLifecycleStateProvider([LifecycleState initial = LifecycleState.foreground]) + : _state = MutableStateEmitter(initial); + + @override + LifecycleStateEmitter get state => _state; + final MutableStateEmitter _state; + + /// Reports the app as in use, as returning to it does. + void foreground() => _state.value = LifecycleState.foreground; + + /// Reports the app as put away, as leaving it does. + void background() => _state.value = LifecycleState.background; + + Future close() => _state.close(); +} + +/// Everything a test drives a client through, and the signals it asserts on. +class WsClientTester { + WsClientTester._({ + required this.client, + required this.server, + required this.network, + required this.lifecycle, + required this.states, + required this._attempts, + required this._tokenLoads, + required this._subscription, + required this._recovery, + required this.tokens, + }); + + /// The client under test, driven through its public surface alone. + final StreamWebSocketClient client; + + /// The server it is talking to. + final FakeServer server; + + /// The manager the default authenticator loads credentials from. + /// + /// Point it at another user with `setTokenProvider` to model an app signing a different one in. + final TokenManager tokens; + + /// The network the recovery handler is watching. + final TestNetworkStateProvider network; + + /// The app lifecycle the recovery handler is watching. + final TestLifecycleStateProvider lifecycle; + + /// Every connection state the client has reported, in order. + /// + /// An app can observe these, which is what makes them worth asserting on: a reconnection shows + /// up as another [Connecting], and nothing else needs to be reached into to see it. + final List states; + + final int Function() _attempts; + final int Function() _tokenLoads; + final StreamSubscription _subscription; + final ConnectionRecoveryHandler? _recovery; + + /// How many connection attempts have been made, counted as the options were built for each. + int get attempts => _attempts(); + + /// How many times credentials have been loaded from the token provider. + /// + /// A reconnection that presents a token issued after a refusal shows up here as another load. + int get tokenLoads => _tokenLoads(); + + /// The current connection state. + WebSocketConnectionState get connectionState => client.connectionState.value; + + /// Sends [frame] from the server and lets the client react to it. + Future emit(Map frame) async { + server.send(frame); + await pumpEventQueue(); + } + + /// Waits for the work an action set off to finish. + Future pumpEventQueue({int times = 20}) => test.pumpEventQueue(times: times); + + /// Releases everything this tester holds. + /// + /// [wsClientTest] calls this. A test driving `fakeAsync` must not: these futures were + /// created inside the fake zone, and awaiting them once it has been discarded never returns. + /// Nothing outlives that zone, so there is nothing left to release. + Future dispose() async { + await _subscription.cancel(); + await _recovery?.dispose(); + await client.dispose(); + await network.close(); + await lifecycle.close(); + server.dispose(); + } +} + +/// Runs a test against a fully wired [StreamWebSocketClient]. +/// +/// Only the socket is stood in for: the engine, codec, authentication handler, health monitor and +/// recovery handler are the real ones, so a test drives the client the way an app does and the +/// server answers what the client actually sent. +/// +/// [user] is who the client authenticates as, and who the server accepts by default. [tokenLoader] +/// replaces the default token, for a test that cares how often credentials are loaded or what is +/// presented the second time. [authenticator] replaces the whole handshake, for a client that sends +/// something else. Pass `authenticates: false` for a connection that needs nothing sent. +/// +/// [recover] wires the recovery handler in, so a drop is reconnected as it is in an app. It is off +/// by default, because most tests are about what a single attempt does. +/// +/// [connect] runs before [body] and defaults to connecting and asserting the connection was +/// established. Pass a callback of your own for a test that starts from somewhere else, or +/// `(_) {}` to start from a client that has never connected. +@isTest +void wsClientTest( + String description, { + String user = 'luke_skywalker', + Future Function(String userId)? tokenLoader, + WebSocketAuthenticator? authenticator, + bool authenticates = true, + TokenManager? tokens, + bool recover = false, + Duration? connectTimeout, + bool handshakeFails = false, + bool handshakeHangs = false, + bool holdClose = false, + Object? closeError, + FutureOr Function(WsClientTester tester)? connect, + required FutureOr Function(WsClientTester tester) body, + Iterable tags = const ['ws-client'], +}) { + return test.test( + description, + tags: tags, + () async { + final tester = buildTester( + user: user, + tokenLoader: tokenLoader, + authenticator: authenticator, + authenticates: authenticates, + tokens: tokens, + recover: recover, + connectTimeout: connectTimeout, + handshakeFails: handshakeFails, + handshakeHangs: handshakeHangs, + holdClose: holdClose, + closeError: closeError, + ); + test.addTearDown(tester.dispose); + + await (connect ?? _defaultConnect)(tester); + await body(tester); + }, + ); +} + +// Connects and checks the connection was established, so a test body starts from a client that +// completed a handshake rather than one that merely tried. +Future _defaultConnect(WsClientTester tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + + test.expect(tester.connectionState, test.isA()); +} + +/// Builds a tester without running a test around it, for a body that needs to control time. +/// +/// A test that drives timers wraps its own `fakeAsync` and cannot await a connect across it, so it +/// calls this and connects by hand. Everything else should use [wsClientTest]. +/// +/// [handshakeFailsWhen] is asked before each attempt, for a test where only some of them fail. It +/// takes precedence over [handshakeFails], which applies to every attempt alike. +WsClientTester buildTester({ + String user = 'luke_skywalker', + Future Function(String userId)? tokenLoader, + WebSocketAuthenticator? authenticator, + bool authenticates = true, + TokenManager? tokens, + bool recover = false, + Duration? connectTimeout, + bool handshakeFails = false, + bool Function()? handshakeFailsWhen, + bool handshakeHangs = false, + bool holdClose = false, + Object? closeError, +}) { + final server = FakeServer(user: user); + + var tokenLoads = 0; + final tokenManager = + tokens ?? + TokenManager( + userId: user, + tokenProvider: TokenProvider.dynamic((userId) async { + tokenLoads++; + return tokenLoader?.call(userId) ?? generateTestUserToken(userId); + }), + ); + + var attempts = 0; + final client = StreamWebSocketClient( + optionsBuilder: () { + attempts++; + return switch (connectTimeout) { + final it? => WebSocketOptions(url: 'wss://example.com', connectTimeout: it), + // Left to the class default, so the attempts that rely on it really go through it. + null => const WebSocketOptions(url: 'wss://example.com'), + }; + }, + wsProvider: (_) => server.connect( + handshakeFails: handshakeFailsWhen?.call() ?? handshakeFails, + handshakeHangs: handshakeHangs, + holdClose: holdClose, + closeError: closeError, + ), + onAuthenticate: switch (authenticates) { + true => authenticator ?? _authenticatorFor(tokenManager), + false => null, + }, + messageCodec: const JsonCodec(), + ); + + final network = TestNetworkStateProvider(); + final lifecycle = TestLifecycleStateProvider(); + + final recovery = switch (recover) { + true => ConnectionRecoveryHandler( + client: client, + networkStateProvider: network, + lifecycleStateProvider: lifecycle, + ), + false => null, + }; + + final states = []; + // Cancelled by `WsClientTester.dispose`, which a `fakeAsync` test deliberately skips. + // ignore: cancel_subscriptions + final subscription = client.connectionState.listen(states.add); + + return WsClientTester._( + client: client, + server: server, + network: network, + lifecycle: lifecycle, + states: states, + attempts: () => attempts, + tokenLoads: () => tokenLoads, + subscription: subscription, + recovery: recovery, + tokens: tokenManager, + ); +} + +// The handshake a consuming SDK performs: present a token, and replace one the server refused for +// having expired before presenting another. +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(); + + final token = await tokens.getToken(); + send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); + }; +} diff --git a/packages/stream_core/test/query/filter_test.dart b/packages/stream_core/test/query/filter_test.dart index a949ca34..3ad814a2 100644 --- a/packages/stream_core/test/query/filter_test.dart +++ b/packages/stream_core/test/query/filter_test.dart @@ -51,19 +51,6 @@ class TestFilterField extends FilterField { void main() { group('Comparison', () { group('Equal', () { - test('should create equal filter correctly', () { - final field = TestFilterField.name; - const value = 'test'; - - var filter = Filter.equal(field, value); - - expect(filter, isA>()); - filter = filter as EqualOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.equal); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.name; const value = 'test'; @@ -90,19 +77,6 @@ void main() { }); group('Greater', () { - test('should create greater filter correctly', () { - final field = TestFilterField.createdAt; - final value = DateTime(2023); - - var filter = Filter.greater(field, value); - - expect(filter, isA>()); - filter = filter as GreaterOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.greater); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 100; @@ -117,19 +91,6 @@ void main() { }); group('GreaterOrEqual', () { - test('should create greater or equal filter correctly', () { - final field = TestFilterField.id; - const value = 50; - - var filter = Filter.greaterOrEqual(field, value); - - expect(filter, isA>()); - filter = filter as GreaterOrEqualOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.greaterOrEqual); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 50; @@ -144,19 +105,6 @@ void main() { }); group('Less', () { - test('should create less filter correctly', () { - final field = TestFilterField.id; - const value = 100; - - var filter = Filter.less(field, value); - - expect(filter, isA>()); - filter = filter as LessOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.less); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 100; @@ -171,19 +119,6 @@ void main() { }); group('LessOrEqual', () { - test('should create less or equal filter correctly', () { - final field = TestFilterField.id; - const value = 100; - - var filter = Filter.lessOrEqual(field, value); - - expect(filter, isA>()); - filter = filter as LessOrEqualOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.lessOrEqual); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.id; const value = 100; @@ -200,19 +135,6 @@ void main() { group('List', () { group('In', () { - test('should create in filter correctly', () { - final field = TestFilterField.tags; - final values = ['tag1', 'tag2', 'tag3']; - - var filter = Filter.in_(field, values); - - expect(filter, isA>()); - filter = filter as InOperator; - expect(filter.field, field); - expect(filter.value, values); - expect(filter.operator, FilterOperator.in_); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.tags; final values = ['tag1', 'tag2', 'tag3']; @@ -239,19 +161,6 @@ void main() { }); group('Contains', () { - test('should create contains filter correctly', () { - final field = TestFilterField.members; - const value = 'user123'; - - var filter = Filter.contains(field, value); - - expect(filter, isA>()); - filter = filter as ContainsOperator; - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.contains_); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.members; const value = 'user123'; @@ -267,19 +176,6 @@ void main() { }); group('Exists', () { - test('should create exists filter correctly', () { - final field = TestFilterField.metadata; - const exists = true; - - var filter = Filter.exists(field, exists: exists); - - expect(filter, isA>()); - filter = filter as ExistsOperator; - expect(filter.field, field); - expect(filter.exists, exists); - expect(filter.operator, FilterOperator.exists); - }); - test('should serialize to JSON correctly with exists=true', () { final field = TestFilterField.metadata; const exists = true; @@ -307,19 +203,6 @@ void main() { group('Evaluation', () { group('Query', () { - test('should create query filter correctly', () { - final field = TestFilterField.name; - const query = 'search term'; - - var filter = Filter.query(field, query); - - expect(filter, isA>()); - filter = filter as QueryOperator; - expect(filter.field, field); - expect(filter.query, query); - expect(filter.operator, FilterOperator.query); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.name; const query = 'search term'; @@ -334,19 +217,6 @@ void main() { }); group('AutoComplete', () { - test('should create autocomplete filter correctly', () { - final field = TestFilterField.name; - const query = 'prefix'; - - var filter = Filter.autoComplete(field, query); - - expect(filter, isA>()); - filter = filter as AutoCompleteOperator; - expect(filter.field, field); - expect(filter.query, query); - expect(filter.operator, FilterOperator.autoComplete); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.name; const query = 'prefix'; @@ -362,19 +232,6 @@ void main() { }); group('PathExists', () { - test('should create path exists filter correctly', () { - final field = TestFilterField.metadata; - const path = 'nested.field'; - - var filter = Filter.pathExists(field, path); - - expect(filter, isA>()); - filter = filter as PathExistsOperator; - expect(filter.field, field); - expect(filter.path, path); - expect(filter.operator, FilterOperator.pathExists); - }); - test('should serialize to JSON correctly', () { final field = TestFilterField.metadata; const path = 'nested.field'; @@ -390,19 +247,6 @@ void main() { group('Logical', () { group('And', () { - test('should create and filter correctly', () { - final filter1 = Filter.equal(TestFilterField.name, 'test'); - final filter2 = Filter.greater(TestFilterField.id, 100); - final filters = [filter1, filter2]; - - var andFilter = Filter.and(filters); - - expect(andFilter, isA>()); - andFilter = andFilter as AndOperator; - expect(andFilter.filters, filters); - expect(andFilter.operator, FilterOperator.and); - }); - test('should serialize to JSON correctly', () { final filter1 = Filter.equal(TestFilterField.name, 'test'); final filter2 = Filter.greater(TestFilterField.id, 100); @@ -454,19 +298,6 @@ void main() { }); group('Or', () { - test('should create or filter correctly', () { - final filter1 = Filter.equal(TestFilterField.name, 'test'); - final filter2 = Filter.greater(TestFilterField.id, 100); - final filters = [filter1, filter2]; - - var orFilter = Filter.or(filters); - - expect(orFilter, isA>()); - orFilter = orFilter as OrOperator; - expect(orFilter.filters, filters); - expect(orFilter.operator, FilterOperator.or); - }); - test('should serialize to JSON correctly', () { final filter1 = Filter.equal(TestFilterField.name, 'test'); final filter2 = Filter.greater(TestFilterField.id, 100); @@ -559,17 +390,6 @@ void main() { }); }); - test('should handle single item list in In', () { - final filter = Filter.in_(TestFilterField.tags, ['single']); - final json = filter.toJson(); - - expect(json, { - 'tags': { - r'$in': ['single'], - }, - }); - }); - test('should handle empty filters list in Logical', () { const andFilter = Filter.and(>[]); final json = andFilter.toJson(); @@ -578,113 +398,6 @@ void main() { }); }); - group('Type Safety', () { - test('should enforce FilterField type consistency', () { - // This test ensures that the generic type system works correctly - final filter1 = Filter.equal(TestFilterField.name, 'test'); - final filter2 = Filter.greater(TestFilterField.id, 100); - - var logicalFilter = Filter.and([filter1, filter2]); - - expect(logicalFilter, isA>()); - logicalFilter = logicalFilter as AndOperator; - expect(logicalFilter.filters.length, 2); - expect(logicalFilter.filters.elementAt(0), filter1); - expect(logicalFilter.filters.elementAt(1), filter2); - }); - }); - - group('Real-world Usage Examples', () { - test('should create a complex chat channel filter', () { - // Example: Find messaging channels where user is a member, created after a date, and has specific metadata - const userId = 'user123'; - const createdAfter = '2023-01-01T00:00:00Z'; - - final filter = Filter.and( - [ - Filter.equal(TestFilterField.type, 'messaging'), - Filter.contains(TestFilterField.members, userId), - Filter.greater( - TestFilterField.createdAt, - createdAfter, - ), - Filter.exists(TestFilterField.metadata, exists: true), - ], - ); - - final json = filter.toJson(); - - expect(json, { - r'$and': [ - { - 'type': {r'$eq': 'messaging'}, - }, - { - 'members': {r'$contains': 'user123'}, - }, - { - 'created_at': {r'$gt': '2023-01-01T00:00:00Z'}, - }, - { - 'metadata': {r'$exists': true}, - }, - ], - }); - }); - - test('should create a search filter with autocomplete', () { - const searchQuery = 'john'; - - final filter = Filter.or( - [ - Filter.query(TestFilterField.name, searchQuery), - Filter.autoComplete( - TestFilterField.name, - searchQuery, - ), - ], - ); - - final json = filter.toJson(); - - expect(json, { - r'$or': [ - { - 'name': {r'$q': 'john'}, - }, - { - 'name': {r'$autocomplete': 'john'}, - }, - ], - }); - }); - - test('should create a range filter', () { - const minId = 100; - const maxId = 200; - - final filter = Filter.and( - [ - Filter.greaterOrEqual(TestFilterField.id, minId), - Filter.lessOrEqual(TestFilterField.id, maxId), - ], - ); - - final json = filter.toJson(); - - expect(json, { - r'$and': [ - { - 'id': {r'$gte': 100}, - }, - { - 'id': {r'$lte': 200}, - }, - ], - }); - }); - }); - group('Filter.matches()', () { group('Equal', () { test('should match primitive values', () { diff --git a/packages/stream_core/test/user/connect_user_details_request_test.dart b/packages/stream_core/test/user/connect_user_details_request_test.dart new file mode 100644 index 00000000..f77fd92f --- /dev/null +++ b/packages/stream_core/test/user/connect_user_details_request_test.dart @@ -0,0 +1,59 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ConnectUserDetailsRequest.fromUser', () { + test('carries the fields the server accepts from a client', () { + const user = User(id: 'user-1', name: 'Bob', image: 'https://example.com/bob.png', custom: {'plan': 'pro'}); + + final details = ConnectUserDetailsRequest.fromUser(user); + + expect(details.id, 'user-1'); + expect(details.name, 'Bob'); + expect(details.image, 'https://example.com/bob.png'); + expect(details.custom, {'plan': 'pro'}); + }); + + test('sends nothing but the id when the details are excluded', () { + const user = User(id: 'user-1', name: 'Bob', image: 'https://example.com/bob.png', custom: {'plan': 'pro'}); + + final json = ConnectUserDetailsRequest.fromUser(user, includeDetails: false).toJson(); + + // An unset field is left out rather than sent as a null, so the server is not asked to + // distinguish "no opinion" from "clear this". + expect(json, {'id': 'user-1'}); + }); + + test('leaves out the fields the server decides itself', () { + const user = User(id: 'user-1', role: 'admin', teams: ['red']); + + final json = ConnectUserDetailsRequest.fromUser(user).toJson(); + + // Neither is accepted from a client, so sending them is pointless. + expect(json, isNot(contains('role'))); + expect(json, isNot(contains('teams'))); + }); + + test('sends the id alone when details are excluded', () { + const user = User(id: 'user-1', name: 'Bob', image: 'https://example.com/bob.png', custom: {'plan': 'pro'}); + + final details = ConnectUserDetailsRequest.fromUser(user, includeDetails: false); + + expect(details.id, 'user-1'); + expect(details.name, isNull); + expect(details.image, isNull); + expect(details.custom, isNull); + }); + + test('reports the name the user was created with, not the id fallback', () { + // `User.name` falls back to the id; the wire form must not, or a user with no name would be + // given the id as one. + const user = User(id: 'user-1'); + + final details = ConnectUserDetailsRequest.fromUser(user); + + expect(user.name, 'user-1'); + expect(details.name, isNull); + }); + }); +} diff --git a/packages/stream_core/test/utils/result_test.dart b/packages/stream_core/test/utils/result_test.dart new file mode 100644 index 00000000..02934abe --- /dev/null +++ b/packages/stream_core/test/utils/result_test.dart @@ -0,0 +1,112 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Result.getOrElse', () { + test('returns the value when the fallback only throws', () { + const result = Result.success(42); + + // The value used to be cast to the fallback's return type, here `Never`. + final value = result.getOrElse((_, _) => throw StateError('unreachable')); + + expect(value, 42); + }); + + test('throws what the fallback throws, so an error can be reworded', () { + final result = Result.failure(Exception('original')); + + expect(() => result.getOrElse((error, _) => throw StateError('$error')), throwsA(isA())); + }); + + test('returns what the fallback returns', () { + final result = Result.failure(Exception('original')); + + expect(result.getOrElse((_, _) => 0), 0); + }); + + test('hands the fallback the error and its stack trace', () { + final stackTrace = StackTrace.current; + final error = Exception('original'); + final result = Result.failure(error, stackTrace); + + Object? seenError; + StackTrace? seenStackTrace; + result.getOrElse((error, stackTrace) { + seenError = error; + seenStackTrace = stackTrace; + return 0; + }); + + expect(seenError, error); + expect(seenStackTrace, stackTrace); + }); + }); + + group('Result widening', () { + test('falls back to a supertype when the result is widened', () { + // Naming the wider type on the result widens the fallback with it, because `Result` is + // covariant. + final Result widened = Result.failure(Exception('failed')); + + expect(widened.getOrElse((_, _) => 0.5), 0.5); + expect(widened.getOrDefault(0.5), 0.5); + expect(widened.recover((_, _) => 0.5).getOrNull(), 0.5); + }); + }); + + group('Result.getOrDefault', () { + test('returns the value when there is one', () { + const result = Result.success(42); + + expect(result.getOrDefault(0), 42); + }); + + test('returns the default when there is not', () { + final result = Result.failure(Exception('failed')); + + expect(result.getOrDefault(0), 0); + }); + }); + + group('Result.recover', () { + test('keeps the value when the transform only throws', () { + const result = Result.success(42); + + // Same cast as `getOrElse`. + final recovered = result.recover((_, _) => throw StateError('unreachable')); + + expect(recovered.getOrNull(), 42); + }); + + test('turns a failure into the value the transform returns', () { + final result = Result.failure(Exception('failed')); + + expect(result.recover((_, _) => 0).getOrNull(), 0); + }); + + test('rethrows an error from the transform', () { + final result = Result.failure(Exception('failed')); + + expect(() => result.recover((_, _) => throw StateError('while recovering')), throwsA(isA())); + }); + }); + + group('Result.recoverCatching', () { + test('keeps the value when the transform only throws', () { + const result = Result.success(42); + + final recovered = result.recoverCatching((_, _) => throw StateError('unreachable')); + + expect(recovered.getOrNull(), 42); + }); + + test('reports an error from the transform as a failure', () { + final result = Result.failure(Exception('failed')); + + final recovered = result.recoverCatching((_, _) => throw StateError('while recovering')); + + // Unlike `recover`, the error replaces the original rather than escaping. + expect(recovered.exceptionOrNull(), isA()); + }); + }); +} 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 new file mode 100644 index 00000000..b409f049 --- /dev/null +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -0,0 +1,301 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/web_socket.dart'; + +/// A codec that passes strings through untouched. +class _StringCodec implements WebSocketMessageCodec { + const _StringCodec(); + + @override + Object encode(String message) => message; + + @override + String decode(Object message) => message.toString(); +} + +/// Records what the engine reported, in the order it reported it. +class _RecordingListener implements WebSocketEngineListener { + final closures = <({int? code, String? reason})>[]; + final messages = []; + int get opened => _opened; + var _opened = 0; + + @override + void onOpen() => _opened++; + + @override + void onMessage(String message) => messages.add(message); + + @override + void onError(Object error, [StackTrace? stackTrace]) {} + + @override + void onClose([int? closeCode, String? closeReason]) { + closures.add((code: closeCode, reason: closeReason)); + } +} + +/// Builds an engine over a socket a test drives. +/// +/// Pass [closeFails] for a socket that refuses to close. +({ + StreamWebSocketEngine engine, + _RecordingListener listener, + FakeWebSocketChannel socket, +}) +_subject({bool closeFails = false}) { + final socket = FakeWebSocketChannel( + closeError: closeFails ? Exception('close failed') : null, + ); + addTearDown(socket.endStream); + + final listener = _RecordingListener(); + final engine = StreamWebSocketEngine( + wsProvider: (_) => socket, + listener: listener, + messageCodec: const _StringCodec(), + ); + + return (engine: engine, listener: listener, socket: socket); +} + +/// Builds an engine that opens a fresh socket each time, for a test about more than one of them. +/// +/// [_subject] reuses one socket, so a second `open` there fails on a stream already listened to +/// rather than on anything the engine decided. +({ + StreamWebSocketEngine engine, + _RecordingListener listener, + List sockets, +}) +_subjectWithFreshSockets({bool holdFirstClose = false}) { + final sockets = []; + final listener = _RecordingListener(); + + final engine = StreamWebSocketEngine( + listener: listener, + messageCodec: const _StringCodec(), + wsProvider: (_) { + final socket = FakeWebSocketChannel(holdClose: holdFirstClose && sockets.isEmpty); + sockets.add(socket); + return socket; + }, + ); + + addTearDown(() { + for (final socket in sockets) { + socket.endStream(); + } + }); + + return (engine: engine, listener: listener, sockets: sockets); +} + +const _options = WebSocketOptions(url: 'wss://example.com'); + +void main() { + test('reports the closure with the code and reason it was asked for', () async { + final (:engine, :listener, socket: _) = _subject(); + await engine.open(_options); + + final result = await engine.close(CloseCode.normalClosure, 'done'); + + expect(result.isSuccess, isTrue); + expect(listener.closures, [(code: CloseCode.normalClosure, reason: 'done')]); + }); + + test('refuses to send once closed', () async { + final (:engine, listener: _, socket: _) = _subject(); + await engine.open(_options); + + await engine.close(); + + expect(engine.sendMessage('ping').isFailure, isTrue); + }); + + test('reports the closure once, however many ways it hears about it', () async { + final (:engine, :listener, socket: _) = _subject(); + await engine.open(_options); + + await engine.close(CloseCode.normalClosure, 'done'); + // Long enough for a stream that ended to have reported it. + await pumpEventQueue(); + + // The socket ending is the same closure, not a second one: a listener told twice would treat + // the echo as a fresh disconnection. + expect(listener.closures, hasLength(1)); + }); + + test('reports a socket that failed to close as a failure, not a closure', () async { + final (:engine, :listener, socket: _) = _subject(closeFails: true); + await engine.open(_options); + + final result = await engine.close(CloseCode.normalClosure, 'done'); + + // Nothing closed, so nothing is announced. The caller hears the failure instead and decides + // what to tell anyone waiting. + expect(result.isFailure, isTrue); + expect(listener.closures, isEmpty); + }); + + test('delivers nothing from a socket it has closed', () async { + // A close that fails leaves the socket's stream running, which is what a message arriving after + // the closure needs to come from. + final (:engine, :listener, :socket) = _subject(closeFails: true); + await engine.open(_options); + + await engine.close(); + socket.emit('late'); + await pumpEventQueue(); + + // Delivered, it would arrive on a connection its listener has already been told is closed. + expect(listener.messages, isEmpty); + }); + + test('lets go of a socket that failed to close', () async { + final (:engine, listener: _, socket: _) = _subject(closeFails: true); + await engine.open(_options); + + await engine.close(); + + // A socket that refuses to close is not one this engine can use. Held on to, it would refuse + // every later open, leaving no way to replace it. + expect(engine.sendMessage('ping').isFailure, isTrue); + }); + + test('reports the closure when there was no connection to close', () async { + final (:engine, :listener, socket: _) = _subject(); + + final result = await engine.close(CloseCode.normalClosure, 'done'); + + // The caller asked for a closed connection and has one. Staying silent would leave whoever is + // waiting on the closure waiting for a report that never comes. + expect(result.isSuccess, isTrue); + expect(listener.closures, [(code: CloseCode.normalClosure, reason: 'done')]); + }); + + test('does not report the closure of a socket that has already been replaced', () async { + // Only the first socket holds its close, so it is still closing when the next one opens. + final (:engine, :listener, :sockets) = _subjectWithFreshSockets(holdFirstClose: true); + + await engine.open(_options); + final closing = engine.close(); + await pumpEventQueue(); + await engine.open(_options); + + sockets.first.sink.completeClose(); + await closing; + await pumpEventQueue(); + + // Closing yields, so the second socket is already open by the time the first one finishes. + // Reported now, its closure would bring down a connection that is being established. + expect(listener.opened, 2); + expect(listener.closures, isEmpty); + }); + + test('reports the closure for every close it is asked for', () async { + final (:engine, :listener, socket: _) = _subject(); + await engine.open(_options); + + await engine.close(CloseCode.normalClosure, 'first'); + await engine.close(CloseCode.normalClosure, 'second'); + + expect(listener.closures, [ + (code: CloseCode.normalClosure, reason: 'first'), + (code: CloseCode.normalClosure, reason: 'second'), + ]); + }); + + test('reports the socket as open', () async { + final (:engine, :listener, socket: _) = _subject(); + + final result = await engine.open(_options); + + expect(result.isSuccess, isTrue); + expect(listener.opened, 1); + }); + + test('refuses to open a socket while one is still open', () async { + 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); + expect(sockets, hasLength(1)); + expect(sockets.single.sink.closedWith, isNull); + expect(listener.closures, isEmpty); + }); + + test('reports a socket that ends on its own as closed', () async { + final (:engine, :listener, :socket) = _subject(); + await engine.open(_options); + + socket.endStream(); + await pumpEventQueue(); + + // A server that hangs up produces no close call, only a stream that ends. + expect(listener.closures, hasLength(1)); + }); + + test('refuses to send before a socket is open', () { + final (:engine, listener: _, socket: _) = _subject(); + + expect(engine.sendMessage('ping').isFailure, isTrue); + }); + + test('encodes what it sends', () async { + final (:engine, listener: _, :socket) = _subject(); + await engine.open(_options); + + final result = engine.sendMessage('ping'); + + expect(result.isSuccess, isTrue); + expect(socket.sink.sent, ['ping']); + }); + + test('decodes what it receives', () async { + final (:engine, :listener, :socket) = _subject(); + await engine.open(_options); + + socket.emit('pong'); + await pumpEventQueue(); + + expect(listener.messages, ['pong']); + }); + + test('drops a message it cannot decode, rather than reporting it', () async { + final socket = FakeWebSocketChannel(); + addTearDown(socket.endStream); + + final listener = _RecordingListener(); + final engine = StreamWebSocketEngine( + wsProvider: (_) => socket, + listener: listener, + messageCodec: const _ThrowingCodec(), + ); + await engine.open(_options); + + socket.emit('garbage'); + await pumpEventQueue(); + + // A frame the codec rejects says nothing about the connection, so it is not worth ending one + // over. + expect(listener.messages, isEmpty); + expect(listener.closures, isEmpty); + }); +} + +/// A codec that cannot decode anything, as one meeting an unknown frame cannot. +class _ThrowingCodec implements WebSocketMessageCodec { + const _ThrowingCodec(); + + @override + Object encode(String message) => message; + + @override + String decode(Object message) => throw const FormatException('undecodable'); +} 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 new file mode 100644 index 00000000..f6ee932a --- /dev/null +++ b/packages/stream_core/test/ws/client/engine/web_socket_engine_test.dart @@ -0,0 +1,55 @@ +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/reconnect/automatic_reconnection_policy_test.dart b/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart new file mode 100644 index 00000000..6fe03e3e --- /dev/null +++ b/packages/stream_core/test/ws/client/reconnect/automatic_reconnection_policy_test.dart @@ -0,0 +1,143 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/ws_client_tester.dart'; + +/// A policy with a fixed answer, for testing how policies combine. +class _Fixed implements AutomaticReconnectionPolicy { + const _Fixed({required this._answer}); + + final bool _answer; + + @override + bool canBeReconnected() => _answer; +} + +void main() { + group('the connection state policy', () { + /// Builds the policy over a state a test controls. + ({WebSocketAutomaticReconnectionPolicy policy, MutableStateEmitter state}) subject() { + final state = MutableStateEmitter(const Initialized()); + addTearDown(state.close); + + return ( + policy: WebSocketAutomaticReconnectionPolicy(connectionState: state), + state: state, + ); + } + + test('allows a reconnection after a drop the client did not ask for', () { + final (:policy, :state) = subject(); + + state.value = const Disconnected(source: SystemInitiated()); + + expect(policy.canBeReconnected(), isTrue); + }); + + test('refuses one after a disconnect the caller asked for', () { + final (:policy, :state) = subject(); + + state.value = const Disconnected(source: UserInitiated()); + + expect(policy.canBeReconnected(), isFalse); + }); + + test('refuses one while a connection is still being made', () { + final (:policy, :state) = subject(); + + // A second attempt on top of one already running would leave a socket nothing closes. + state.value = const Connecting(); + expect(policy.canBeReconnected(), isFalse); + + state.value = const Authenticating(); + expect(policy.canBeReconnected(), isFalse); + }); + }); + + group('the internet availability policy', () { + test('allows a reconnection while the network is up', () { + final network = TestNetworkStateProvider(); + addTearDown(network.close); + + expect(InternetAvailabilityReconnectionPolicy(networkState: network.state).canBeReconnected(), isTrue); + }); + + test('refuses one while the network is down', () { + final network = TestNetworkStateProvider(NetworkState.disconnected); + addTearDown(network.close); + + // An attempt made with no network fails immediately and spends a backoff step for nothing. + expect(InternetAvailabilityReconnectionPolicy(networkState: network.state).canBeReconnected(), isFalse); + }); + + test('refuses one before the network has been looked at', () { + final network = TestNetworkStateProvider(NetworkState.unknown); + addTearDown(network.close); + + // Not yet known is not the same as available, and guessing costs an attempt. + expect(InternetAvailabilityReconnectionPolicy(networkState: network.state).canBeReconnected(), isFalse); + }); + }); + + group('the background state policy', () { + test('allows a reconnection while the app is in use', () { + final lifecycle = TestLifecycleStateProvider(); + addTearDown(lifecycle.close); + + expect(BackgroundStateReconnectionPolicy(appLifecycleState: lifecycle.state).canBeReconnected(), isTrue); + }); + + test('refuses one while the app is put away', () { + final lifecycle = TestLifecycleStateProvider(LifecycleState.background); + addTearDown(lifecycle.close); + + // Nobody is looking at it, so a connection would cost battery for nothing. + expect(BackgroundStateReconnectionPolicy(appLifecycleState: lifecycle.state).canBeReconnected(), isFalse); + }); + + test('refuses one before the lifecycle has been looked at', () { + final lifecycle = TestLifecycleStateProvider(LifecycleState.unknown); + addTearDown(lifecycle.close); + + expect(BackgroundStateReconnectionPolicy(appLifecycleState: lifecycle.state).canBeReconnected(), isFalse); + }); + }); + + group('policies combined', () { + test('with `and`, one refusal is enough to stop a reconnection', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.and, + policies: const [_Fixed(answer: true), _Fixed(answer: false), _Fixed(answer: true)], + ); + + expect(policy.canBeReconnected(), isFalse); + }); + + test('with `and`, every policy has to agree', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.and, + policies: const [_Fixed(answer: true), _Fixed(answer: true)], + ); + + expect(policy.canBeReconnected(), isTrue); + }); + + test('with `or`, one policy is enough to allow a reconnection', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.or, + policies: const [_Fixed(answer: false), _Fixed(answer: true)], + ); + + expect(policy.canBeReconnected(), isTrue); + }); + + test('with `or`, every policy has to refuse to stop one', () { + final policy = CompositeReconnectionPolicy( + operator: Operator.or, + policies: const [_Fixed(answer: false), _Fixed(answer: false)], + ); + + expect(policy.canBeReconnected(), isFalse); + }); + }); +} diff --git a/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart new file mode 100644 index 00000000..d44779bf --- /dev/null +++ b/packages/stream_core/test/ws/client/reconnect/connection_recovery_handler_test.dart @@ -0,0 +1,384 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/user_token.dart'; +import '../../../helpers/ws_client_tester.dart'; + +/// Long enough for a health check to go unanswered, which is the drop this handler recovers from. +/// +/// The monitor pings every 25 seconds and gives the peer 3 to answer. +const _untilUnhealthy = Duration(seconds: 29); + +void main() { + test('does not retry a first attempt that never connected', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + // A server that takes the credentials and never answers, so the attempt is abandoned. + tester.server.onFrame = (_) => []; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // Retrying here would work behind a caller already told it failed. + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 1); + }); + }); + + test('retries a connection that dropped after being established', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The server stops answering health checks, which is a drop rather than a failed attempt, so + // recovering it is this handler's job. The first retry carries no delay. + tester.server.onFrame = (_) => []; + async.elapse(_untilUnhealthy); + async.flushMicrotasks(); + + expect(tester.attempts, 2); + expect(tester.states.whereType(), hasLength(2)); + }); + }); + + test('keeps recovering after a retry fails its handshake', () { + fakeAsync((async) { + var handshakeFails = false; + final tester = buildTester(recover: true, handshakeFailsWhen: () => handshakeFails); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The connection drops, and no retry can upgrade from here. + handshakeFails = true; + tester.server.onFrame = (_) => []; + async.elapse(_untilUnhealthy); + async.flushMicrotasks(); + + final afterDrop = tester.attempts; + expect(afterDrop, greaterThan(1)); + + // Recorded as the deliberate close the engine defaults to, a retry that failed to upgrade + // would have ended the recovery it was part of, leaving the connection down for good. + async.elapse(const Duration(minutes: 1)); + async.flushMicrotasks(); + expect(tester.attempts, greaterThan(afterDrop)); + }); + }); + + test('hands connecting back after the caller disconnected', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + + // A fresh attempt, awaited by whoever made it, that never connects. + tester.server.onFrame = (_) => []; + tester.client.connect().ignore(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + + // Having connected in a previous session does not make this failure the handler's to retry. + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 2); + }); + }); + + test('stops a retry the caller called off while it was pending', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // A closure worth retrying, so one is scheduled. + tester.server.hangUp(); + async.flushMicrotasks(); + + // The caller says stop while that retry is still pending. Asking to disconnect a connection + // that is already down is asking for whatever is pending on its behalf to stop too. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + + async.elapse(const Duration(minutes: 1)); + + expect(tester.attempts, 1); + }); + }); + + test('cancels a retry it had already scheduled when the caller disconnects', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The server hangs up, which schedules a retry. + tester.server.hangUp(); + async.flushMicrotasks(); + expect(async.pendingTimers, isNotEmpty); + + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + + // Nothing is left armed. A timer that outlives the caller's disconnect fires against a client + // they have closed, and reconnects it behind them. + expect(async.pendingTimers, isEmpty); + }); + }); + + test('stops retrying once an authenticator gives up', () { + fakeAsync((async) { + final loaded = Completer(); + var attempts = 0; + final tester = buildTester( + recover: true, + // The first attempt authenticates. Every one after it waits on credentials that never + // arrive, and then reports that there are none to send. + authenticator: (send, _) async { + if (++attempts > 1) { + await loaded.future; + throw StateError('nothing left to offer'); + } + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // Dropped, so this handler retries. That attempt hangs and is abandoned by the connect + // timeout, which schedules another. + tester.server.hangUp(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + final retried = tester.attempts; + + loaded.complete(); + async.flushMicrotasks(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + + async.elapse(const Duration(minutes: 2)); + + // Credentials the authenticator has given up on cannot be repaired by trying again, so a + // retry already scheduled must not go ahead either. + expect(tester.attempts, retried); + }); + }); + + test('hands connecting back after a closure it will not act on', () { + fakeAsync((async) { + var calls = 0; + final tester = buildTester( + recover: true, + // The retry this handler makes gives up on authentication, which is not reconnected. + authenticator: (send, _) async { + if (++calls == 2) throw StateError('nothing left to offer'); + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + tester.server.hangUp(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + + // A fresh attempt, awaited by whoever made it, that never connects. + tester.server.onFrame = (_) => []; + tester.client.connect().ignore(); + async.flushMicrotasks(); + final made = tester.attempts; + async.elapse(WebSocketOptions.defaultConnectTimeout); + async.flushMicrotasks(); + + // Having connected before the closure this handler stopped at does not make this failure its + // to retry: the caller made this attempt and was handed the outcome. + async.elapse(const Duration(minutes: 2)); + expect(tester.attempts, made); + }); + }); + + test('does not retry a disconnect the caller asked for', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // Having been connected is not enough on its own: the source says this was deliberate. + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 1); + }); + }); + + group('while the network is down', () { + test('does not retry a drop', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.network.disconnect(); + async.flushMicrotasks(); + + // Losing the network takes the connection down, and there is nothing to reconnect to. + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 1); + }); + }); + + test('retries as soon as it comes back', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.network.disconnect(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + tester.network.connect(); + async.flushMicrotasks(); + + // The network returning is the triggering event, so nothing waits out a backoff for it. + expect(tester.attempts, 2); + expect(tester.connectionState, isA()); + }); + }); + }); + + group('while the app is in the background', () { + test('takes the connection down and leaves it down', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.lifecycle.background(); + async.flushMicrotasks(); + + // A connection nobody is looking at costs battery for nothing. + expect(tester.connectionState, isA()); + + async.elapse(const Duration(minutes: 1)); + expect(tester.attempts, 1); + }); + }); + + test('reconnects when the app is opened again', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.lifecycle.background(); + async.flushMicrotasks(); + + tester.lifecycle.foreground(); + async.flushMicrotasks(); + + expect(tester.attempts, 2); + expect(tester.connectionState, isA()); + }); + }); + }); + + group('a policy the app supplied', () { + test('can refuse a reconnection the built-in policies would allow', () { + fakeAsync((async) { + // No handler of its own, so this test owns the one it is testing. + final tester = buildTester(); + ConnectionRecoveryHandler( + client: tester.client, + policies: [const _Refuses()], + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // A drop that would otherwise be recovered from. + tester.server.hangUp(); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 1)); + + // Policies are combined with `and`, so an app can veto a reconnection the client is + // otherwise happy to make. + expect(tester.attempts, 1); + }); + }); + + test('is consulted alongside them rather than replacing them', () { + fakeAsync((async) { + final tester = buildTester(); + ConnectionRecoveryHandler( + client: tester.client, + policies: [const _Allows()], + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // A disconnect the caller asked for, which a built-in policy refuses. An app policy that + // allows must not override that. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 1)); + + expect(tester.attempts, 1); + }); + }); + }); +} + +/// An app policy that never wants a reconnection. +class _Refuses implements AutomaticReconnectionPolicy { + const _Refuses(); + + @override + bool canBeReconnected() => false; +} + +/// An app policy that always wants one. +class _Allows implements AutomaticReconnectionPolicy { + const _Allows(); + + @override + bool canBeReconnected() => true; +} diff --git a/packages/stream_core/test/ws/client/reconnect/retry_strategy_test.dart b/packages/stream_core/test/ws/client/reconnect/retry_strategy_test.dart new file mode 100644 index 00000000..1600e405 --- /dev/null +++ b/packages/stream_core/test/ws/client/reconnect/retry_strategy_test.dart @@ -0,0 +1,81 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('retries the first time without waiting', () { + final strategy = RetryStrategy(); + + // A connection that has just dropped is most likely to come straight back, and a delay here is + // felt by whoever is looking at the screen. + expect(strategy.getNextRetryDelay(), Duration.zero); + }); + + test('waits longer the more attempts have failed', () { + final strategy = RetryStrategy(); + + final delays = []; + for (var i = 0; i < 6; i++) { + delays.add(strategy.getDelayAfterTheFailure()); + } + + // Not strictly increasing, because each delay is drawn from a range, but the ranges climb: a + // server that is struggling is asked less and less often. + expect(delays.first, Duration.zero); + expect(delays.last, greaterThan(delays[1])); + expect(delays, everyElement(lessThanOrEqualTo(const Duration(seconds: 25)))); + }); + + test('never waits longer than the ceiling', () { + final strategy = RetryStrategy(); + + for (var i = 0; i < 100; i++) { + strategy.incrementConsecutiveFailures(); + } + + // Without a ceiling a long outage would push the next attempt hours away, and the connection + // would not come back for a user who is waiting on it. + expect( + strategy.getNextRetryDelay(), + lessThanOrEqualTo(const Duration(seconds: DefaultRetryStrategy.maximumReconnectionDelayInSeconds)), + ); + }); + + test('spreads the delay across a range, so clients do not retry in lockstep', () { + // Every client that dropped when a server went down would otherwise come back at the same + // instant and take it down again. + final delays = {}; + for (var i = 0; i < 200; i++) { + final strategy = RetryStrategy(); + for (var f = 0; f < 5; f++) { + strategy.incrementConsecutiveFailures(); + } + delays.add(strategy.getNextRetryDelay()); + } + + expect(delays, hasLength(greaterThan(1))); + }); + + test('counts the failures it has seen', () { + final strategy = RetryStrategy(); + expect(strategy.consecutiveFailuresCount, 0); + + strategy.getDelayAfterTheFailure(); + strategy.getDelayAfterTheFailure(); + + expect(strategy.consecutiveFailuresCount, 2); + }); + + test('starts over once a connection is established', () { + final strategy = RetryStrategy(); + for (var i = 0; i < 5; i++) { + strategy.getDelayAfterTheFailure(); + } + + strategy.resetConsecutiveFailures(); + + // The backoff a previous outage built up does not apply to the next one, which starts with the + // immediate retry a fresh drop deserves. + expect(strategy.consecutiveFailuresCount, 0); + expect(strategy.getNextRetryDelay(), Duration.zero); + }); +} 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 new file mode 100644 index 00000000..77b28916 --- /dev/null +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -0,0 +1,1304 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../helpers/fake_server.dart'; +import '../../helpers/user_token.dart'; +import '../../helpers/ws_client_tester.dart'; + +/// A connect that is expected not to establish anything, so nothing is asserted about the outcome. +Future _justConnect(WsClientTester tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); +} + +void main() { + group('connect', () { + wsClientTest( + 'reports a handshake that failed as closed', + handshakeFails: true, + connect: _justConnect, + body: (tester) { + // Nothing else reports it: a socket that never opened has no closure of its own to deliver, + // so the client would otherwise be left connecting for good. + expect(tester.connectionState, isA()); + }, + ); + + wsClientTest( + 'reports a handshake that failed with the reason it failed', + handshakeFails: true, + connect: _justConnect, + body: (tester) { + // Reported with its cause, so the closure stays reconnectable. Closed without one it would + // read as the deliberate close the engine defaults to, which never is. + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error?.error, 'error', isNotNull), + ), + ); + expect(tester.connectionState.isAutomaticReconnectionEnabled, isTrue); + }, + ); + + wsClientTest( + 'lets a caller connect again straight after a failed handshake', + handshakeFails: true, + connect: (_) {}, + body: (tester) async { + // Nothing is pumped between the two calls, so `connect` has to finish abandoning the first + // attempt before it returns; otherwise the second is refused for racing a close still + // under way, and the caller is left with a connection nobody is trying to make. + await tester.client.connect(); + await tester.client.connect(); + + expect(tester.attempts, 2); + }, + ); + + wsClientTest( + 'reports the closure when the socket of a failed handshake refuses to close', + handshakeFails: true, + closeError: Exception('close failed'), + connect: _justConnect, + body: (tester) async { + // The engine announces nothing when a close fails, and the connect timeout cannot rescue a + // state that is already `Disconnecting`, so this would stay there for good. + expect(tester.connectionState, isA()); + + await tester.client.connect(); + await tester.pumpEventQueue(); + expect(tester.attempts, 2); + }, + ); + + wsClientTest( + 'ignores a handshake that finishes after the connection was closed', + handshakeHangs: true, + connect: (_) {}, + body: (tester) async { + tester.client.connect().ignore(); + await tester.pumpEventQueue(); + + await tester.client.disconnect(); + await tester.pumpEventQueue(); + expect(tester.connectionState, isA().having((it) => it.source, 'source', isA())); + + // Acted on, it would authenticate a socket the client has let go of, and failing to send + // would relabel the closure as `AuthenticationFailed`. That costs a caller their own reason + // here; on the connect-timeout path it turns a reconnectable `ConnectTimeout` into a state + // that never reconnects. + tester.server.sockets.last.completeReady(); + await tester.pumpEventQueue(); + + expect(tester.connectionState, isA().having((it) => it.source, 'source', isA())); + }, + ); + + wsClientTest( + 'closes a socket whose handshake failed', + handshakeFails: true, + connect: _justConnect, + body: (tester) { + // The socket is opened before the handshake it fails, so reporting closed without closing + // left one open that nothing could reach — not even `dispose`. + expect(tester.server.socket.sink.closedWith, isNotNull); + }, + ); + + wsClientTest( + 'leaves no socket behind for a later attempt to close', + handshakeFails: true, + connect: _justConnect, + body: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + + // Each attempt closes its own socket. A socket left behind is closed by the next attempt + // instead, which reports a closure while that attempt is still connecting. + expect(tester.server.sockets, hasLength(2)); + expect(tester.server.sockets.every((it) => it.sink.closedWith != null), isTrue); + }, + ); + + wsClientTest( + 'builds the options for every connection attempt, not once per client', + body: (tester) async { + expect(tester.attempts, 1); + + await tester.client.disconnect(); + await tester.client.connect(); + await tester.pumpEventQueue(); + + expect(tester.attempts, 2); + }, + ); + + wsClientTest( + 'reports the connection established only once the server answers', + connect: (tester) async { + // A server that accepts the credentials but has not replied yet. + tester.server.onFrame = (_) => []; + + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + // `connect` completes when the socket opens, well before the connection is usable, which is + // why an app watches the state rather than awaiting the call. + expect(tester.connectionState, isA()); + + await tester.emit({'type': 'connection.ok', 'connection_id': 'connection-id'}); + + expect(tester.connectionState, isA()); + expect( + tester.states.map((it) => it.runtimeType), + containsAllInOrder([Initialized, Connecting, Authenticating, Connected]), + ); + }, + ); + + wsClientTest( + 'carries the connection id the server issued', + connect: (tester) async { + tester.server.onFrame = (_) => []; + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + await tester.emit({'type': 'connection.ok', 'connection_id': 'a-real-connection'}); + + // The id is what later pings are stamped with, so it has to survive the wire. + expect( + tester.connectionState, + isA().having( + (it) => it.healthCheck.connectionId, + 'healthCheck.connectionId', + 'a-real-connection', + ), + ); + }, + ); + }); + + group('authenticate', () { + wsClientTest( + 'presents credentials once the socket is open, while authenticating', + authenticator: (send, _) async { + send(const WsAuthMessageRequest(token: 'token')).getOrThrow(); + }, + connect: (tester) async { + WebSocketConnectionState? whenPresented; + tester.server.onFrame = (_) { + whenPresented = tester.connectionState; + return []; + }; + + await tester.client.connect(); + await tester.pumpEventQueue(); + + // The socket is open but the connection is not usable until the server answers. + expect(whenPresented, isA()); + }, + body: (tester) { + expect(tester.connectionState, isA()); + }, + ); + + wsClientTest( + 'authenticates once per connection attempt', + body: (tester) async { + expect(tester.server.received, hasLength(1)); + + await tester.client.disconnect(); + await tester.client.connect(); + await tester.pumpEventQueue(); + + // Each attempt presents credentials of its own; the server has now seen two handshakes. + expect(tester.server.received, hasLength(2)); + expect(tester.connectionState, isA()); + }, + ); + + wsClientTest( + 'puts the credentials it was given on the wire', + tokenLoader: (userId) async => generateTestUserToken(userId), + body: (tester) { + // Really serialised and really read by the server, rather than handed over in memory. + final handshake = tester.server.received.single; + expect(handshake['token'], isA()); + expect(tester.tokenLoads, 1); + }, + ); + + wsClientTest( + 'stays authenticating when there is nothing to authenticate with', + authenticates: false, + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + // Nothing was sent, so the server has nothing to answer and the connection waits. + expect(tester.server.received, isEmpty); + expect(tester.connectionState, isA()); + + await tester.emit({'type': 'connection.ok', 'connection_id': 'connection-id'}); + expect(tester.connectionState, isA()); + }, + ); + + 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 = []; + return ( + authenticator: (send, previousError) async { + seen.add(previousError); + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + seen: seen, + ); + } + + test('is what the server closed the previous attempt with', () async { + final (:authenticator, :seen) = watching(); + final tester = buildTester(authenticator: authenticator); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + await tester.emit(expiredTokenFrame()); + await tester.client.connect(); + 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)]); + }); + + test('is handed on even when the closure is not one to reconnect from', () async { + final (:authenticator, :seen) = watching(); + final tester = buildTester(authenticator: authenticator); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + // A refusal no other token repairs, so nothing reconnects on the client's own initiative. + await tester.emit(invalidSignatureFrame()); + expect(tester.connectionState.isAutomaticReconnectionEnabled, isFalse); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + // 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)]); + }); + + test('is absent once a connection has been established', () async { + final (:authenticator, :seen) = watching(); + final tester = buildTester(authenticator: authenticator); + + await tester.client.connect(); + await tester.pumpEventQueue(); + await tester.emit(expiredTokenFrame()); + + // The replacement is accepted, so the refusal is spent. + await tester.client.connect(); + await tester.pumpEventQueue(); + expect(tester.connectionState, isA()); + + await tester.client.disconnect(); + await tester.client.connect(); + await tester.pumpEventQueue(); + + expect(seen, [null, isA(), null]); + }); + + test('is absent after a closure the server did not cause', () async { + final seen = []; + final tester = buildTester( + // Declines, the way an authenticator with nothing left to offer does, which closes the + // connection as `AuthenticationFailed`. + authenticator: (send, previousError) async { + seen.add(previousError); + if (previousError != null) throw StateError('nothing to offer'); + send(WsAuthMessageRequest(token: generateTestUserToken('luke_skywalker').rawValue)).getOrThrow(); + }, + ); + + await tester.client.connect(); + await tester.pumpEventQueue(); + await tester.emit(expiredTokenFrame()); + + await tester.client.connect(); + await tester.pumpEventQueue(); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + + // A refusal left armed through that would decline this attempt too, without the credentials + // it carries ever being sent. + await tester.client.connect(); + await tester.pumpEventQueue(); + + expect(seen, [null, isA(), null]); + }); + }); + + group('when authentication fails', () { + wsClientTest( + 'closes the connection instead of waiting for a reply', + authenticator: (_, _) async => throw StateError('no token'), + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error, 'error', isStateError), + ), + ); + }, + ); + + wsClientTest( + 'does not retry, since the same credentials would fail again', + authenticator: (_, _) async => throw StateError('no token'), + recover: true, + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + expect(tester.connectionState.isAutomaticReconnectionEnabled, isFalse); + + // Nothing was sent, so there is nothing for a retry to build on. + expect(tester.server.received, isEmpty); + expect(tester.attempts, 1); + }, + ); + + wsClientTest( + 'reports a refusal the server sent, carrying what it said', + connect: (tester) async { + // The credentials reach the server and it refuses them, rather than the client failing to + // present any. + tester.server.onFrame = (_) => [invalidSignatureFrame()]; + + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) { + // The credentials went out and were answered, so this is the server's refusal rather than + // a failure to present them. + expect(tester.server.received, hasLength(1)); + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having( + (it) => it.error?.apiError?.code, + 'apiError.code', + 43, + ), + ), + ); + + // No other token repairs a signature the server rejected. + expect(tester.connectionState.isAutomaticReconnectionEnabled, isFalse); + }, + ); + }); + + group('when an attempt is abandoned while authenticating', () { + test('does not send its credentials over the connection that replaced it', () { + fakeAsync((async) { + final loaded = Completer(); + var calls = 0; + final tester = buildTester( + // Only the first attempt waits, so the token it is eventually handed belongs to a + // connection that has since been abandoned. + authenticator: (send, _) async { + if (++calls > 1) return; + await loaded.future; + send(const WsAuthMessageRequest(token: 'stale')).getOrThrow(); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + final abandoned = tester.server.socket; + + async.elapse(WebSocketOptions.defaultConnectTimeout); + expect(tester.connectionState, isA()); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + final replacement = tester.server.socket; + expect(replacement, isNot(abandoned)); + + loaded.complete(); + async.flushMicrotasks(); + + // The sender writes to whichever socket the engine holds, so credentials loaded for the + // abandoned attempt land on the one that replaced it and never asked for them. + expect(tester.server.received, isEmpty); + }); + }); + + test('records the failure when nothing replaced it, so it is not reconnected', () { + fakeAsync((async) { + // A token load that outlives the attempt the timeout abandoned and then fails. Nothing has + // replaced that attempt, so this failure is the last word on it. + final loaded = Completer(); + final tester = buildTester( + authenticator: (_, _) async { + await loaded.future; + throw StateError('token load failed'); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + expect(tester.connectionState, isA()); + + loaded.complete(); + async.flushMicrotasks(); + + // Nothing can repair credentials the authenticator has given up on, so an attempt left + // eligible for a reconnection would be refused the same way. + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + }); + + test('does not close the connection that replaced it when its credentials fail', () { + fakeAsync((async) { + final loaded = Completer(); + var calls = 0; + final tester = buildTester( + authenticator: (send, _) async { + if (++calls > 1) return; + await loaded.future; + throw StateError('token load failed'); + }, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + tester.server.send({'type': 'connection.ok', 'connection_id': 'connection-id'}); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + loaded.complete(); + async.flushMicrotasks(); + + // The failure belongs to an attempt abandoned before this connection was opened. Reported + // against this one it closes a working connection as `AuthenticationFailed`, which is + // never retried. + expect(tester.connectionState, isA()); + }); + }); + }); + }); + + group('disconnect', () { + wsClientTest( + 'leaves the connection closed, not closing, once it returns', + body: (tester) async { + await tester.client.disconnect(); + + // A caller that reconnects straight away would otherwise race the close. + expect(tester.connectionState, isA()); + }, + ); + + wsClientTest( + 'reports the connection closed even when the socket close fails', + closeError: Exception('close failed'), + body: (tester) async { + await tester.client.disconnect(); + + // The engine reports the failure rather than the closure, so the client is what moves this + // out of 'disconnecting'. + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); + + wsClientTest( + 'leaves a client whose socket refused to close able to connect', + closeError: Exception('close failed'), + body: (tester) async { + await tester.client.disconnect(); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + // A socket that refuses to close is not one the client can use, and holding on to it would + // refuse every later connect. + expect(tester.connectionState, isA()); + expect(tester.attempts, 2); + }, + ); + + wsClientTest( + 'does not open a socket while the previous one is still closing', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); + expect(tester.connectionState, isA()); + + await tester.client.connect(); + + // The old socket's close event would otherwise bring the new connection down and disarm the + // timeout meant to be watching it. + expect(tester.attempts, 1); + expect(tester.connectionState, isA()); + tester.server.socket.sink.completeClose(); + }, + ); + + wsClientTest( + 'does not refuse a connect made straight after it', + connect: (_) {}, + body: (tester) async { + // Not awaited, as a caller reasonably might not: there is no connection to close, so nothing + // should stand in the way of the connect that follows. + tester.client.disconnect().ignore(); + tester.client.connect().ignore(); + await tester.pumpEventQueue(); + + expect(tester.connectionState, isA()); + expect(tester.attempts, 1); + }, + ); + + wsClientTest( + 'leaves a client that never connected able to connect', + connect: (_) {}, + body: (tester) async { + // Nothing was opened, so there is nothing to close and no closure to report. Reporting one + // would leave a connect made straight after it racing a close that never happened. + await tester.client.disconnect(); + expect(tester.connectionState, isA()); + + await tester.client.connect(); + await tester.pumpEventQueue(); + + expect(tester.connectionState, isA()); + expect(tester.attempts, 1); + }, + ); + + wsClientTest( + 'takes over a closure already under way', + holdClose: true, + body: (tester) async { + // A closure the client decided on, still in flight. + tester.client.disconnect(source: const UnHealthyConnection()).ignore(); + expect(tester.connectionState, isA()); + + final disconnected = tester.client.disconnect(); + tester.server.socket.sink.completeClose(); + await disconnected; + + // Recorded as the caller's, so nothing is reconnected after they asked to stop. + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); + + wsClientTest( + 'takes over a closure the server already made', + body: (tester) async { + // The server hung up, which the client would reconnect after. + tester.server.hangUp(); + await tester.pumpEventQueue(); + expect(tester.connectionState, isA()); + + await tester.client.disconnect(); + + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); + + wsClientTest( + 'keeps the reason the caller gave when a server error arrives mid-close', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); + + // A rate limit clears on its own, so the client would reconnect after a closure recorded for + // one. + await tester.emit(connectionErrorFrame(code: 9, statusCode: 429)); + tester.server.socket.sink.completeClose(); + await tester.pumpEventQueue(); + + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); + + test('stands the health monitor down, so it cannot relabel the closure', () { + fakeAsync((async) { + final tester = buildTester(); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // A live connection is being watched, so there is something to stand down. + expect(async.pendingTimers, isNotEmpty); + + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + + // Nothing is left to fire. A monitor still watching would report the connection unhealthy, + // which is reconnected where the caller's closure is not. + expect(async.pendingTimers, isEmpty); + + async.elapse(const Duration(minutes: 1)); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + group('dispose', () { + wsClientTest( + 'closes the connection and both emitters', + body: (tester) async { + await tester.client.dispose(); + + expect(tester.client.isDisposed, isTrue); + expect(tester.client.events.isClosed, isTrue); + expect(tester.client.connectionState.isClosed, isTrue); + }, + ); + + wsClientTest( + 'does nothing when called again', + body: (tester) async { + await tester.client.dispose(); + + await expectLater(tester.client.dispose(), completes); + }, + ); + + wsClientTest( + 'refuses to connect again', + body: (tester) async { + await tester.client.dispose(); + + // Throws rather than asserts, so a release build refuses too: the recovery handler is the + // one caller that does not await this, and it ignores the future. The untouched builder + // count pins that no socket was opened. + await expectLater(tester.client.connect(), throwsA(isA())); + expect(tester.attempts, 1); + }, + ); + }); + }); + + group('the events it receives', () { + group('an event the client has no handling of', () { + wsClientTest( + 'reaches whoever is listening', + body: (tester) async { + final received = []; + final subscription = tester.client.events.listen(received.add); + addTearDown(subscription.cancel); + + await tester.emit({'type': 'activity.added'}); + + // Everything the client does not act on itself is an app's to act on, and an event it does + // not recognise must not be taken as a reason to end the connection. + expect(received, [isA().having((it) => it.type, 'type', 'activity.added')]); + expect(tester.connectionState, isA()); + }, + ); + }); + + group('a health check', () { + wsClientTest( + 'is emitted as well as acted on', + body: (tester) async { + final received = []; + final subscription = tester.client.events.listen(received.add); + addTearDown(subscription.cancel); + + await tester.emit({'type': 'health.check', 'connection_id': 'connection-id'}); + + // Handled and then passed on, so an app can react to a connection coming back. + expect(received, hasLength(1)); + expect(tester.connectionState, isA()); + }, + ); + }); + + group('when the socket itself gives out', () { + wsClientTest( + 'reports the closure as the server ending it', + connect: (tester) async { + await tester.client.connect(); + await tester.pumpEventQueue(); + }, + body: (tester) async { + // Not a `connection.error` over a working socket: the socket is what failed. + tester.server.fail(StateError('socket died')); + await tester.pumpEventQueue(); + + expect( + tester.connectionState, + isA().having( + (it) => it.source, + 'source', + isA().having((it) => it.error?.error, 'error.error', isStateError), + ), + ); + }, + ); + + wsClientTest( + 'is reconnected, since nothing says the credentials were the problem', + recover: true, + body: (tester) async { + tester.server.fail(StateError('socket died')); + await tester.pumpEventQueue(); + + // A socket that gave out says nothing about the token, so this is a drop to recover from, + // and the token it presents again is the one it already had. + expect(tester.attempts, 2); + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + }, + ); + }); + + group('an error event the server sent', () { + wsClientTest( + 'closes the connection rather than being emitted', + body: (tester) async { + final received = []; + final subscription = tester.client.events.listen(received.add); + addTearDown(subscription.cancel); + + await tester.emit(expiredTokenFrame()); + + // The client acts on it; an app learns about it from the connection state. + expect(received, isEmpty); + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }, + ); + }); + }); + + group('the connect timeout', () { + test('abandons an attempt that never becomes connected', () { + fakeAsync((async) { + // A server that takes the credentials and never answers. + final tester = buildTester(); + tester.server.onFrame = (_) => []; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // The socket opened, so the client is authenticating with nothing else watching it. + expect(tester.connectionState, isA()); + + // Still waiting a tick before the timeout is due. + async.elapse(WebSocketOptions.defaultConnectTimeout - const Duration(seconds: 1)); + expect(tester.connectionState, isA()); + + async.elapse(const Duration(seconds: 1)); + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); + + // An attempt abandoned here is retried: a first health check that never arrives is the same + // failure as one that stops arriving. + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + }); + + test('abandons an attempt whose socket never opens', () { + fakeAsync((async) { + // A handshake that hangs, so the attempt never leaves 'connecting'. + final tester = buildTester(handshakeHangs: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('abandons an attempt whose authenticator never returns', () { + fakeAsync((async) { + // An authenticator awaiting something that never resolves. Nothing else watches + // 'authenticating', so only this fires. + final tester = buildTester( + authenticator: (_, _) => Completer().future, + ); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + async.elapse(WebSocketOptions.defaultConnectTimeout); + + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('times out a later attempt too', () { + fakeAsync((async) { + final tester = buildTester(); + tester.server.onFrame = (_) => []; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + expect(tester.connectionState, isA()); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + async.elapse(WebSocketOptions.defaultConnectTimeout); + + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('honours a timeout given in the options', () { + fakeAsync((async) { + final tester = buildTester(connectTimeout: const Duration(seconds: 2)); + tester.server.onFrame = (_) => []; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 2)); + + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('does not time out once the connection is established', () { + fakeAsync((async) { + // The default server answers every ping, which is what keeps a live connection alive. + final tester = buildTester(); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // Past the timeout, and past several ping cycles with it. + async.elapse(WebSocketOptions.defaultConnectTimeout + const Duration(seconds: 60)); + + expect(tester.connectionState, isA()); + }); + }); + + test('does not replace the source of a closure that came first', () { + fakeAsync((async) { + final tester = buildTester(); + tester.server.onFrame = (_) => []; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // The server refuses and hangs up while the attempt is still being timed. + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout * 2); + + // The source of the closure already under way wins: `ConnectTimeout` would say the server + // never answered, when it answered with a refusal an authenticator can act on. + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + }); + + test('does not replace the source of a disconnect that came first', () { + fakeAsync((async) { + final tester = buildTester(); + tester.server.onFrame = (_) => []; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + + async.elapse(WebSocketOptions.defaultConnectTimeout * 2); + + // The timeout would otherwise report this deliberate disconnect as a timed-out attempt, + // which is retried where this is not. + expect( + tester.connectionState, + isA().having((it) => it.source, 'source', isA()), + ); + }); + }); + + test('releases the timeout of an attempt in flight when disposed', () { + fakeAsync((async) { + final tester = buildTester(handshakeHangs: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + tester.client.dispose().ignore(); + async.flushMicrotasks(); + + // A timeout left armed keeps a disposed client alive for as long as it has left to run. + expect(async.pendingTimers, isEmpty); + }); + }); + + group('when a health check arrives while disconnecting', () { + wsClientTest( + 'does not report the connection as established again', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); + expect(tester.connectionState, isA()); + + // Arrives before the socket finished closing. + await tester.emit({'type': 'connection.ok', 'connection_id': 'late'}); + + expect(tester.connectionState, isA()); + tester.server.socket.sink.completeClose(); + }, + ); + + wsClientTest( + 'ignores one that arrives before the credentials have gone out', + handshakeHangs: true, + connect: (tester) async { + tester.client.connect().ignore(); + await tester.pumpEventQueue(); + }, + body: (tester) { + // The engine subscribes before the handshake completes, so a frame can reach the client + // while it is still connecting. Acted on, it would report a connection established that + // has never presented credentials. + expect(tester.connectionState, isA()); + + tester.client.onMessage(const HealthCheck(connectionId: 'early')); + + expect(tester.connectionState, isA()); + }, + ); + + wsClientTest( + 'ignores one the engine delivers after the state has moved on', + holdClose: true, + body: (tester) { + tester.client.disconnect().ignore(); + expect(tester.connectionState, isA()); + + // Delivered straight to the listener, as the engine does for a frame already in its queue + // when the state flipped. Closing cancels the subscription, so a frame sent through the + // socket is dropped before it gets here and cannot reach this guard. + tester.client.onMessage(const HealthCheck(connectionId: 'late')); + + expect(tester.connectionState, isA()); + tester.server.socket.sink.completeClose(); + }, + ); + + wsClientTest( + 'leaves the disconnection source intact once the socket closes', + holdClose: true, + body: (tester) async { + tester.client.disconnect().ignore(); + await tester.emit({'type': 'connection.ok', 'connection_id': 'late'}); + + tester.server.socket.sink.completeClose(); + await tester.pumpEventQueue(); + + // A late health check must not move the state back to connected: the closure would then be + // reported as server-initiated, which is eligible for a reconnect. + final state = tester.connectionState; + expect(state, isA().having((it) => it.source, 'source', isA())); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }, + ); + }); + }); + + // The client, its authentication handler, its health monitor and its recovery handler all take + // part, so these cover the loop rather than any one of them: the server refuses, the refusal + // reaches the next attempt, that attempt presents something else, and the connection comes back + // without the app being told anything. + group('the token behind the connection', () { + // The handshake a consuming SDK performs, which acts on `previousError`: an expired token is + // dropped so the provider can issue another, and a provider with nothing else to give declines + // rather than presenting the same token again. + WebSocketAuthenticator authenticatorFor(TokenManager tokens) { + return (send, previousError) async { + if (previousError?.isTokenExpiredError ?? false) { + tokens.expireToken(); + if (tokens.usesStaticProvider) { + throw ClientException(message: 'The token was refused and the provider has no other to give'); + } + } + + final token = await tokens.getToken(); + send(WsAuthMessageRequest(token: token.rawValue)).getOrThrow(); + }; + } + + test('is still answered for by the attempt after it, when the user has not changed', () { + fakeAsync((async) { + final asked = []; + final tokens = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), + ); + final tester = buildTester( + tokens: tokens, + authenticator: (send, previousError) async { + asked.add(previousError); + send(WsAuthMessageRequest(token: (await tokens.getToken()).rawValue)).getOrThrow(); + }, + ); + tester.server.onFrame = (_) => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ]; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // 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)]); + }); + }); + + test('is not answered for with a refusal recorded against the user before it', () { + fakeAsync((async) { + final tokens = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.dynamic((id) async => generateTestUserToken(id)), + ); + final tester = buildTester(tokens: tokens, authenticator: authenticatorFor(tokens)); + // Whose token it is, is the server's business elsewhere; this is about what the client does. + tester.server.onFrame = (_) => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ]; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The server refuses the token. Nothing retries it, so the refusal is still armed. + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + + // The app takes connecting back, then signs a different user in: a new identity, on a + // provider with one token to give, as a guest exchange produces. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + tokens.setTokenProvider('guest-1', tokenProvider: TokenProvider.static(generateTestUserToken('guest-1'))); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // The refusal was about the user before this one. Handed on, it makes an authenticator drop + // credentials that were never refused and decline a connection that would have been served. + expect(tester.connectionState, isA()); + expect(tokens.peekToken(), isNotNull); + }); + }); + + test('comes back with a fresh token after the server refuses an expired one', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + + // The server refuses the token of a connection that was working. + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + async.elapse(Duration.zero); + + // Reconnecting is the recovery handler's, and the token it presents was loaded after the + // refusal. The app is told nothing and does nothing. + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 2); + expect(tester.states.whereType(), hasLength(2)); + }); + }); + + test('presents the replacement, not the token that was refused', () { + fakeAsync((async) { + var issued = 0; + final tester = buildTester( + recover: true, + // A backend handing out a distinguishable token each time. The user has to stay the same — + // a manager configured for one user refuses to cache another's token — so these differ by + // the nonce they carry. + tokenLoader: (userId) async => generateTestUserToken(userId, nonce: '${++issued}'), + ); + // The server accepts any token, so what matters is which one arrives second. + tester.server.onFrame = (_) => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ]; + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + tester.server.send(expiredTokenFrame()); + async.flushMicrotasks(); + async.elapse(Duration.zero); + + final presented = tester.server.received.map((it) => it['token']).toList(); + expect(presented, hasLength(2)); + + // Offering the refused token again would be refused again, for the life of the client. + expect(presented[1], isNot(presented[0])); + }); + }); + + test('keeps the token when the server closed for a reason that was not about it', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.tokenLoads, 1); + + // A server error that says nothing about the token, and is retried like any other. Dropping + // the token here would send the reconnect to the provider for one that was never refused. + tester.server.send(connectionErrorFrame(code: 5, statusCode: 500)); + async.flushMicrotasks(); + async.elapse(Duration.zero); + + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + }); + }); + + test('keeps the token across a disconnect the caller asked for', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.tokenLoads, 1); + + // A deliberate disconnect says nothing about the token. Dropping it here would send every + // reconnect to the provider for a token that was never refused. + tester.client.disconnect().ignore(); + async.flushMicrotasks(); + tester.client.connect().ignore(); + async.flushMicrotasks(); + + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + }); + }); + + test('keeps the token across a connection that stopped answering', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + expect(tester.tokenLoads, 1); + + // A connection that goes quiet says nothing about the token either. + tester.server.onFrame = (frame) => switch (frame['type']) { + 'health.check' => const [], + _ => [ + {'type': 'connection.ok', 'connection_id': 'connection-id'}, + ], + }; + async.elapse(const Duration(seconds: 29)); + async.flushMicrotasks(); + + expect(tester.states.whereType(), hasLength(2)); + expect(tester.tokenLoads, 1); + }); + }); + + test('stays closed when no other token would be accepted', () { + fakeAsync((async) { + final tester = buildTester(recover: true); + + tester.client.connect().ignore(); + async.flushMicrotasks(); + + // A signature signed with the wrong secret: another token from the same provider carries the + // same problem, so retrying would offer refused credentials for the life of the client. + tester.server.send(invalidSignatureFrame()); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 1)); + + expect(tester.connectionState, isA()); + expect(tester.tokenLoads, 1); + expect(tester.attempts, 1); + }); + }); + }); +} 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 new file mode 100644 index 00000000..cc47d802 --- /dev/null +++ b/packages/stream_core/test/ws/client/web_socket_authentication_handler_test.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:stream_core/src/ws/client/web_socket_authentication_handler.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +final class _PingRequest extends WsRequest { + const _PingRequest(); + + @override + Map toJson() => const {}; + + @override + List get props => const []; +} + +StreamApiError _apiError({ + required int code, + int statusCode = 401, +}) => StreamApiError( + code: code, + details: const [], + duration: '0ms', + message: 'error $code', + moreInfo: '', + statusCode: statusCode, +); + +final _expiredToken = _apiError(code: 40); + +Disconnected _serverClosure(StreamApiError? apiError) => Disconnected( + source: ServerInitiated(error: WebSocketEngineException(error: apiError)), +); + +/// Builds a handler, along with the errors it handed the authenticator and the failures it reported. +({ + WebSocketAuthenticationHandler authentication, + List asked, + List failures, +}) +_subject({WebSocketAuthenticator? authenticator}) { + final asked = []; + final failures = []; + + final authentication = WebSocketAuthenticationHandler( + authenticator: + authenticator ?? + (send, previousError) async { + asked.add(previousError); + send(const _PingRequest()).getOrThrow(); + }, + send: (_) => const Result.success(null), + onFailure: failures.add, + ); + + return (authentication: authentication, asked: asked, failures: failures); +} + +void main() { + test('previousError holds what the server closed the connection with', () { + final (:authentication, asked: _, failures: _) = _subject(); + + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + expect(authentication.previousError, _expiredToken); + }); + + test('previousError survives a closure the server did not explain', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + // A socket that fails to open closes without an error. The earlier refusal still applies, + // because no new credentials were sent. + authentication.onConnectionStateChanged(_serverClosure(null)); + + expect(authentication.previousError, _expiredToken); + }); + + test('previousError is cleared once a connection has been established', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + authentication.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + + // The connection succeeded, so there is no refusal to report. + expect(authentication.previousError, isNull); + }); + + test('previousError survives a newer refusal equal to the one being answered', () async { + // `StreamApiError` compares by value, so a second refusal of the same kind cannot be told from + // the first by comparing them. Spent on the strength of that, it would leave the attempt after + // this one with nothing to answer, and it would present the same refused credentials again. + final held = Completer(); + final (:authentication, asked: _, failures: _) = _subject( + authenticator: (send, previousError) => held.future, + ); + + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + authentication.onConnectionStateChanged(const Connecting()); + final running = authentication.authenticate(); + + // The server refuses again while this attempt is still awaiting its credentials. + authentication.onConnectionStateChanged(_serverClosure(_apiError(code: 40))); + held.complete(); + await running; + + expect(authentication.previousError, _expiredToken); + }); + + test('previousError is forgotten once the caller has disconnected', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + // A caller that disconnects takes connecting back, and what they connect with next is theirs to + // decide: another user, for one, whose credentials this refusal says nothing about. + // + // The cost is a connect made straight afterwards with credentials that are still spent: it is + // refused, and since a caller's disconnect hands connecting back to them, nothing retries it + // either. That attempt records the refusal afresh, so the connect after it is told and succeeds. + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.userInitiated()), + ); + + expect(authentication.previousError, isNull); + }); + + test('previousError is taken by the attempt that reads it, not left for the next', () async { + final (:authentication, :asked, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + await authentication.authenticate(); + await authentication.authenticate(); + + // Left behind, the refusal would reach a later attempt that it says nothing about. + expect(asked, [_expiredToken, null]); + expect(authentication.previousError, isNull); + }); + + test('previousError survives the states an attempt passes through', () { + final (:authentication, asked: _, failures: _) = _subject(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + // The error must survive until the retry actually authenticates. + authentication.onConnectionStateChanged(const Connecting()); + authentication.onConnectionStateChanged(const Authenticating()); + + expect(authentication.previousError, _expiredToken); + }); + + test('authenticate hands the previous error to the authenticator', () async { + final (:authentication, :asked, failures: _) = _subject(); + + await authentication.authenticate(); + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + await authentication.authenticate(); + + expect(asked, [null, _expiredToken]); + }); + + test('authenticate does nothing without an authenticator', () async { + final authentication = WebSocketAuthenticationHandler( + authenticator: null, + send: (_) => const Result.success(null), + onFailure: (_) => fail('nothing to authenticate, so nothing can fail'), + ); + + await expectLater(authentication.authenticate(), completes); + }); + + test('authenticate reports an error the authenticator threw, rather than letting it escape', () async { + final (:authentication, asked: _, :failures) = _subject( + // Loading a token throws, and nothing awaits `authenticate`, so the error would otherwise + // escape unhandled. + authenticator: (_, _) async => throw StateError('token load failed'), + ); + + await authentication.authenticate(); + + expect(failures, [isStateError]); + }); + + test('previousError keeps a refusal the server sent while an attempt was authenticating', () async { + final loaded = Completer(); + final authentication = WebSocketAuthenticationHandler( + authenticator: (_, _) => loaded.future, + send: (_) => const Result.success(null), + onFailure: (_) => fail('the credentials went out'), + ); + + authentication.onConnectionStateChanged(const Connecting()); + final authenticating = authentication.authenticate(); + + // Refused while this attempt was still running, so it is not the refusal this attempt read and + // it has yet to be answered. + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + + loaded.complete(); + await authenticating; + + expect(authentication.previousError, _expiredToken); + }); + + group('when another attempt has begun', () { + test('sends nothing over the connection that replaced the one it belongs to', () async { + final loaded = Completer(); + final sent = []; + Result? outcome; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (send, _) async { + await loaded.future; + outcome = send(const _PingRequest()); + }, + send: (request) { + sent.add(request); + return const Result.success(null); + }, + onFailure: (_) => fail('the credentials were never offered, so nothing failed to go out'), + ); + + authentication.onConnectionStateChanged(const Connecting()); + final authenticating = authentication.authenticate(); + + // Abandoned while its credentials were still loading, and replaced. + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.connectTimeout()), + ); + authentication.onConnectionStateChanged(const Connecting()); + + loaded.complete(); + await authenticating; + + expect(sent, isEmpty); + expect(outcome, isA()); + }); + + test('reports no failure against the connection that replaced the one it belongs to', () async { + final loaded = Completer(); + final failures = []; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (_, _) async { + await loaded.future; + throw StateError('token load failed'); + }, + send: (_) => const Result.success(null), + onFailure: failures.add, + ); + + authentication.onConnectionStateChanged(const Connecting()); + final authenticating = authentication.authenticate(); + + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.connectTimeout()), + ); + authentication.onConnectionStateChanged(const Connecting()); + + loaded.complete(); + await authenticating; + + // Reported against the attempt that replaced it, this would close a connection that + // authenticated fine as `AuthenticationFailed`, which is never reconnected. + expect(failures, isEmpty); + }); + + test('leaves the refusal for the attempt that replaces it', () async { + final loaded = Completer(); + final asked = []; + var calls = 0; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (send, previousError) async { + asked.add(previousError); + if (++calls > 1) return; + await loaded.future; + }, + send: (_) => const Result.success(null), + onFailure: (_) {}, + ); + + authentication.onConnectionStateChanged(_serverClosure(_expiredToken)); + authentication.onConnectionStateChanged(const Connecting()); + final abandoned = authentication.authenticate(); + + authentication.onConnectionStateChanged( + const Disconnected(source: DisconnectionSource.connectTimeout()), + ); + authentication.onConnectionStateChanged(const Connecting()); + await authentication.authenticate(); + + loaded.complete(); + await abandoned; + + // The abandoned attempt never sent anything, so the refusal still applies to the credentials + // in place, and belongs to the attempt that can actually answer it. + expect(asked, [_expiredToken, _expiredToken]); + }); + + test('still reports a failure for the attempt in flight', () async { + final failures = []; + + final authentication = WebSocketAuthenticationHandler( + authenticator: (_, _) async => throw StateError('token load failed'), + send: (_) => const Result.success(null), + onFailure: failures.add, + ); + + authentication.onConnectionStateChanged(const Connecting()); + authentication.onConnectionStateChanged(const Authenticating()); + + await authentication.authenticate(); + + expect(failures, [isStateError]); + }); + }); +} 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 c8d9a6b9..04554c3f 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 @@ -1,44 +1,170 @@ import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -StreamApiError _apiError(int code) => StreamApiError( +StreamApiError _apiError(int code, {int statusCode = 401}) => StreamApiError( code: code, details: const [], duration: '0ms', message: 'error $code', moreInfo: '', - statusCode: 401, + statusCode: statusCode, ); Disconnected _serverDisconnect(StreamApiError apiError) => Disconnected( source: ServerInitiated( - error: WebSocketEngineException( - reason: apiError.message, - code: 4001, - error: apiError, - ), + error: WebSocketEngineException(reason: apiError.message, code: 4001, error: apiError), ), ); +const _healthCheck = HealthCheckInfo(connectionId: 'connection-id'); + +/// Every state a connection passes through, other than [except]. +Iterable _everyStateBut(WebSocketConnectionState except) { + return const [ + Initialized(), + Connecting(), + Authenticating(), + Connected(healthCheck: _healthCheck), + Disconnecting(source: UserInitiated()), + Disconnected(source: UserInitiated()), + ].where((it) => it != except); +} + void main() { - group('WebSocketConnectionState.isAutomaticReconnectionEnabled', () { - test( - 'is disabled when the server closes with a token-expired error, so an ' - 'expired (e.g. guest) token does not trigger a silent reconnect loop', - () { - // Token-invalid error codes are 40..42; 40 = token expired. - final state = _serverDisconnect(_apiError(40)); - - expect(state.isAutomaticReconnectionEnabled, isFalse); - }, + 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 + // 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]) { + expect(_serverDisconnect(_apiError(code)).isAutomaticReconnectionEnabled, isFalse, reason: 'code $code'); + } + }); + + test('automatic reconnection is enabled when the request was rate limited', () { + // 9 is a rate limit, sent as 429, which clears on its own without the caller doing anything. + final state = _serverDisconnect(_apiError(9, statusCode: 429)); + + 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)); + + 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)); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + + test('automatic reconnection is disabled when the socket was closed deliberately', () { + const state = Disconnected( + source: ServerInitiated(error: WebSocketEngineException(code: CloseCode.normalClosure)), ); - test('is enabled for a generic, retryable server-initiated disconnection', () { - // A server error that is neither a normal closure (1000), a token error - // (40..42), nor a client error (400..499) should still reconnect. - final state = _serverDisconnect(_apiError(43)); + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + + test('automatic reconnection is enabled when the server closed without saying why', () { + const state = Disconnected(source: ServerInitiated()); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + + 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')); + + expect(state.isAutomaticReconnectionEnabled, isFalse); + }); + + test('automatic reconnection is enabled when a connected socket stops answering health checks', () { + const state = Disconnected(source: UnHealthyConnection()); + + expect(state.isAutomaticReconnectionEnabled, isTrue); + }); + + test('closeReason reads differently for every source', () { + const sources = [ + UserInitiated(), + ServerInitiated(), + SystemInitiated(), + UnHealthyConnection(), + ConnectTimeout(), + AuthenticationFailed(error: 'no token'), + ]; + + final reasons = sources.map((it) => it.closeReason).toSet(); + + // A shared reason would report two different outcomes identically. + expect(reasons, hasLength(sources.length)); + }); + + test('a connection is active until it is closed', () { + // A connection being made, or being closed, is still one an app must not open another over. + for (final state in _everyStateBut(const Disconnected(source: UserInitiated()))) { + expect(state.isActive, isTrue, reason: '$state'); + } + + expect(const Disconnected(source: UserInitiated()).isActive, isFalse); + }); + + test('a connection is connected only once it is established', () { + expect(const Connected(healthCheck: _healthCheck).isConnected, isTrue); + + for (final state in _everyStateBut(const Connected(healthCheck: _healthCheck))) { + expect(state.isConnected, isFalse, reason: '$state'); + } + }); + + 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); + + // The close code is the only account of the closure there is, so it stands in. + expect(source.cause, same(exception)); + }); + + test('is null for a server closure that named no reason at all', () { + expect(const ServerInitiated().cause, isNull); + }); + + test('is what authentication failed with', () { + final error = Exception('the token was refused'); + final source = AuthenticationFailed(error: error); + + expect(source.cause, same(error)); + }); - expect(state.isAutomaticReconnectionEnabled, isTrue); + test('is null for the sources that carry none', () { + // Nothing went wrong in these, or nothing that the source was told about. + expect(const UserInitiated().cause, isNull); + expect(const SystemInitiated().cause, isNull); + expect(const UnHealthyConnection().cause, isNull); + expect(const ConnectTimeout().cause, isNull); }); }); } diff --git a/packages/stream_core/test/ws/client/web_socket_health_monitor_test.dart b/packages/stream_core/test/ws/client/web_socket_health_monitor_test.dart new file mode 100644 index 00000000..c113240e --- /dev/null +++ b/packages/stream_core/test/ws/client/web_socket_health_monitor_test.dart @@ -0,0 +1,224 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +/// Records what the monitor asked of the connection it is watching. +class _Listener implements WebSocketHealthListener { + var _pings = 0; + var _unhealthy = 0; + + /// How many times the monitor asked for a ping to be sent. + int get pings => _pings; + + /// How many times it reported the connection unhealthy. + int get unhealthy => _unhealthy; + + @override + void onPingRequested() => _pings++; + + @override + void onUnhealthy() => _unhealthy++; +} + +// Deliberately not the defaults, so these also pin that the monitor uses what it was given. +const _pingInterval = Duration(seconds: 10); +const _timeout = Duration(seconds: 2); + +({WebSocketHealthMonitor monitor, _Listener listener}) _subject() { + final listener = _Listener(); + return ( + monitor: WebSocketHealthMonitor( + listener: listener, + pingInterval: _pingInterval, + timeoutThreshold: _timeout, + ), + listener: listener, + ); +} + +void main() { + test('asks for nothing until it is started', () { + fakeAsync((async) { + final (monitor: _, :listener) = _subject(); + + async.elapse(_pingInterval * 5); + + expect(listener.pings, 0); + expect(listener.unhealthy, 0); + }); + }); + + test('waits out the interval before the first ping', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + // A connection that has just answered does not need asking again straight away. + async.elapse(_pingInterval - const Duration(seconds: 1)); + expect(listener.pings, 0); + + async.elapse(const Duration(seconds: 1)); + expect(listener.pings, 1); + + monitor.stop(); + }); + }); + + test('keeps asking for as long as the answers keep coming', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + for (var i = 0; i < 4; i++) { + async.elapse(_pingInterval); + monitor.onPongReceived(); + } + + expect(listener.pings, 4); + expect(listener.unhealthy, 0); + + monitor.stop(); + }); + }); + + test('reports the connection unhealthy when an answer does not arrive', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + async.elapse(_pingInterval); + expect(listener.pings, 1); + expect(listener.unhealthy, 0); + + // Still inside the window the peer has to answer in. + async.elapse(_timeout - const Duration(seconds: 1)); + expect(listener.unhealthy, 0); + + async.elapse(const Duration(seconds: 1)); + expect(listener.unhealthy, 1); + + monitor.stop(); + }); + }); + + test('holds off on the verdict while answers arrive in time', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + // An answer that lands just inside the window, as a slow connection gives. + async.elapse(_pingInterval); + async.elapse(_timeout - const Duration(milliseconds: 1)); + monitor.onPongReceived(); + + async.elapse(const Duration(seconds: 1)); + expect(listener.unhealthy, 0); + + monitor.stop(); + }); + }); + + test('asks for nothing once it is stopped', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + async.elapse(_pingInterval); + + monitor.stop(); + async.elapse(_pingInterval * 5); + + // The one ping from before it stopped, and nothing after. + expect(listener.pings, 1); + expect(async.pendingTimers, isEmpty); + }); + }); + + test('does not deliver a verdict it had already reached when stopped', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.start(); + + // A ping went unanswered, so the verdict is pending. + async.elapse(_pingInterval); + monitor.stop(); + + async.elapse(_timeout * 2); + + // Reported after the connection was closed, this would reopen one nobody asked for. + expect(listener.unhealthy, 0); + }); + }); + + group('following the connection state', () { + test('starts watching once a connection is established', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + async.elapse(_pingInterval); + + expect(listener.pings, 1); + + monitor.stop(); + }); + }); + + test('stops watching once it is not', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + + monitor.onConnectionStateChanged(const Disconnected(source: UserInitiated())); + async.elapse(_pingInterval * 3); + + expect(listener.pings, 0); + expect(async.pendingTimers, isEmpty); + }); + }); + + test('watches nothing while a connection is still being made', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + + // The connect timeout is what bounds an attempt; pinging a socket that has not been + // accepted yet would report it unhealthy before it ever had a chance. + monitor.onConnectionStateChanged(const Connecting()); + monitor.onConnectionStateChanged(const Authenticating()); + async.elapse(_pingInterval * 2); + + expect(listener.pings, 0); + }); + }); + + test('keeps a fixed cadence when an answer arrives mid-interval', () { + fakeAsync((async) { + final (:monitor, :listener) = _subject(); + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'connection-id')), + ); + + async.elapse(_pingInterval * 0.6); + expect(listener.pings, 0); + + // Every answer arrives as another `Connected`, which asks the monitor to start again. + monitor.onPongReceived(); + monitor.onConnectionStateChanged( + const Connected(healthCheck: HealthCheckInfo(connectionId: 'answered')), + ); + + async.elapse(_pingInterval * 0.5); + + // Starting again would reschedule the ping, so each answer would push the next one a full + // interval away and a connection answering steadily would be asked less and less often. + expect(listener.pings, 1); + expect(listener.unhealthy, 0); + + monitor.stop(); + }); + }); + }); +} diff --git a/packages/stream_core/test/ws/events/ws_event_test.dart b/packages/stream_core/test/ws/events/ws_event_test.dart new file mode 100644 index 00000000..58c1d9ab --- /dev/null +++ b/packages/stream_core/test/ws/events/ws_event_test.dart @@ -0,0 +1,33 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('HealthCheckInfo', () { + test('compares by what it carries', () { + expect( + const HealthCheckInfo(connectionId: 'a', participantCount: 2), + const HealthCheckInfo(connectionId: 'a', participantCount: 2), + ); + expect( + const HealthCheckInfo(connectionId: 'a'), + isNot(const HealthCheckInfo(connectionId: 'b')), + ); + }); + }); + + group('HealthCheckPingEvent', () { + test('is sent as a health check naming the connection it is for', () { + // The server matches a ping to a connection by this id, so the wire shape is the contract. + expect( + const HealthCheckPingEvent(connectionId: 'connection-id').toJson(), + {'type': 'health.check', 'client_id': 'connection-id'}, + ); + }); + + test('leaves the connection out when there is not one yet', () { + // A null id is omitted rather than sent as null, which the server would read as a client + // claiming no connection. + expect(const HealthCheckPingEvent(connectionId: null).toJson(), {'type': 'health.check'}); + }); + }); +}