From 21aa063cc47da4bdc91defe5f78ff1b624893eda Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:10:58 -0700 Subject: [PATCH] feat!: report every SSE error response with status, headers, and recoverability Replace UnrecoverableStatusError with SseHttpError, reported on the event stream for any non-200 response -- recoverable or not. It carries the status code, the response headers (which may hold a service directive), and a recoverable flag indicating whether the client will retry on its own (backoff) or has stopped. BREAKING CHANGE: The SSE client now reports recoverable error responses (e.g. 5xx) on the stream; previously only unrecoverable responses surfaced and recoverable ones were retried silently. A consumer that treats any error from the stream as terminal will now tear down on a transient error. Such consumers must check SseHttpError.recoverable and ignore recoverable errors -- the client retries those on its own. UnrecoverableStatusError is removed; use SseHttpError (statusCode, headers, recoverable) instead. --- .../lib/launchdarkly_event_source_client.dart | 2 +- .../event_source_client/lib/src/errors.dart | 36 ++++++++++++------- .../lib/src/state_connecting.dart | 22 +++++++----- .../test/state_connecting_test.dart | 34 +++++++++++++++--- .../test/uri_provider_test.dart | 5 +-- 5 files changed, 71 insertions(+), 28 deletions(-) diff --git a/packages/event_source_client/lib/launchdarkly_event_source_client.dart b/packages/event_source_client/lib/launchdarkly_event_source_client.dart index f103e191..802d49aa 100644 --- a/packages/event_source_client/lib/launchdarkly_event_source_client.dart +++ b/packages/event_source_client/lib/launchdarkly_event_source_client.dart @@ -12,7 +12,7 @@ import 'src/sse_client_stub.dart' if (dart.library.js_interop) 'src/sse_client_html.dart'; import 'src/test_sse_client.dart'; -export 'src/errors.dart' show UnrecoverableStatusError; +export 'src/errors.dart' show SseHttpError; export 'src/events.dart' show Event, MessageEvent, OpenEvent; export 'src/test_sse_client.dart' show TestSseClient; export 'src/logging.dart' diff --git a/packages/event_source_client/lib/src/errors.dart b/packages/event_source_client/lib/src/errors.dart index 6a41fc51..80395b5b 100644 --- a/packages/event_source_client/lib/src/errors.dart +++ b/packages/event_source_client/lib/src/errors.dart @@ -1,23 +1,35 @@ -/// Reported on the event stream when the server responds with an HTTP -/// status code that the client will not retry (anything other than 200, -/// 400, 408, 429, or 5xx). After reporting this error the client stops -/// reconnecting until connection desire changes. +/// Reported on the event stream when the server responds with a non-200 +/// HTTP status. Carries the status code and the response headers, which +/// may hold service directives (e.g. protocol fallback instructions) even +/// on error responses. +/// +/// [recoverable] indicates what the client does next: +/// - `true`: the status is one the client retries, so it has scheduled a +/// reconnect with backoff. The subscription stays open; the error is an +/// advisory the consumer may act on (or ignore and let the retry run). +/// - `false`: the client will not retry. It stops reconnecting until +/// connection desire changes. /// /// Only produced by implementations whose transport can observe HTTP /// responses. The browser's native `EventSource` cannot, so on `html` -/// platforms this error is never reported and the client retries every -/// failure indefinitely. -final class UnrecoverableStatusError implements Exception { +/// platforms this error is never reported. +final class SseHttpError implements Exception { /// The HTTP status code of the response. final int statusCode; - /// The response headers, when available. May carry service directives - /// (e.g. protocol fallback instructions) even on error responses. + /// The response headers, lower-cased keys as provided by the transport. final Map headers; - const UnrecoverableStatusError(this.statusCode, - [this.headers = const {}]); + /// Whether the client will retry this connection on its own. + final bool recoverable; + + const SseHttpError( + this.statusCode, + this.headers, { + required this.recoverable, + }); @override - String toString() => 'UnrecoverableStatusError(statusCode: $statusCode)'; + String toString() => + 'SseHttpError(statusCode: $statusCode, recoverable: $recoverable)'; } diff --git a/packages/event_source_client/lib/src/state_connecting.dart b/packages/event_source_client/lib/src/state_connecting.dart index 0b11021e..d4a995de 100644 --- a/packages/event_source_client/lib/src/state_connecting.dart +++ b/packages/event_source_client/lib/src/state_connecting.dart @@ -58,18 +58,24 @@ class StateConnecting { if (response.statusCode != HttpStatusCodes.okStatus) { svo.logger.info( 'HTTP connection error occurred with status code ${response.statusCode}'); - if (!ErrorUtils.isHttpStatusCodeRecoverable(response.statusCode)) { - // looks like the error wasn't recoverable, go to idle and wait - // for something to change - return () => StateIdle.run(svo, - errorCause: UnrecoverableStatusError( - response.statusCode, response.headers)); + // Report every error response with its status and headers (which + // may carry a service directive). The recoverable flag tells the + // consumer whether we will retry on our own. + final recoverable = + ErrorUtils.isHttpStatusCodeRecoverable(response.statusCode); + final error = SseHttpError(response.statusCode, response.headers, + recoverable: recoverable); + if (!recoverable) { + // Not retried: report the error and go idle until something + // changes. + return () => StateIdle.run(svo, errorCause: error); } - - // the error is recoverable, backoff then we'll try again + // Retried: report the error, then back off and try again. The + // consumer may react (e.g. close the client) before the retry runs. return () { svo.logger.info( 'Recoverable HTTP status code ${response.statusCode}, will retry'); + svo.eventSink.addError(error); StateBackoff.run(svo); }; } diff --git a/packages/event_source_client/test/state_connecting_test.dart b/packages/event_source_client/test/state_connecting_test.dart index 795b9f2a..1d9f50a5 100644 --- a/packages/event_source_client/test/state_connecting_test.dart +++ b/packages/event_source_client/test/state_connecting_test.dart @@ -2,7 +2,8 @@ import 'dart:async'; -import 'package:launchdarkly_event_source_client/src/http_consts.dart'; +import 'package:launchdarkly_event_source_client/launchdarkly_event_source_client.dart' + show Event, SseHttpError; import 'package:launchdarkly_event_source_client/src/state_backoff.dart'; import 'package:launchdarkly_event_source_client/src/state_connected.dart'; import 'package:launchdarkly_event_source_client/src/state_connecting.dart'; @@ -22,28 +23,51 @@ void main() { await StateConnecting.run(svo); }); - test('Test connecting to backoff on recoverable error', () async { + test( + 'a recoverable error backs off and reports SseHttpError(recoverable: ' + 'true) with its headers', () async { final transitionController = StreamController.broadcast(); + final eventController = StreamController.broadcast(); final svo = TestUtils.makeMockStateValues( transitionSink: transitionController, + eventSink: eventController, clientFactory: () => TestUtils.makeMockHttpClient( - httpStatusCode: HttpStatusCodes.tooManyRequestsStatus)); + httpStatusCode: 503, headers: const {'x-ld-fd-fallback': 'true'})); expectLater(transitionController.stream, emitsInOrder([StateConnecting, StateBackoff])); + expectLater( + eventController.stream, + emitsError(isA() + .having((e) => e.statusCode, 'statusCode', 503) + .having((e) => e.recoverable, 'recoverable', true) + .having((e) => e.headers['x-ld-fd-fallback'], 'directive header', + 'true'))); await StateConnecting.run(svo); }); - test('Test connecting to idle on unrecoverable error', () async { + test( + 'an unrecoverable error goes idle and reports SseHttpError(recoverable: ' + 'false) with its headers', () async { final transitionController = StreamController.broadcast(); + final eventController = StreamController.broadcast(); final svo = TestUtils.makeMockStateValues( transitionSink: transitionController, - clientFactory: () => TestUtils.makeMockHttpClient(httpStatusCode: 404)); + eventSink: eventController, + clientFactory: () => TestUtils.makeMockHttpClient( + httpStatusCode: 401, headers: const {'x-ld-fd-fallback': 'true'})); expectLater(transitionController.stream, emitsInOrder([StateConnecting, StateIdle])); + expectLater( + eventController.stream, + emitsError(isA() + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.recoverable, 'recoverable', false) + .having((e) => e.headers['x-ld-fd-fallback'], 'directive header', + 'true'))); await StateConnecting.run(svo); }); diff --git a/packages/event_source_client/test/uri_provider_test.dart b/packages/event_source_client/test/uri_provider_test.dart index 465f3af0..2755933f 100644 --- a/packages/event_source_client/test/uri_provider_test.dart +++ b/packages/event_source_client/test/uri_provider_test.dart @@ -40,7 +40,7 @@ void main() { }); test( - 'reports UnrecoverableStatusError with the status code and headers ' + 'reports SseHttpError with the status code and headers ' 'for non-retryable status codes', () async { final eventsController = StreamController.broadcast(); final svo = TestUtils.makeMockStateValues( @@ -50,8 +50,9 @@ void main() { final expectation = expectLater( eventsController.stream, - emitsError(isA() + emitsError(isA() .having((error) => error.statusCode, 'statusCode', 401) + .having((error) => error.recoverable, 'recoverable', false) .having((error) => error.headers['x-ld-fd-fallback'], 'fallback header', 'true')));