Initial implementation (#1)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..da41cf2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+Middleware for the [`http`](https://pub.dartlang.org/packages/http) package that
+transparently retries failing requests.
+
+To use this, just create an [`RetryClient`][RetryClient] that wraps the
+underlying [`http.Client`][Client]:
+
+[RetryClient]: https://www.dartdocs.org/documentation/http_retry/latest/http_retry/RetryClient-class.html
+[Client]: https://www.dartdocs.org/documentation/http/latest/http/Client-class.html
+
+```dart
+import 'package:http/http.dart' as http;
+import 'package:http_retry/http_retry.dart';
+
+main() async {
+ var client = new RetryClient(new http.Client());
+ print(await client.read("http://example.org"));
+ await client.close();
+}
+```
+
+By default, this retries any request whose response has status code 503
+Temporary Failure up to three retries. It waits 500ms before the first retry,
+and increases the delay by 1.5x each time. All of this can be customized using
+the [`new RetryClient()`][new RetryClient] constructor.
+
+[new RetryClient]: https://www.dartdocs.org/documentation/http_retry/latest/http_retry/RetryClient/RetryClient.html
diff --git a/lib/http_retry.dart b/lib/http_retry.dart
new file mode 100644
index 0000000..03cebd8
--- /dev/null
+++ b/lib/http_retry.dart
@@ -0,0 +1,99 @@
+// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:async/async.dart';
+import 'package:http/http.dart';
+
+/// An HTTP client wrapper that automatically retries failing requests.
+class RetryClient extends BaseClient {
+ /// The wrapped client.
+ final Client _inner;
+
+ /// The number of times a request should be retried.
+ final int _retries;
+
+ /// The callback that determines whether a request should be retried.
+ final bool Function(StreamedResponse) _when;
+
+ /// The callback that determines how long to wait before retrying a request.
+ final Duration Function(int) _delay;
+
+ /// Creates a client wrapping [inner] that retries HTTP requests.
+ ///
+ /// This retries a failing request [retries] times (3 by default). Note that
+ /// `n` retries means that the request will be sent at most `n + 1` times.
+ ///
+ /// By default, this retries requests whose responses have status code 503
+ /// Temporary Failure. If [when] is passed, it retries any request for whose
+ /// response [when] returns `true`.
+ ///
+ /// By default, this waits 500ms between the original request and the first
+ /// retry, then increases the delay by 1.5x for each subsequent retry. If
+ /// [delay] is passed, it's used to determine the time to wait before the
+ /// given (zero-based) retry.
+ RetryClient(this._inner,
+ {int retries,
+ bool when(StreamedResponse response),
+ Duration delay(int retryCount)})
+ : _retries = retries ?? 3,
+ _when = when ?? ((response) => response.statusCode == 503),
+ _delay = delay ??
+ ((retryCount) =>
+ new Duration(milliseconds: 500) * math.pow(1.5, retryCount)) {
+ RangeError.checkNotNegative(_retries, "retries");
+ }
+
+ /// Like [new RetryClient], but with a pre-computed list of [delays]
+ /// between each retry.
+ ///
+ /// This will retry a request at most `delays.length` times, using each delay
+ /// in order. It will wait for `delays[0]` after the initial request,
+ /// `delays[1]` after the first retry, and so on.
+ RetryClient.withDelays(Client inner, Iterable<Duration> delays,
+ {bool when(StreamedResponse response)})
+ : this._withDelays(inner, delays.toList(), when: when);
+
+ RetryClient._withDelays(Client inner, List<Duration> delays,
+ {bool when(StreamedResponse response)})
+ : this(inner,
+ retries: delays.length, delay: (retryCount) => delays[retryCount]);
+
+ Future<StreamedResponse> send(BaseRequest request) async {
+ var splitter = new StreamSplitter(request.finalize());
+
+ var i = 0;
+ while (true) {
+ var response = await _inner.send(_copyRequest(request, splitter.split()));
+ if (i == _retries || !_when(response)) return response;
+
+ // Make sure the response stream is listened to so that we don't leave
+ // dangling connections.
+ response.stream.listen((_) {}).cancel()?.catchError((_) {});
+ await new Future.delayed(_delay(i));
+ i++;
+ }
+ }
+
+ /// Returns a copy of [original] with the given [body].
+ StreamedRequest _copyRequest(BaseRequest original, Stream<List<int>> body) {
+ var request = new StreamedRequest(original.method, original.url);
+ request.contentLength = original.contentLength;
+ request.followRedirects = original.followRedirects;
+ request.headers.addAll(original.headers);
+ request.maxRedirects = original.maxRedirects;
+ request.persistentConnection = original.persistentConnection;
+
+ body.listen(request.sink.add,
+ onError: request.sink.addError,
+ onDone: request.sink.close,
+ cancelOnError: true);
+
+ return request;
+ }
+
+ void close() => _inner.close();
+}
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..6af9806
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,16 @@
+name: http_retry
+version: 0.1.0-dev
+description: HTTP client middleware that automatically retries requests.
+author: Dart Team <misc@dartlang.org>
+homepage: https://github.com/dart-lang/http_retry
+
+environment:
+ sdk: '>=1.24.0 <2.0.0'
+
+dependencies:
+ async: ">=1.2.0 <=3.0.0"
+ http: "^0.11.0"
+
+dev_dependencies:
+ fake_async: "^0.1.2"
+ test: "^0.12.0"
diff --git a/test/http_retry_test.dart b/test/http_retry_test.dart
new file mode 100644
index 0000000..3ba61b4
--- /dev/null
+++ b/test/http_retry_test.dart
@@ -0,0 +1,190 @@
+// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:fake_async/fake_async.dart';
+import 'package:http/http.dart';
+import 'package:http/testing.dart';
+import 'package:test/test.dart';
+
+import 'package:http_retry/http_retry.dart';
+
+void main() {
+ group("doesn't retry when", () {
+ test("a request has a non-503 error code", () async {
+ var client = new RetryClient(new MockClient(
+ expectAsync1((_) async => new Response("", 502), count: 1)));
+ var response = await client.get("http://example.org");
+ expect(response.statusCode, equals(502));
+ });
+
+ test("a request doesn't match when()", () async {
+ var client = new RetryClient(
+ new MockClient(
+ expectAsync1((_) async => new Response("", 503), count: 1)),
+ when: (_) => false);
+ var response = await client.get("http://example.org");
+ expect(response.statusCode, equals(503));
+ });
+
+ test("retries is 0", () async {
+ var client = new RetryClient(
+ new MockClient(
+ expectAsync1((_) async => new Response("", 503), count: 1)),
+ retries: 0);
+ var response = await client.get("http://example.org");
+ expect(response.statusCode, equals(503));
+ });
+ });
+
+ test("retries on a 503 by default", () async {
+ var count = 0;
+ var client = new RetryClient(
+ new MockClient(expectAsync1((request) async {
+ count++;
+ return count < 2 ? new Response("", 503) : new Response("", 200);
+ }, count: 2)),
+ delay: (_) => Duration.ZERO);
+
+ var response = await client.get("http://example.org");
+ expect(response.statusCode, equals(200));
+ });
+
+ test("retries on any request where when() returns true", () async {
+ var count = 0;
+ var client = new RetryClient(
+ new MockClient(expectAsync1((request) async {
+ count++;
+ return new Response("", 503,
+ headers: {"retry": count < 2 ? "true" : "false"});
+ }, count: 2)),
+ when: (response) => response.headers["retry"] == "true",
+ delay: (_) => Duration.ZERO);
+
+ var response = await client.get("http://example.org");
+ expect(response.headers, containsPair("retry", "false"));
+ expect(response.statusCode, equals(503));
+ });
+
+ test("retries three times by default", () async {
+ var client = new RetryClient(
+ new MockClient(
+ expectAsync1((_) async => new Response("", 503), count: 4)),
+ delay: (_) => Duration.ZERO);
+ var response = await client.get("http://example.org");
+ expect(response.statusCode, equals(503));
+ });
+
+ test("retries the given number of times", () async {
+ var client = new RetryClient(
+ new MockClient(
+ expectAsync1((_) async => new Response("", 503), count: 13)),
+ retries: 12,
+ delay: (_) => Duration.ZERO);
+ var response = await client.get("http://example.org");
+ expect(response.statusCode, equals(503));
+ });
+
+ test("waits 1.5x as long each time by default", () {
+ new FakeAsync().run((fake) {
+ var count = 0;
+ var client = new RetryClient(new MockClient(expectAsync1((_) async {
+ count++;
+ if (count == 1) {
+ expect(fake.elapsed, equals(Duration.ZERO));
+ } else if (count == 2) {
+ expect(fake.elapsed, equals(new Duration(milliseconds: 500)));
+ } else if (count == 3) {
+ expect(fake.elapsed, equals(new Duration(milliseconds: 1250)));
+ } else if (count == 4) {
+ expect(fake.elapsed, equals(new Duration(milliseconds: 2375)));
+ }
+
+ return new Response("", 503);
+ }, count: 4)));
+
+ expect(client.get("http://example.org"), completes);
+ fake.elapse(new Duration(minutes: 10));
+ });
+ });
+
+ test("waits according to the delay parameter", () {
+ new FakeAsync().run((fake) {
+ var count = 0;
+ var client = new RetryClient(
+ new MockClient(expectAsync1((_) async {
+ count++;
+ if (count == 1) {
+ expect(fake.elapsed, equals(Duration.ZERO));
+ } else if (count == 2) {
+ expect(fake.elapsed, equals(Duration.ZERO));
+ } else if (count == 3) {
+ expect(fake.elapsed, equals(new Duration(seconds: 1)));
+ } else if (count == 4) {
+ expect(fake.elapsed, equals(new Duration(seconds: 3)));
+ }
+
+ return new Response("", 503);
+ }, count: 4)),
+ delay: (requestCount) => new Duration(seconds: requestCount));
+
+ expect(client.get("http://example.org"), completes);
+ fake.elapse(new Duration(minutes: 10));
+ });
+ });
+
+ test("waits according to the delay list", () {
+ new FakeAsync().run((fake) {
+ var count = 0;
+ var client = new RetryClient.withDelays(
+ new MockClient(expectAsync1((_) async {
+ count++;
+ if (count == 1) {
+ expect(fake.elapsed, equals(Duration.ZERO));
+ } else if (count == 2) {
+ expect(fake.elapsed, equals(new Duration(seconds: 1)));
+ } else if (count == 3) {
+ expect(fake.elapsed, equals(new Duration(seconds: 61)));
+ } else if (count == 4) {
+ expect(fake.elapsed, equals(new Duration(seconds: 73)));
+ }
+
+ return new Response("", 503);
+ }, count: 4)),
+ [
+ new Duration(seconds: 1),
+ new Duration(minutes: 1),
+ new Duration(seconds: 12)
+ ]);
+
+ expect(client.get("http://example.org"), completes);
+ fake.elapse(new Duration(minutes: 10));
+ });
+ });
+
+ test("copies all request attributes for each attempt", () async {
+ var client = new RetryClient.withDelays(
+ new MockClient(expectAsync1((request) async {
+ expect(request.contentLength, equals(5));
+ expect(request.followRedirects, isFalse);
+ expect(request.headers, containsPair("foo", "bar"));
+ expect(request.maxRedirects, equals(12));
+ expect(request.method, equals("POST"));
+ expect(request.persistentConnection, isFalse);
+ expect(request.url, equals(Uri.parse("http://example.org")));
+ expect(request.body, equals("hello"));
+ return new Response("", 503);
+ }, count: 2)),
+ [Duration.ZERO]);
+
+ var request = new Request("POST", Uri.parse("http://example.org"));
+ request.body = "hello";
+ request.followRedirects = false;
+ request.headers["foo"] = "bar";
+ request.maxRedirects = 12;
+ request.persistentConnection = false;
+
+ var response = await client.send(request);
+ expect(response.statusCode, equals(503));
+ });
+}