Skip to content
34 changes: 32 additions & 2 deletions packages/aws_common/lib/src/exception/aws_http_exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,37 @@ class AWSHttpException implements Exception {
if (underlyingException is AWSHttpException) {
return underlyingException;
}
return AWSHttpException._(request.method, request.uri, underlyingException);
return AWSHttpException._(
request.method,
request.uri,
underlyingException,
false,
);
}

const AWSHttpException._(this.method, this.uri, this.underlyingException);
/// Creates an exception for a transport-level failure that is safe to retry.
factory AWSHttpException.retryable(
AWSBaseHttpRequest request, [
Object? underlyingException,
]) {
// Anti-double-wrap guard; callers pass the raw transport error.
if (underlyingException is AWSHttpException) {
return underlyingException;
}
return AWSHttpException._(
request.method,
request.uri,
underlyingException,
true,
);
}

const AWSHttpException._(
this.method,
this.uri,
this.underlyingException,
this.retryable,
);

/// The method of the HTTP operation which was in progress.
final AWSHttpMethod method;
Expand All @@ -29,6 +56,9 @@ class AWSHttpException implements Exception {
/// The exception which triggered this exception being thrown.
final Object? underlyingException;

/// Whether this is a transport-level failure that is safe to retry.
final bool retryable;

@override
String toString() =>
'${method.value} $uri failed'
Expand Down
6 changes: 3 additions & 3 deletions packages/aws_common/lib/src/http/aws_http_client_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -351,14 +351,14 @@ class AWSHttpClientImpl extends AWSHttpClient {
logger.debug('Error in stream: $error');
if (!gotHeaders.isCompleted) {
gotHeaders.completeError(
AWSHttpException(request, error),
AWSHttpException.retryable(request, error),
stackTrace,
);
return;
}
if (!bodyController.isClosed) {
bodyController
..addError(AWSHttpException(request, error), stackTrace)
..addError(AWSHttpException.retryable(request, error), stackTrace)
..close();
}
},
Expand Down Expand Up @@ -528,7 +528,7 @@ class AWSHttpClientImpl extends AWSHttpClient {
),
);
}).catchError((Object e, StackTrace st) {
completer.completeError(AWSHttpException(request, e), st);
completer.completeError(AWSHttpException.retryable(request, e), st);
});

return operation;
Expand Down
5 changes: 3 additions & 2 deletions packages/aws_common/lib/src/http/aws_http_client_js.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class AWSHttpClientImpl extends AWSHttpClient {
);
completer.complete(streamedResponse);
} on Object catch (e, st) {
completer.completeError(AWSHttpException(request, e), st);
completer.completeError(AWSHttpException.retryable(request, e), st);
}
}

Expand Down Expand Up @@ -213,7 +213,8 @@ class AWSHttpClientImpl extends AWSHttpClient {
completer: completer,
cancelTrigger: cancelTrigger,
).catchError(
(Object e, st) => completer.completeError(AWSHttpException(request, e)),
(Object e, st) =>
completer.completeError(AWSHttpException.retryable(request, e)),
);
_openConnections.add(WeakReference(operation));
return operation;
Expand Down
35 changes: 35 additions & 0 deletions packages/aws_common/test/http/retryable_transport_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

@TestOn('vm')
library;

import 'dart:io';

import 'package:aws_common/aws_common.dart';
import 'package:test/test.dart';

void main() {
group('AWSHttpClient transport failures', () {
test('flags a failed connection as a retryable AWSHttpException', () async {
// Bind then release a port so the connection is guaranteed to fail.
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = socket.port;
await socket.close();

final client = AWSHttpClient();
addTearDown(client.close);
final request = AWSHttpRequest.get(Uri.parse('http://127.0.0.1:$port/'));
await expectLater(
client.send(request).response,
throwsA(
isA<AWSHttpException>().having(
(e) => e.retryable,
'retryable',
isTrue,
),
),
);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ class AWSRetryer implements Retryer {
if (exception is TimeoutException) {
return true;
}
// Transport-level failures are flagged retryable by the HTTP client.
if (exception is AWSHttpException && exception.retryable) {
return true;
}
if (exception is! SmithyException) {
return false;
}
Expand Down
50 changes: 50 additions & 0 deletions packages/smithy/smithy_aws/test/http/aws_retryer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,56 @@ void main() {
});
}
});

group('isRetryable', () {
final retryer = AWSRetryer();
final request = AWSHttpRequest.get(Uri.parse('https://example.com'));

test('retries transport-level AWSHttpException (retryable flag)', () {
expect(
retryer.isRetryable(AWSHttpException.retryable(request)),
isTrue,
);
});

test('does not retry a non-transport AWSHttpException', () {
expect(retryer.isRetryable(AWSHttpException(request)), isFalse);
});

test('retries TimeoutException', () {
expect(retryer.isRetryable(TimeoutException('slow')), isTrue);
});

test('does not retry non-transport, non-Smithy exceptions', () {
expect(retryer.isRetryable(const FormatException('bad')), isFalse);
});
});

group('retry recovery', () {
test('recovers after a transient AWSHttpException (retries then '
'succeeds)', () async {
await runZoned(() async {
// exponentialBase: 0 => zero backoff, so the test is fast.
final retryer = AWSRetryer(exponentialBase: 0);
final request = AWSHttpRequest.get(Uri.parse('https://example.com'));
var attempts = 0;
final result = await retryer.retry<int>(() {
attempts++;
final completer = CancelableCompleter<int>();
if (attempts < 2) {
completer.completeError(
AWSHttpException.retryable(request, Exception('reset')),
);
} else {
completer.complete(42);
}
return completer.operation;
}).valueOrCancellation();
expect(attempts, 2, reason: 'should retry once, then succeed');
expect(result, 42, reason: 'should return the retried result');
}, zoneValues: {AWSConfigValue.maxAttempts: 3});
});
});
});
}

Expand Down
Loading