Skip to content

Commit 2343975

Browse files
committed
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. Previously recoverable responses surfaced nothing, so a directive delivered on, say, a 5xx was invisible; now the consumer can read it and decide. The browser EventSource cannot observe responses and reports nothing, as before.
1 parent b3b7c09 commit 2343975

5 files changed

Lines changed: 71 additions & 28 deletions

File tree

packages/event_source_client/lib/launchdarkly_event_source_client.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import 'src/sse_client_stub.dart'
1212
if (dart.library.js_interop) 'src/sse_client_html.dart';
1313
import 'src/test_sse_client.dart';
1414

15-
export 'src/errors.dart' show UnrecoverableStatusError;
15+
export 'src/errors.dart' show SseHttpError;
1616
export 'src/events.dart' show Event, MessageEvent, OpenEvent;
1717
export 'src/test_sse_client.dart' show TestSseClient;
1818
export 'src/logging.dart'
Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
1-
/// Reported on the event stream when the server responds with an HTTP
2-
/// status code that the client will not retry (anything other than 200,
3-
/// 400, 408, 429, or 5xx). After reporting this error the client stops
4-
/// reconnecting until connection desire changes.
1+
/// Reported on the event stream when the server responds with a non-200
2+
/// HTTP status. Carries the status code and the response headers, which
3+
/// may hold service directives (e.g. protocol fallback instructions) even
4+
/// on error responses.
5+
///
6+
/// [recoverable] indicates what the client does next:
7+
/// - `true`: the status is one the client retries, so it has scheduled a
8+
/// reconnect with backoff. The subscription stays open; the error is an
9+
/// advisory the consumer may act on (or ignore and let the retry run).
10+
/// - `false`: the client will not retry. It stops reconnecting until
11+
/// connection desire changes.
512
///
613
/// Only produced by implementations whose transport can observe HTTP
714
/// responses. The browser's native `EventSource` cannot, so on `html`
8-
/// platforms this error is never reported and the client retries every
9-
/// failure indefinitely.
10-
final class UnrecoverableStatusError implements Exception {
15+
/// platforms this error is never reported.
16+
final class SseHttpError implements Exception {
1117
/// The HTTP status code of the response.
1218
final int statusCode;
1319

14-
/// The response headers, when available. May carry service directives
15-
/// (e.g. protocol fallback instructions) even on error responses.
20+
/// The response headers, lower-cased keys as provided by the transport.
1621
final Map<String, String> headers;
1722

18-
const UnrecoverableStatusError(this.statusCode,
19-
[this.headers = const <String, String>{}]);
23+
/// Whether the client will retry this connection on its own.
24+
final bool recoverable;
25+
26+
const SseHttpError(
27+
this.statusCode,
28+
this.headers, {
29+
required this.recoverable,
30+
});
2031

2132
@override
22-
String toString() => 'UnrecoverableStatusError(statusCode: $statusCode)';
33+
String toString() =>
34+
'SseHttpError(statusCode: $statusCode, recoverable: $recoverable)';
2335
}

packages/event_source_client/lib/src/state_connecting.dart

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,24 @@ class StateConnecting {
5858
if (response.statusCode != HttpStatusCodes.okStatus) {
5959
svo.logger.info(
6060
'HTTP connection error occurred with status code ${response.statusCode}');
61-
if (!ErrorUtils.isHttpStatusCodeRecoverable(response.statusCode)) {
62-
// looks like the error wasn't recoverable, go to idle and wait
63-
// for something to change
64-
return () => StateIdle.run(svo,
65-
errorCause: UnrecoverableStatusError(
66-
response.statusCode, response.headers));
61+
// Report every error response with its status and headers (which
62+
// may carry a service directive). The recoverable flag tells the
63+
// consumer whether we will retry on our own.
64+
final recoverable =
65+
ErrorUtils.isHttpStatusCodeRecoverable(response.statusCode);
66+
final error = SseHttpError(response.statusCode, response.headers,
67+
recoverable: recoverable);
68+
if (!recoverable) {
69+
// Not retried: report the error and go idle until something
70+
// changes.
71+
return () => StateIdle.run(svo, errorCause: error);
6772
}
68-
69-
// the error is recoverable, backoff then we'll try again
73+
// Retried: report the error, then back off and try again. The
74+
// consumer may react (e.g. close the client) before the retry runs.
7075
return () {
7176
svo.logger.info(
7277
'Recoverable HTTP status code ${response.statusCode}, will retry');
78+
svo.eventSink.addError(error);
7379
StateBackoff.run(svo);
7480
};
7581
}

packages/event_source_client/test/state_connecting_test.dart

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import 'dart:async';
44

5-
import 'package:launchdarkly_event_source_client/src/http_consts.dart';
5+
import 'package:launchdarkly_event_source_client/launchdarkly_event_source_client.dart'
6+
show Event, SseHttpError;
67
import 'package:launchdarkly_event_source_client/src/state_backoff.dart';
78
import 'package:launchdarkly_event_source_client/src/state_connected.dart';
89
import 'package:launchdarkly_event_source_client/src/state_connecting.dart';
@@ -22,28 +23,51 @@ void main() {
2223
await StateConnecting.run(svo);
2324
});
2425

25-
test('Test connecting to backoff on recoverable error', () async {
26+
test(
27+
'a recoverable error backs off and reports SseHttpError(recoverable: '
28+
'true) with its headers', () async {
2629
final transitionController = StreamController<dynamic>.broadcast();
30+
final eventController = StreamController<Event>.broadcast();
2731

2832
final svo = TestUtils.makeMockStateValues(
2933
transitionSink: transitionController,
34+
eventSink: eventController,
3035
clientFactory: () => TestUtils.makeMockHttpClient(
31-
httpStatusCode: HttpStatusCodes.tooManyRequestsStatus));
36+
httpStatusCode: 503, headers: const {'x-ld-fd-fallback': 'true'}));
3237

3338
expectLater(transitionController.stream,
3439
emitsInOrder([StateConnecting, StateBackoff]));
40+
expectLater(
41+
eventController.stream,
42+
emitsError(isA<SseHttpError>()
43+
.having((e) => e.statusCode, 'statusCode', 503)
44+
.having((e) => e.recoverable, 'recoverable', true)
45+
.having((e) => e.headers['x-ld-fd-fallback'], 'directive header',
46+
'true')));
3547
await StateConnecting.run(svo);
3648
});
3749

38-
test('Test connecting to idle on unrecoverable error', () async {
50+
test(
51+
'an unrecoverable error goes idle and reports SseHttpError(recoverable: '
52+
'false) with its headers', () async {
3953
final transitionController = StreamController<dynamic>.broadcast();
54+
final eventController = StreamController<Event>.broadcast();
4055

4156
final svo = TestUtils.makeMockStateValues(
4257
transitionSink: transitionController,
43-
clientFactory: () => TestUtils.makeMockHttpClient(httpStatusCode: 404));
58+
eventSink: eventController,
59+
clientFactory: () => TestUtils.makeMockHttpClient(
60+
httpStatusCode: 401, headers: const {'x-ld-fd-fallback': 'true'}));
4461

4562
expectLater(transitionController.stream,
4663
emitsInOrder([StateConnecting, StateIdle]));
64+
expectLater(
65+
eventController.stream,
66+
emitsError(isA<SseHttpError>()
67+
.having((e) => e.statusCode, 'statusCode', 401)
68+
.having((e) => e.recoverable, 'recoverable', false)
69+
.having((e) => e.headers['x-ld-fd-fallback'], 'directive header',
70+
'true')));
4771
await StateConnecting.run(svo);
4872
});
4973

packages/event_source_client/test/uri_provider_test.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ void main() {
4040
});
4141

4242
test(
43-
'reports UnrecoverableStatusError with the status code and headers '
43+
'reports SseHttpError with the status code and headers '
4444
'for non-retryable status codes', () async {
4545
final eventsController = StreamController<Event>.broadcast();
4646
final svo = TestUtils.makeMockStateValues(
@@ -50,8 +50,9 @@ void main() {
5050

5151
final expectation = expectLater(
5252
eventsController.stream,
53-
emitsError(isA<UnrecoverableStatusError>()
53+
emitsError(isA<SseHttpError>()
5454
.having((error) => error.statusCode, 'statusCode', 401)
55+
.having((error) => error.recoverable, 'recoverable', false)
5556
.having((error) => error.headers['x-ld-fd-fallback'],
5657
'fallback header', 'true')));
5758

0 commit comments

Comments
 (0)