Last active
August 13, 2021 12:52
-
-
Save slightfoot/fea35818d405556fe7d2d1325d90896d to your computer and use it in GitHub Desktop.
Function to perform a http request with retry and back-off logic. This is modified version from NetworkImageWithRetry - by Simon Lightfoot 13/05/2021
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright 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. | |
// | |
// This is modified version from NetworkImageWithRetry - by Simon Lightfoot 13/05/2021 | |
// | |
// Built from : https://github.com/flutter/flutter_image/blob/master/lib/network.dart | |
// | |
import 'dart:async'; | |
import 'dart:convert' show ByteConversionSink; | |
import 'dart:io' as io; | |
import 'dart:math' as math; | |
import 'dart:typed_data'; | |
import 'package:http/http.dart' as http; | |
import 'package:meta/meta.dart'; | |
/// | |
/// If [fetchStrategy] is specified, uses it instead of the | |
/// [defaultFetchStrategy] to obtain instructions for fetching the URL. | |
/// 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. | |
Future<http.Response> performWithRetry({ | |
required http.Client client, | |
required http.Request request, | |
FetchStrategy fetchStrategy = defaultFetchStrategy, | |
}) async { | |
final stopwatch = Stopwatch()..start(); | |
var instructions = await fetchStrategy(request, null); | |
_debugCheckInstructions(fetchStrategy, instructions); | |
var attemptCount = 0; | |
FetchFailure? lastFailure; | |
http.StreamedResponse? response; | |
while (!instructions.shouldGiveUp) { | |
attemptCount += 1; | |
try { | |
response = await client.send(instructions.request).timeout(instructions.timeout); | |
if (response.statusCode != 200) { | |
throw FetchFailure._( | |
totalDuration: stopwatch.elapsed, | |
attemptCount: attemptCount, | |
httpStatusCode: response.statusCode, | |
); | |
} | |
final bodyCompleter = Completer<Uint8List>(); | |
final bodySink = ByteConversionSink.withCallback((List<int> accumulated) { | |
bodyCompleter.complete(Uint8List.fromList(accumulated)); | |
}); | |
response.stream // | |
.timeout(instructions.timeout) | |
.listen( | |
bodySink.add, | |
onError: bodyCompleter.completeError, | |
onDone: bodySink.close, | |
cancelOnError: true, | |
); | |
final bytes = await bodyCompleter.future; | |
if (bytes.lengthInBytes == 0) { | |
throw FetchFailure._( | |
totalDuration: stopwatch.elapsed, | |
attemptCount: attemptCount, | |
httpStatusCode: response.statusCode, | |
); | |
} | |
return http.Response.bytes( | |
bytes, | |
response.statusCode, | |
request: response.request, | |
headers: response.headers, | |
isRedirect: response.isRedirect, | |
persistentConnection: response.persistentConnection, | |
reasonPhrase: response.reasonPhrase, | |
); | |
} catch (error) { | |
await response?.stream.drain(); | |
lastFailure = error is FetchFailure | |
? error | |
: FetchFailure._( | |
totalDuration: stopwatch.elapsed, | |
attemptCount: attemptCount, | |
originalException: error, | |
); | |
instructions = await fetchStrategy(instructions.request, lastFailure); | |
_debugCheckInstructions(fetchStrategy, instructions); | |
} | |
} | |
assert(lastFailure != null); | |
throw lastFailure!; | |
} | |
void _debugCheckInstructions(FetchStrategy fetchStrategy, FetchInstructions? instructions) { | |
assert(() { | |
if (instructions == null) { | |
if (fetchStrategy == defaultFetchStrategy) { | |
throw StateError('The default FetchStrategy returned null FetchInstructions.'); | |
} else { | |
throw StateError('The custom FetchStrategy used returned null\n' | |
'FetchInstructions. FetchInstructions must never be null, but\n' | |
'instead instruct to either make another fetch attempt or give up.'); | |
} | |
} | |
return true; | |
}()); | |
} | |
/// The [FetchStrategy] that [performWithRetry] uses by default. | |
Future<FetchInstructions> defaultFetchStrategy(http.Request request, FetchFailure? failure) { | |
return _defaultFetchStrategyFunction(request, failure); | |
} | |
/// 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. | |
final FetchStrategy _defaultFetchStrategyFunction = const FetchStrategyBuilder().build(); | |
/// This function is called to get [FetchInstructions] to perform the request. | |
/// | |
/// 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 [Future.delayed]. This is useful for implementing | |
/// back-off strategies and for recovering from lack of connectivity. | |
/// | |
/// [request] is the last request used. A [FetchStrategy] may choose to use | |
/// a different URI (see [FetchInstructions.uri]). | |
/// | |
/// If [failure] is `null`, then this is the first attempt. | |
/// | |
/// If the [failure] is not `null`, it contains the information about the | |
/// previous attempt. A [FetchStrategy] may attempt to recover from the | |
/// failure by returning [FetchInstructions] that instruct [performWithRetry] | |
/// to try again. | |
/// | |
/// See [defaultFetchStrategy] for an example. | |
typedef FetchStrategy = Future<FetchInstructions> Function( | |
http.Request request, FetchFailure? failure); | |
/// Instructions [performWithRetry] uses to perform the request | |
@immutable | |
class FetchInstructions { | |
/// Instructs [performWithRetry] to give up trying to perfomr the request. | |
const FetchInstructions.giveUp({required this.request}) | |
: shouldGiveUp = true, | |
timeout = Duration.zero; | |
/// Instructs [performWithRetry] to attempt to perform the request with the | |
/// given [request] and [timeout] if it takes too long. | |
const FetchInstructions.attempt({ | |
required this.request, | |
required this.timeout, | |
}) : shouldGiveUp = false; | |
/// Instructs to give up trying. | |
/// | |
/// Reports the latest [FetchFailure]. | |
final bool shouldGiveUp; | |
/// Timeout for the next network call. | |
final Duration timeout; | |
/// The URI to use on the next attempt. | |
final http.Request request; | |
@override | |
String toString() { | |
return '$runtimeType(\n' | |
' shouldGiveUp: $shouldGiveUp\n' | |
' timeout: $timeout\n' | |
' request: $request\n' | |
')'; | |
} | |
} | |
/// Contains information about a failed attempt to perform the request. | |
@immutable | |
class FetchFailure implements Exception { | |
const FetchFailure._({ | |
required this.totalDuration, | |
required this.attemptCount, | |
this.httpStatusCode, | |
this.originalException, | |
}) : assert(attemptCount > 0); | |
/// The total amount of time it has taken so far to perform the request. | |
final Duration totalDuration; | |
/// The number of times attempted to perform the request so far. | |
/// | |
/// This value starts with 1 and grows by 1 with each attempt to perform the request. | |
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' | |
')'; | |
} | |
} | |
/// Determines whether the given HTTP [statusCode] is transient. | |
typedef TransientHttpStatusCodePredicate = bool Function(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 { | |
/// 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, | |
}); | |
/// 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 = <int>[ | |
0, // Network error | |
408, // Request timeout | |
500, // Internal server error | |
502, // Bad gateway | |
503, // Service unavailable | |
504 // Gateway timeout | |
]; | |
/// 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 num 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 (http.Request request, FetchFailure? failure) async { | |
if (failure == null) { | |
// First attempt. Just load. | |
return FetchInstructions.attempt(request: request, timeout: timeout); | |
} | |
final isRetryableFailure = (failure.httpStatusCode != null && | |
transientHttpStatusCodePredicate(failure.httpStatusCode!)) || | |
failure.originalException is io.SocketException; | |
// If cannot retry, give up. | |
if (!isRetryableFailure || // retrying will not help | |
failure.totalDuration > totalFetchTimeout || // taking too long | |
failure.attemptCount > maxAttempts) { | |
// too many attempts | |
return FetchInstructions.giveUp(request: request); | |
} | |
// Exponential back-off. | |
final pauseBetweenRetries = initialPauseBetweenRetries * | |
math.pow(exponentialBackoffMultiplier, failure.attemptCount - 1); | |
await Future<void>.delayed(pauseBetweenRetries); | |
// Retry. | |
return FetchInstructions.attempt(request: request, timeout: timeout); | |
}; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment