Skip to content

Commit

Permalink
feat: add BackOff strategy.
Browse files Browse the repository at this point in the history
Signed-off-by: xsahil03x <xdsahil@gmail.com>
  • Loading branch information
xsahil03x committed Dec 14, 2022
1 parent 0a5e885 commit 4e1e2b0
Show file tree
Hide file tree
Showing 4 changed files with 243 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/rate_limiter.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
library rate_limiter;

export 'src/backoff.dart';
export 'src/debounce.dart';
export 'src/throttle.dart';
export 'src/extension.dart';
105 changes: 105 additions & 0 deletions lib/src/backoff.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'dart:async';
import 'dart:math' as math;

final _rand = math.Random();

/// Object holding options for retrying a function.
///
/// With the default configuration functions will be retried up-to 7 times
/// (8 attempts in total), sleeping 1st, 2nd, 3rd, ..., 7th attempt:
/// 1. 400 ms +/- 25%
/// 2. 800 ms +/- 25%
/// 3. 1600 ms +/- 25%
/// 4. 3200 ms +/- 25%
/// 5. 6400 ms +/- 25%
/// 6. 12800 ms +/- 25%
/// 7. 25600 ms +/- 25%
///
/// **Example**
/// ```dart
/// final response = await backOff(
/// // Make a GET request
/// () => http.get('https://google.com').timeout(Duration(seconds: 5)),
/// // Retry on SocketException or TimeoutException
/// retryIf: (e) => e is SocketException || e is TimeoutException,
/// );
/// print(response.body);
/// ```
class BackOff<T> {
const BackOff(
this.func, {
this.delayFactor = const Duration(milliseconds: 200),
this.randomizationFactor = 0.25,
this.maxDelay = const Duration(seconds: 30),
this.maxAttempts = 8,
this.retryIf,
}) : assert(maxAttempts >= 1, 'maxAttempts must be greater than 0');

/// The [Function] to execute. If the function throws an error, it will be
/// retried [maxAttempts] times with an increasing delay between each attempt
/// up to [maxDelay].
///
/// If [retryIf] is provided, the function will only be retried if the error
/// matches the predicate.
final FutureOr<T> Function() func;

/// Delay factor to double after every attempt.
///
/// Defaults to 200 ms, which results in the following delays:
///
/// 1. 400 ms
/// 2. 800 ms
/// 3. 1600 ms
/// 4. 3200 ms
/// 5. 6400 ms
/// 6. 12800 ms
/// 7. 25600 ms
///
/// Before application of [randomizationFactor].
final Duration delayFactor;

/// Percentage the delay should be randomized, given as fraction between
/// 0 and 1.
///
/// If [randomizationFactor] is `0.25` (default) this indicates 25 % of the
/// delay should be increased or decreased by 25 %.
final double randomizationFactor;

/// Maximum delay between retries, defaults to 30 seconds.
final Duration maxDelay;

/// Maximum number of attempts before giving up, defaults to 8.
final int maxAttempts;

/// Function to determine if a retry should be attempted.
///
/// If `null` (default) all errors will be retried.
final FutureOr<bool> Function(Object error, int attempt)? retryIf;

// returns the sleep duration based on `attempt`.
Duration _getSleepDuration(int attempt) {
final rf = (randomizationFactor * (_rand.nextDouble() * 2 - 1) + 1);
final exp = math.min(attempt, 31); // prevent overflows.
final delay = (delayFactor * math.pow(2.0, exp) * rf);
return delay < maxDelay ? delay : maxDelay;
}

Future<T> call() async {
var attempt = 0;
while (true) {
attempt++; // first invocation is the first attempt.
try {
return await func();
} catch (error) {
final attemptLimitReached = attempt >= maxAttempts;
if (attemptLimitReached) rethrow;

final shouldRetry = await retryIf?.call(error, attempt);
if (shouldRetry == false) rethrow;
}

// sleep for a delay.
await Future.delayed(_getSleepDuration(attempt));
}
}
}
41 changes: 41 additions & 0 deletions lib/src/extension.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import 'dart:async';

import 'backoff.dart';
import 'debounce.dart';
import 'throttle.dart';

/// Useful rate limiter extensions for [Function] class.
extension BackOffExtension<T> on FutureOr<T> Function() {
/// Converts this into a [BackOff] function.
Future<T> backOff({
Duration delayFactor = const Duration(milliseconds: 200),
double randomizationFactor = 0.25,
Duration maxDelay = const Duration(seconds: 30),
int maxAttempts = 8,
FutureOr<bool> Function(Object error, int attempt)? retry,
}) =>
BackOff(
this,
delayFactor: delayFactor,
randomizationFactor: randomizationFactor,
maxDelay: maxDelay,
maxAttempts: maxAttempts,
retryIf: retry,
).call();
}

/// Useful rate limiter extensions for [Function] class.
extension RateLimit on Function {
/// Converts this into a [Debounce] function.
Expand Down Expand Up @@ -32,6 +55,24 @@ extension RateLimit on Function {
);
}

/// TopLevel lambda to apply [BackOff] to functions.
Future<T> backOff<T>(
FutureOr<T> Function() func, {
Duration delayFactor = const Duration(milliseconds: 200),
double randomizationFactor = 0.25,
Duration maxDelay = const Duration(seconds: 30),
int maxAttempts = 8,
FutureOr<bool> Function(Object error, int attempt)? retryIf,
}) =>
BackOff(
func,
delayFactor: delayFactor,
randomizationFactor: randomizationFactor,
maxDelay: maxDelay,
maxAttempts: maxAttempts,
retryIf: retryIf,
).call();

/// TopLevel lambda to create [Debounce] functions.
Debounce debounce(
Function func,
Expand Down
96 changes: 96 additions & 0 deletions test/backoff_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import 'package:rate_limiter/rate_limiter.dart';
import 'package:test/test.dart';

void main() {
test('backOff (success)', () async {
var count = 0;
final f = backOff(() {
count++;
return true;
});
expect(f, completion(isTrue));
expect(count, equals(1));
});

test('retry (unhandled exception)', () async {
var count = 0;
final f = backOff(
() {
count++;
throw Exception('Retry will fail');
},
maxAttempts: 5,
retryIf: (_, __) => false,
);
await expectLater(f, throwsA(isException));
expect(count, equals(1));
});

test('retry (retryIf, exhaust retries)', () async {
var count = 0;
final f = backOff(
() {
count++;
throw FormatException('Retry will fail');
},
maxAttempts: 5,
maxDelay: Duration(),
retryIf: (e, _) => e is FormatException,
);
await expectLater(f, throwsA(isFormatException));
expect(count, equals(5));
});

test('retry (success after 2)', () async {
var count = 0;
final f = backOff(
() {
count++;
if (count == 1) {
throw FormatException('Retry will be okay');
}
return true;
},
maxAttempts: 5,
maxDelay: Duration(),
retryIf: (e, _) => e is FormatException,
);
await expectLater(f, completion(isTrue));
expect(count, equals(2));
});

test('retry (no retryIf)', () async {
var count = 0;
final f = backOff(
() {
count++;
if (count == 1) {
throw FormatException('Retry will be okay');
}
return true;
},
maxAttempts: 5,
maxDelay: Duration(),
);
await expectLater(f, completion(isTrue));
expect(count, equals(2));
});

test('retry (unhandled on 2nd try)', () async {
var count = 0;
final f = backOff(
() {
count++;
if (count == 1) {
throw FormatException('Retry will be okay');
}
throw Exception('unhandled thing');
},
maxAttempts: 5,
maxDelay: Duration(),
retryIf: (e, _) => e is FormatException,
);
await expectLater(f, throwsA(isException));
expect(count, equals(2));
});
}

0 comments on commit 4e1e2b0

Please sign in to comment.