NetworkImageWithRetry: a retrying network image provider (#3)
* implement network image with retry
* NetworkImageWithRetry: a retrying network image provider
* add FLUTTER_HOME to travis
* move default values into constructor
diff --git a/packages/flutter_image/.travis.yml b/packages/flutter_image/.travis.yml
index 851489c..bd3e106 100644
--- a/packages/flutter_image/.travis.yml
+++ b/packages/flutter_image/.travis.yml
@@ -14,6 +14,7 @@
 before_script:
   - git clone https://github.com/flutter/flutter.git -b alpha --depth 1
   - export PATH=$PATH:$(pwd)/flutter/bin
+  - export FLUTTER_HOME=$(pwd)/flutter
   - flutter doctor
 
 script: ./tool/travis.sh
diff --git a/packages/flutter_image/lib/network.dart b/packages/flutter_image/lib/network.dart
index 0d79637..cd86d7e 100644
--- a/packages/flutter_image/lib/network.dart
+++ b/packages/flutter_image/lib/network.dart
@@ -9,8 +9,419 @@
 /// mechanism and connectivity detection.
 library network;
 
+import 'dart:async';
+import 'dart:io' as io;
+import 'dart:math' as math;
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter/widgets.dart';
 
-void main() {
-  const NetworkImage('');
+/// Fetches the image from the given URL, associating it with the given scale.
+///
+/// If [fetchStrategy] is specified, uses it instead of the
+/// [defaultFetchStrategy] to obtain instructions for fetching the URL.
+///
+/// The image will be cached regardless of cache headers from the server.
+class NetworkImageWithRetry extends ImageProvider<NetworkImageWithRetry> {
+  /// Creates an object that fetches the image at the given [url].
+  ///
+  /// The arguments must not be null.
+  const NetworkImageWithRetry(this.url, { this.scale: 1.0, this.fetchStrategy: defaultFetchStrategy })
+      : assert(url != null),
+        assert(scale != null),
+        assert(fetchStrategy != null);
+
+  /// The HTTP client used to download images.
+  static final io.HttpClient _client = new io.HttpClient();
+
+  /// The URL from which the image will be fetched.
+  final String url;
+
+  /// The scale to place in the [ImageInfo] object of the image.
+  final double scale;
+
+  /// The strategy used to fetch the [url] and retry when the fetch fails.
+  ///
+  /// This function is called at least once and may be called multiple times.
+  /// The first time it is called, it is passed a null [FetchFailure], which
+  /// indicates that this is the first attempt to fetch the [url]. Subsequent
+  /// calls pass non-null [FetchFailure] values, which indicate that previous
+  /// fetch attempts failed.
+  final FetchStrategy fetchStrategy;
+
+  /// Used by [defaultFetchStrategy].
+  ///
+  /// This indirection is necessary because [defaultFetchStrategy] is used as
+  /// the default constructor argument value, which requires that it be a const
+  /// expression.
+  static final FetchStrategy _defaultFetchStrategyFunction = const FetchStrategyBuilder().build();
+
+  /// The [FetchStrategy] that [NetworkImageWithRetry] uses by default.
+  static Future<FetchInstructions> defaultFetchStrategy(Uri uri, FetchFailure failure) {
+    return _defaultFetchStrategyFunction(uri, failure);
+  }
+
+  @override
+  Future<NetworkImageWithRetry> obtainKey(ImageConfiguration configuration) {
+    return new SynchronousFuture<NetworkImageWithRetry>(this);
+  }
+
+  @override
+  ImageStreamCompleter load(NetworkImageWithRetry key) {
+    return new OneFrameImageStreamCompleter(
+        _loadWithRetry(key),
+        informationCollector: (StringBuffer information) {
+          information.writeln('Image provider: $this');
+          information.write('Image key: $key');
+        }
+    );
+  }
+
+  void _debugCheckInstructions(FetchInstructions instructions) {
+    assert(() {
+      if (instructions == null) {
+        if (fetchStrategy == defaultFetchStrategy) {
+          throw new StateError(
+            'The default FetchStrategy returned null FetchInstructions. This\n'
+            'is likely a bug in $runtimeType. Please file a bug at\n'
+            'https://github.com/flutter/flutter/issues.'
+          );
+        } else {
+          throw new StateError(
+            'The custom FetchStrategy used to fetch $url returned null\n'
+            'FetchInstructions. FetchInstructions must never be null, but\n'
+            'instead instruct to either make another fetch attempt or give up.'
+          );
+        }
+      }
+      return true;
+    });
+  }
+
+  Future<ImageInfo> _loadWithRetry(NetworkImageWithRetry key) async {
+    assert(key == this);
+
+    final Stopwatch stopwatch = new Stopwatch()..start();
+    final Uri resolved = Uri.base.resolve(key.url);
+    FetchInstructions instructions = await fetchStrategy(resolved, null);
+    _debugCheckInstructions(instructions);
+    int attemptCount = 0;
+    FetchFailure lastFailure;
+
+    while (!instructions.shouldGiveUp) {
+      attemptCount += 1;
+      io.HttpClientRequest request;
+      try {
+        request = await _client.getUrl(instructions.uri).timeout(instructions.timeout);
+        final io.HttpClientResponse response = await request.close().timeout(instructions.timeout);
+
+        if (response == null || response.statusCode != 200) {
+          throw new FetchFailure._(
+            totalDuration: stopwatch.elapsed,
+            attemptCount: attemptCount,
+            httpStatusCode: response.statusCode,
+          );
+        }
+
+        final _Uint8ListBuilder builder = await response.fold(
+          new _Uint8ListBuilder(),
+              (_Uint8ListBuilder buffer, List<int> bytes) => buffer..add(bytes),
+        ).timeout(instructions.timeout);
+
+        final Uint8List bytes = builder.data;
+
+        if (bytes.lengthInBytes == 0)
+          return null;
+
+        final ui.Image image = await decodeImageFromList(bytes);
+        if (image == null)
+          return null;
+
+        return new ImageInfo(
+          image: image,
+          scale: key.scale,
+        );
+      } catch (error) {
+        request?.close();
+        lastFailure = error is FetchFailure
+            ? error
+            : new FetchFailure._(
+                totalDuration: stopwatch.elapsed,
+                attemptCount: attemptCount,
+                originalException: error,
+              );
+        instructions = await fetchStrategy(instructions.uri, lastFailure);
+        _debugCheckInstructions(instructions);
+      }
+    }
+
+    if (instructions.alternativeImage != null)
+      return instructions.alternativeImage;
+
+    assert(lastFailure != null);
+
+    FlutterError.onError(new FlutterErrorDetails(
+      exception: lastFailure,
+      library: 'package:flutter_image',
+      context: '$runtimeType failed to load ${instructions.uri}',
+    ));
+
+    return null;
+  }
+
+  @override
+  bool operator ==(dynamic other) {
+    if (other.runtimeType != runtimeType)
+      return false;
+    final NetworkImageWithRetry typedOther = other;
+    return url == typedOther.url
+        && scale == typedOther.scale;
+  }
+
+  @override
+  int get hashCode => hashValues(url, scale);
+
+  @override
+  String toString() => '$runtimeType("$url", scale: $scale)';
+}
+
+/// This function is called to get [FetchInstructions] to fetch an image.
+///
+/// The instructions are executed as soon as possible after the returned
+/// [Future] resolves. If a delay in necessary between retries, use a delayed
+/// [Future], such as [new Future.delayed]. This is useful for implementing
+/// back-off strategies and for recovering from lack of connectivity.
+///
+/// [uri] is the last requested image URI. A [FetchStrategy] may choose to use
+/// a different URI (see [FetchInstructions.uri]).
+///
+/// If [failure] is `null`, then this is the first attempt to fetch the image.
+///
+/// If the [failure] is not `null`, it contains the information about the
+/// previous attempt to fetch the image. A [FetchStrategy] may attempt to
+/// recover from the failure by returning [FetchInstructions] that instruct
+/// [NetworkImageWithRetry] to try again.
+///
+/// See [NetworkImageWithRetry.defaultFetchStrategy] for an example.
+typedef Future<FetchInstructions> FetchStrategy(Uri uri, FetchFailure failure);
+
+/// Instructions [NetworkImageWithRetry] uses to fetch the image.
+@immutable
+class FetchInstructions {
+  /// Instructs [NetworkImageWithRetry] to give up trying to download the image.
+  const FetchInstructions.giveUp({
+    @required this.uri,
+    this.alternativeImage,
+  })
+      : shouldGiveUp = true,
+        timeout = null;
+
+  /// Instructs [NetworkImageWithRetry] to attempt to download the image from
+  /// the given [uri] and [timeout] if it takes too long.
+  const FetchInstructions.attempt({
+    @required this.uri,
+    @required this.timeout,
+  }) : shouldGiveUp = false,
+       alternativeImage = null;
+
+  /// Instructs to give up trying.
+  ///
+  /// If [alternativeImage] is `null` reports the latest [FetchFailure] to
+  /// [FlutterError].
+  final bool shouldGiveUp;
+
+  /// Timeout for the next network call.
+  final Duration timeout;
+
+  /// The URI to use on the next attempt.
+  final Uri uri;
+
+  /// Instructs to give up and use this image instead.
+  final Future<ImageInfo> alternativeImage;
+
+  @override
+  String toString() {
+    return '$runtimeType(\n'
+      '  shouldGiveUp: $shouldGiveUp\n'
+      '  timeout: $timeout\n'
+      '  uri: $uri\n'
+      '  alternativeImage?: ${alternativeImage != null ? 'yes' : 'no'}\n'
+      ')';
+  }
+}
+
+/// Contains information about a failed attempt to fetch an image.
+@immutable
+class FetchFailure implements Exception {
+  const FetchFailure._({
+    @required this.totalDuration,
+    @required this.attemptCount,
+    this.httpStatusCode,
+    this.originalException,
+  }) : assert(totalDuration != null),
+       assert(attemptCount > 0);
+
+  /// The total amount of time it has taken so far to download the image.
+  final Duration totalDuration;
+
+  /// The number of times [NetworkImageWithRetry] attempted to fetch the image
+  /// so far.
+  ///
+  /// This value starts with 1 and grows by 1 with each attempt to fetch the
+  /// image.
+  final int attemptCount;
+
+  /// HTTP status code, such as 500.
+  final int httpStatusCode;
+
+  /// The exception that caused the fetch failure.
+  final dynamic originalException;
+
+  @override
+  String toString() {
+    return '$runtimeType(\n'
+        '  attemptCount: $attemptCount\n'
+        '  httpStatusCode: $httpStatusCode\n'
+        '  totalDuration: $totalDuration\n'
+        '  originalException: $originalException\n'
+        ')';
+  }
+}
+
+/// An indefinitely growing builder of a [Uint8List].
+class _Uint8ListBuilder {
+  static const int _kInitialSize = 100000;  // 100KB-ish
+
+  int _usedLength = 0;
+  Uint8List _buffer = new Uint8List(_kInitialSize);
+
+  Uint8List get data => new Uint8List.view(_buffer.buffer, 0, _usedLength);
+
+  void add(List<int> bytes) {
+    _ensureCanAdd(bytes.length);
+    _buffer.setAll(_usedLength, bytes);
+    _usedLength += bytes.length;
+  }
+
+  void _ensureCanAdd(int byteCount) {
+    final int totalSpaceNeeded = _usedLength + byteCount;
+
+    int newLength = _buffer.length;
+    while (totalSpaceNeeded > newLength) {
+      newLength *= 2;
+    }
+
+    if (newLength != _buffer.length) {
+      final Uint8List newBuffer = new Uint8List(newLength);
+      newBuffer.setAll(0, _buffer);
+      newBuffer.setRange(0, _usedLength, _buffer);
+      _buffer = newBuffer;
+    }
+  }
+}
+
+/// Determines whether the given HTTP [statusCode] is transient.
+typedef bool TransientHttpStatusCodePredicate(int statusCode);
+
+/// Builds a [FetchStrategy] function that retries up to a certain amount of
+/// times for up to a certain amount of time.
+///
+/// Pauses between retries with pauses growing exponentially (known as
+/// exponential backoff). Each attempt is subject to a [timeout]. Retries only
+/// those HTTP status codes considered transient by a
+/// [transientHttpStatusCodePredicate] function.
+class FetchStrategyBuilder {
+  /// A list of HTTP status codes that can generally be retried.
+  ///
+  /// You may want to use a different list depending on the needs of your
+  /// application.
+  static const List<int> defaultTransientHttpStatusCodes = const <int>[
+    0,   // Network error
+    408, // Request timeout
+    500, // Internal server error
+    502, // Bad gateway
+    503, // Service unavailable
+    504 // Gateway timeout
+  ];
+
+  /// Creates a fetch strategy builder.
+  ///
+  /// All parameters must be non-null.
+  const FetchStrategyBuilder({
+    this.timeout: const Duration(seconds: 30),
+    this.totalFetchTimeout: const Duration(minutes: 1),
+    this.maxAttempts: 5,
+    this.initialPauseBetweenRetries: const Duration(seconds: 1),
+    this.exponentialBackoffMultiplier: 2,
+    this.transientHttpStatusCodePredicate: defaultTransientHttpStatusCodePredicate,
+  }) : assert(timeout != null),
+       assert(totalFetchTimeout != null),
+       assert(maxAttempts != null),
+       assert(initialPauseBetweenRetries != null),
+       assert(exponentialBackoffMultiplier != null),
+       assert(transientHttpStatusCodePredicate != null);
+
+  /// Maximum amount of time a single fetch attempt is allowed to take.
+  final Duration timeout;
+
+  /// A strategy built by this builder will retry for up to this amount of time
+  /// before giving up.
+  final Duration totalFetchTimeout;
+
+  /// Maximum number of attempts a strategy will make before giving up.
+  final int maxAttempts;
+
+  /// Initial amount of time between retries.
+  final Duration initialPauseBetweenRetries;
+
+  /// The pause between retries is multiplied by this number with each attempt,
+  /// causing it to grow exponentially.
+  final int exponentialBackoffMultiplier;
+
+  /// A function that determines whether a given HTTP status code should be
+  /// retried.
+  final TransientHttpStatusCodePredicate transientHttpStatusCodePredicate;
+
+  /// Uses [defaultTransientHttpStatusCodes] to determine if the [statusCode] is
+  /// transient.
+  static bool defaultTransientHttpStatusCodePredicate(int statusCode) {
+    return defaultTransientHttpStatusCodes.contains(statusCode);
+  }
+
+  /// Builds a [FetchStrategy] that operates using the properties of this
+  /// builder.
+  FetchStrategy build() {
+    return (Uri uri, FetchFailure failure) async {
+      if (failure == null) {
+        // First attempt. Just load.
+        return new FetchInstructions.attempt(
+          uri: uri,
+          timeout: timeout,
+        );
+      }
+
+      final bool isRetriableFailure = transientHttpStatusCodePredicate(failure.httpStatusCode) ||
+          failure.originalException is io.SocketException;
+
+      // If cannot retry, give up.
+      if (!isRetriableFailure ||  // retrying will not help
+          failure.totalDuration > totalFetchTimeout ||  // taking too long
+          failure.attemptCount > maxAttempts) {  // too many attempts
+        return new FetchInstructions.giveUp(uri: uri);
+      }
+
+      // Exponential back-off.
+      final Duration pauseBetweenRetries = initialPauseBetweenRetries * math.pow(exponentialBackoffMultiplier, failure.attemptCount - 1);
+      await new Future<Null>.delayed(pauseBetweenRetries);
+
+      // Retry.
+      return new FetchInstructions.attempt(
+        uri: uri,
+        timeout: timeout,
+      );
+    };
+  }
 }
diff --git a/packages/flutter_image/test/network_test.dart b/packages/flutter_image/test/network_test.dart
index eb127db..a5d9b9b 100644
--- a/packages/flutter_image/test/network_test.dart
+++ b/packages/flutter_image/test/network_test.dart
@@ -2,10 +2,127 @@
 // 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:test/test.dart';
+import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_image/network.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:quiver/testing/async.dart';
+
+String _imageUrl(String fileName) {
+  return 'http://localhost:11111/$fileName';
+}
 
 void main() {
-  group('A group of tests', () {
-    test('First Test', () {});
+  group('NetworkImageWithRetry', () {
+    setUp(() {
+      FlutterError.onError = (FlutterErrorDetails error) {
+        fail('$error');
+      };
+    });
+
+    tearDown(() {
+      FlutterError.onError = FlutterError.dumpErrorToConsole;
+    });
+
+    test('loads image from network', () async {
+      final NetworkImageWithRetry subject = new NetworkImageWithRetry(
+        _imageUrl('immediate_success.png'),
+      );
+
+      subject.load(subject).addListener(expectAsync2((ImageInfo image, bool synchronousCall) {
+        expect(image.image.height, 1);
+        expect(image.image.width, 1);
+      }));
+    });
+
+    test('retries 6 times then gives up', () async {
+      final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[];
+      FlutterError.onError = errorLog.add;
+
+      final FakeAsync fakeAsync = new FakeAsync();
+      final dynamic maxAttemptCountReached = expectAsync0(() {});
+
+      int attemptCount = 0;
+      Future<Null> onAttempt() async {
+        expect(attemptCount, lessThan(7));
+        if (attemptCount == 6) {
+          maxAttemptCountReached();
+        }
+        await new Future<Null>.delayed(Duration.ZERO);
+        fakeAsync.elapse(const Duration(seconds: 60));
+        attemptCount++;
+      }
+
+      final NetworkImageWithRetry subject = new NetworkImageWithRetry(
+        _imageUrl('error.png'),
+        fetchStrategy: (Uri uri, FetchFailure failure) {
+          Timer.run(onAttempt);
+          return fakeAsync.run((FakeAsync fakeAsync) {
+            return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
+          });
+        },
+      );
+
+      subject.load(subject).addListener(expectAsync2((ImageInfo image, bool synchronousCall) {
+        expect(errorLog.single.exception, const isInstanceOf<FetchFailure>());
+        expect(image, null);
+      }));
+    });
+
+    test('gives up immediately on non-retriable errors (HTTP 404)', () async {
+      final List<FlutterErrorDetails> errorLog = <FlutterErrorDetails>[];
+      FlutterError.onError = errorLog.add;
+
+      final FakeAsync fakeAsync = new FakeAsync();
+
+      int attemptCount = 0;
+      Future<Null> onAttempt() async {
+        expect(attemptCount, lessThan(2));
+        await new Future<Null>.delayed(Duration.ZERO);
+        fakeAsync.elapse(const Duration(seconds: 60));
+        attemptCount++;
+      }
+
+      final NetworkImageWithRetry subject = new NetworkImageWithRetry(
+        _imageUrl('does_not_exist.png'),
+        fetchStrategy: (Uri uri, FetchFailure failure) {
+          Timer.run(onAttempt);
+          return fakeAsync.run((FakeAsync fakeAsync) {
+            return NetworkImageWithRetry.defaultFetchStrategy(uri, failure);
+          });
+        },
+      );
+
+      subject.load(subject).addListener(expectAsync2((ImageInfo image, bool synchronousCall) {
+        expect(errorLog.single.exception, const isInstanceOf<FetchFailure>());
+        expect(image, null);
+      }));
+    });
+
+    test('succeeds on successful retry', () async {
+      final NetworkImageWithRetry subject = new NetworkImageWithRetry(
+        _imageUrl('error.png'),
+        fetchStrategy: (Uri uri, FetchFailure failure) async {
+          if (failure == null) {
+            return new FetchInstructions.attempt(
+              uri: uri,
+              timeout: const Duration(minutes: 1),
+            );
+          } else {
+            expect(failure.attemptCount, lessThan(2));
+            return new FetchInstructions.attempt(
+              uri: Uri.parse(_imageUrl('immediate_success.png')),
+              timeout: const Duration(minutes: 1),
+            );
+          }
+        },
+      );
+
+      subject.load(subject).addListener(expectAsync2((ImageInfo image, bool synchronousCall) {
+        expect(image.image.height, 1);
+        expect(image.image.width, 1);
+      }));
+    });
   });
 }
diff --git a/packages/flutter_image/test/network_test_server.dart b/packages/flutter_image/test/network_test_server.dart
new file mode 100644
index 0000000..aa5c212
--- /dev/null
+++ b/packages/flutter_image/test/network_test_server.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2017, the Flutter 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:io';
+
+const int _kTestServerPort = 11111;
+
+Future<Null> main() async {
+  final HttpServer testServer = await HttpServer.bind(InternetAddress.LOOPBACK_IP_V4, _kTestServerPort);
+  await for (HttpRequest request in testServer) {
+    if (request.uri.path.endsWith('/immediate_success.png')) {
+      request.response.add(_kTransparentImage);
+    } else if (request.uri.path.endsWith('/error.png')) {
+      request.response.statusCode = 500;
+    } else {
+      request.response.statusCode = 404;
+    }
+    await request.response.flush();
+    await request.response.close();
+  }
+}
+
+const List<int> _kTransparentImage = const <int>[
+  0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49,
+  0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
+  0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44,
+  0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D,
+  0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
+];