Retry on failed download. (#12293)
diff --git a/packages/flutter_tools/lib/src/base/net.dart b/packages/flutter_tools/lib/src/base/net.dart index abce281..1d9adfb 100644 --- a/packages/flutter_tools/lib/src/base/net.dart +++ b/packages/flutter_tools/lib/src/base/net.dart
@@ -4,39 +4,62 @@ import 'dart:async'; +import '../base/context.dart'; import '../globals.dart'; import 'common.dart'; import 'io.dart'; const int kNetworkProblemExitCode = 50; +typedef HttpClient HttpClientFactory(); + /// Download a file from the given URL and return the bytes. Future<List<int>> fetchUrl(Uri url) async { - printTrace('Downloading $url.'); + int attempts = 0; + int duration = 1; + while (true) { + attempts += 1; + final List<int> result = await _attempt(url); + if (result != null) + return result; + printStatus('Download failed -- attempting retry $attempts in $duration second${ duration == 1 ? "" : "s"}...'); + await new Future<Null>.delayed(new Duration(seconds: duration)); + if (duration < 64) + duration *= 2; + } +} - final HttpClient httpClient = new HttpClient(); +Future<List<int>> _attempt(Uri url) async { + printTrace('Downloading: $url'); + HttpClient httpClient; + if (context[HttpClientFactory] != null) { + httpClient = context[HttpClientFactory](); + } else { + httpClient = new HttpClient(); + } final HttpClientRequest request = await httpClient.getUrl(url); final HttpClientResponse response = await request.close(); - - printTrace('Received response statusCode=${response.statusCode}'); if (response.statusCode != 200) { - throwToolExit( - 'Download failed: $url\n' - ' because (${response.statusCode}) ${response.reasonPhrase}', - exitCode: kNetworkProblemExitCode, - ); + if (response.statusCode > 0 && response.statusCode < 500) { + throwToolExit( + 'Download failed.\n' + 'URL: $url\n' + 'Error: ${response.statusCode} ${response.reasonPhrase}', + exitCode: kNetworkProblemExitCode, + ); + } + // 5xx errors are server errors and we can try again + printTrace('Download error: ${response.statusCode} ${response.reasonPhrase}'); + return null; } - + printTrace('Received response from server, collecting bytes...'); try { final BytesBuilder responseBody = new BytesBuilder(copy: false); await for (List<int> chunk in response) - responseBody.add(chunk); - + responseBody.add(chunk); return responseBody.takeBytes(); - } on IOException catch (e) { - throw new ToolExit( - 'Download failed: $url\n $e', - exitCode: kNetworkProblemExitCode, - ); + } on IOException catch (error) { + printTrace('Download error: $error'); + return null; } }
diff --git a/packages/flutter_tools/test/base/net_test.dart b/packages/flutter_tools/test/base/net_test.dart new file mode 100644 index 0000000..3840750 --- /dev/null +++ b/packages/flutter_tools/test/base/net_test.dart
@@ -0,0 +1,115 @@ +// Copyright 2017 The Chromium Authors. 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 'package:flutter_tools/src/base/io.dart' as io; +import 'package:flutter_tools/src/base/net.dart'; +import 'package:quiver/testing/async.dart'; +import 'package:test/test.dart'; + +import '../src/context.dart'; + +void main() { + testUsingContext('retry from 500', () async { + String error; + new FakeAsync().run((FakeAsync time) { + fetchUrl(Uri.parse('http://example.invalid/')).then((List<int> value) { + error = 'test completed unexpectedly'; + }, onError: (dynamic error) { + error = 'test failed unexpectedly'; + }); + expect(testLogger.statusText, ''); + time.elapse(const Duration(milliseconds: 10000)); + expect(testLogger.statusText, + 'Download failed -- attempting retry 1 in 1 second...\n' + 'Download failed -- attempting retry 2 in 2 seconds...\n' + 'Download failed -- attempting retry 3 in 4 seconds...\n' + 'Download failed -- attempting retry 4 in 8 seconds...\n' + ); + }); + expect(testLogger.errorText, isEmpty); + expect(error, isNull); + }, overrides: <Type, Generator>{ + HttpClientFactory: () => () => new MockHttpClient(500), + }); + + testUsingContext('retry from network error', () async { + String error; + new FakeAsync().run((FakeAsync time) { + fetchUrl(Uri.parse('http://example.invalid/')).then((List<int> value) { + error = 'test completed unexpectedly'; + }, onError: (dynamic error) { + error = 'test failed unexpectedly'; + }); + expect(testLogger.statusText, ''); + time.elapse(const Duration(milliseconds: 10000)); + expect(testLogger.statusText, + 'Download failed -- attempting retry 1 in 1 second...\n' + 'Download failed -- attempting retry 2 in 2 seconds...\n' + 'Download failed -- attempting retry 3 in 4 seconds...\n' + 'Download failed -- attempting retry 4 in 8 seconds...\n' + ); + }); + expect(testLogger.errorText, isEmpty); + expect(error, isNull); + }, overrides: <Type, Generator>{ + HttpClientFactory: () => () => new MockHttpClient(200), + }); +} + +class MockHttpClient implements io.HttpClient { + MockHttpClient(this.statusCode); + + final int statusCode; + + @override + Future<io.HttpClientRequest> getUrl(Uri url) async { + return new MockHttpClientRequest(statusCode); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw 'io.HttpClient - $invocation'; + } +} + +class MockHttpClientRequest implements io.HttpClientRequest { + MockHttpClientRequest(this.statusCode); + + final int statusCode; + + @override + Future<io.HttpClientResponse> close() async { + return new MockHttpClientResponse(statusCode); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw 'io.HttpClientRequest - $invocation'; + } +} + +class MockHttpClientResponse implements io.HttpClientResponse { + MockHttpClientResponse(this.statusCode); + + @override + final int statusCode; + + @override + String get reasonPhrase => '<reason phrase>'; + + @override + StreamSubscription<List<int>> listen(void onData(List<int> event), { + Function onError, void onDone(), bool cancelOnError + }) { + return new Stream<List<int>>.fromFuture(new Future<List<int>>.error(const io.SocketException('test'))) + .listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw 'io.HttpClientResponse - $invocation'; + } +}
diff --git a/packages/flutter_tools/test/cache_test.dart b/packages/flutter_tools/test/cache_test.dart index b921260..a4ef941 100644 --- a/packages/flutter_tools/test/cache_test.dart +++ b/packages/flutter_tools/test/cache_test.dart
@@ -48,6 +48,7 @@ Platform: () => new FakePlatform()..environment = <String, String>{'FLUTTER_ALREADY_LOCKED': 'true'}, }); }); + group('Cache', () { test('should not be up to date, if some cached artifact is not', () { final CachedArtifact artifact1 = new MockCachedArtifact();
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart index 6d89f62..8913488 100644 --- a/packages/flutter_tools/test/src/context.dart +++ b/packages/flutter_tools/test/src/context.dart
@@ -60,7 +60,8 @@ ..putIfAbsent(SimControl, () => new MockSimControl()) ..putIfAbsent(Usage, () => new MockUsage()) ..putIfAbsent(FlutterVersion, () => new MockFlutterVersion()) - ..putIfAbsent(Clock, () => const Clock()); + ..putIfAbsent(Clock, () => const Clock()) + ..putIfAbsent(HttpClient, () => new MockHttpClient()); } void testUsingContext(String description, dynamic testMethod(), { @@ -244,3 +245,5 @@ class MockFlutterVersion extends Mock implements FlutterVersion {} class MockClock extends Mock implements Clock {} + +class MockHttpClient extends Mock implements HttpClient {}