Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
36 changes: 24 additions & 12 deletions packages/event_source_client/lib/src/errors.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> headers;

const UnrecoverableStatusError(this.statusCode,
[this.headers = const <String, String>{}]);
/// 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)';
}
22 changes: 14 additions & 8 deletions packages/event_source_client/lib/src/state_connecting.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}
Expand Down
34 changes: 29 additions & 5 deletions packages/event_source_client/test/state_connecting_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<dynamic>.broadcast();
final eventController = StreamController<Event>.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<SseHttpError>()
.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<dynamic>.broadcast();
final eventController = StreamController<Event>.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<SseHttpError>()
.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);
});

Expand Down
5 changes: 3 additions & 2 deletions packages/event_source_client/test/uri_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event>.broadcast();
final svo = TestUtils.makeMockStateValues(
Expand All @@ -50,8 +50,9 @@ void main() {

final expectation = expectLater(
eventsController.stream,
emitsError(isA<UnrecoverableStatusError>()
emitsError(isA<SseHttpError>()
.having((error) => error.statusCode, 'statusCode', 401)
.having((error) => error.recoverable, 'recoverable', false)
.having((error) => error.headers['x-ld-fd-fallback'],
'fallback header', 'true')));

Expand Down
Loading