smart_retry 1.0.0
smart_retry: ^1.0.0 copied to clipboard
A production-ready retry library for Dart and Flutter featuring exponential backoff, advanced jitter strategies, conditional retries, typed exceptions, event streams, and zero-boilerplate async resilience.
smart_retry π #
Stop writing the same
try/catch/for-loopboilerplate in every project.
A production-ready, pure-Dart package for intelligent async retry logic:
- β Exponential / linear / constant backoff β grows delay between retries
- β Full / equal / decorrelated jitter β prevents thundering herd problems
- β
retryIfpredicate β only retry transient errors, abort on permanent ones - β
onRetrycallback β rich [RetryContext] before each sleep - β Lifecycle event stream β plug in any logger / crash reporter
- β
previewScheduleβ inspect the delay table without executing - β
Typed exceptions β
MaxAttemptsExceededException&NonRetryableException - β
Zero runtime dependencies (only
meta) - β 100% Pure Dart β Flutter, Dart CLI, server-side
Table of Contents #
- Installation
- Quick Start
- API Reference
- Exception Handling
- Delay Schedule Reference
- Strategy Comparison
- Advanced Recipes
- Contributing
- License
Installation #
# pubspec.yaml
dependencies:
smart_retry: ^1.0.0
dart pub get
# or
flutter pub get
Quick Start #
import 'dart:io';
import 'package:smart_retry/smart_retry.dart';
// ββ Minimal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
final data = await SmartRetry.run(
() => api.fetchUser(id: 42),
maxAttempts: 5,
retryIf: (e) => e is SocketException,
);
// ββ With logging ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
final data = await SmartRetry.run(
() => api.fetchUser(id: 42),
maxAttempts: 5,
retryIf: (e) => e is SocketException || e is TimeoutException,
onRetry: (ctx) => print(
'[${ctx.attemptNumber}/${ctx.maxAttempts}] '
'Retry in ${ctx.nextDelay.inMilliseconds}ms β ${ctx.lastException}',
),
);
// ββ Handle errors explicitly βββββββββββββββββββββββββββββββββββββββββββββββββ
try {
final data = await SmartRetry.run(() => api.fetchUser(id: 42));
} on MaxAttemptsExceededException catch (e) {
print('Failed after ${e.attempts} tries: ${e.lastException}');
} on NonRetryableException catch (e) {
print('Permanent error, not retrying: ${e.cause}');
}
API Reference #
SmartRetry.run #
The primary API. All parameters are optional with production-ready defaults.
static Future<T> run<T>(
Future<T> Function() fn, {
int maxAttempts = 3,
Duration initialDelay = const Duration(milliseconds: 500),
Duration maxDelay = const Duration(seconds: 30),
double factor = 2.0,
BackoffStrategy backoffStrategy = BackoffStrategy.exponential,
JitterStrategy jitterStrategy = JitterStrategy.full,
FutureOr<bool> Function(Exception e)? retryIf,
void Function(RetryContext context)? onRetry,
bool debugMode = false,
})
| Parameter | Type | Default | Description |
|---|---|---|---|
fn |
Future<T> Function() |
β | The async callable to execute and retry. |
maxAttempts |
int |
3 |
Total tries including the first. |
initialDelay |
Duration |
500ms |
Base delay before the 2nd attempt. |
maxDelay |
Duration |
30s |
Hard cap on any single delay. |
factor |
double |
2.0 |
Exponential growth multiplier. |
backoffStrategy |
BackoffStrategy |
exponential |
Delay growth curve. |
jitterStrategy |
JitterStrategy |
full |
Randomisation mode. |
retryIf |
FutureOr<bool> Function(Exception)? |
null |
Per-exception retry gate. Returns true β retry, false β abort. |
onRetry |
void Function(RetryContext)? |
null |
Callback invoked before each retry sleep. |
debugMode |
bool |
false |
Print structured debug output. |
SmartRetry.runWithOptions #
Preferred for shared configuration across multiple call-sites.
static Future<T> runWithOptions<T>(
Future<T> Function() fn, {
required RetryOptions options,
})
// Define once
final _retry = RetryOptions(
maxAttempts: 4,
retryIf: (e) => e is SocketException,
);
// Reuse everywhere
final user = await SmartRetry.runWithOptions(() => api.getUser(), options: _retry);
final posts = await SmartRetry.runWithOptions(() => api.getPosts(), options: _retry);
RetryOptions #
Immutable configuration value object. Supports copyWith for derivation.
const RetryOptions({
int maxAttempts = 3,
Duration initialDelay = const Duration(milliseconds: 500),
Duration maxDelay = const Duration(seconds: 30),
double factor = 2.0,
BackoffStrategy backoffStrategy = BackoffStrategy.exponential,
JitterStrategy jitterStrategy = JitterStrategy.full,
FutureOr<bool> Function(Exception e)? retryIf,
void Function(RetryContext context)? onRetry,
bool debugMode = false,
})
// Base config
const base = RetryOptions(maxAttempts: 4, retryIf: _isTransient);
// Derived config β inherits base, overrides specific fields
final upload = base.copyWith(maxAttempts: 8, maxDelay: Duration(minutes: 2));
BackoffStrategy #
Controls how the base delay grows between attempts.
| Strategy | Formula | Example (initial=500ms, factor=2) |
|---|---|---|
exponential |
min(cap, initial Γ factorβΏ) |
500 β 1000 β 2000 β 4000ms |
linear |
min(cap, initial Γ (n+1)) |
500 β 1000 β 1500 β 2000ms |
constant |
initial (always) |
500 β 500 β 500 β 500ms |
JitterStrategy #
Controls randomisation applied on top of the base delay.
| Strategy | Formula | Notes |
|---|---|---|
full |
random(0, base) |
Best total throughput under load |
equal |
base/2 + random(0, base/2) |
Guarantees minimum half delay |
decorrelated |
random(initial, min(cap, lastΓ3)) |
Best under high contention |
none |
base (no randomisation) |
Tests / single-client jobs only |
RetryContext #
Passed to onRetry before every retry sleep.
| Field | Type | Description |
|---|---|---|
attemptNumber |
int |
1-based number of the attempt that just failed. |
maxAttempts |
int |
Total configured attempts. |
lastException |
Exception |
Exception thrown by the last attempt. |
nextDelay |
Duration |
Actual sleep duration (post-jitter). |
elapsedTime |
Duration |
Wall-clock time since the first attempt. |
remainingAttempts |
int |
Computed: maxAttempts - attemptNumber. |
isFinalAttempt |
bool |
True if the next attempt is the last. |
Event Stream #
SmartRetry.events is a broadcast stream of [RetryEvent] instances.
SmartRetry.events.listen((event) {
switch (event) {
case AttemptStarted(:final attemptNumber, :final maxAttempts):
print('βΆ $attemptNumber/$maxAttempts');
case AttemptFailed(:final attemptNumber, :final exception, :final nextDelay):
logger.warn('Attempt $attemptNumber failed (${nextDelay.inMilliseconds}ms): $exception');
case AttemptSucceeded(:final attemptNumber, :final totalElapsed):
metrics.increment('retry.success', tags: {'attempt': '$attemptNumber'});
case AllAttemptsFailed(:final totalAttempts, :final lastException):
Sentry.captureException(lastException);
case RetryAborted(:final exception):
logger.error('Non-retryable: $exception');
}
});
Available event types: AttemptStarted, AttemptFailed, AttemptSucceeded,
AllAttemptsFailed, RetryAborted.
previewSchedule #
Inspect the full delay table without executing any code.
final schedule = SmartRetry.previewSchedule(
options: RetryOptions(
maxAttempts: 5,
initialDelay: Duration(milliseconds: 500),
factor: 2.0,
backoffStrategy: BackoffStrategy.exponential,
jitterStrategy: JitterStrategy.none,
),
);
for (final row in schedule) {
print('Retry #${row.attempt}: ${row.actualDelay.inMilliseconds}ms');
}
// Retry #1: 500ms
// Retry #2: 1000ms
// Retry #3: 2000ms
// Retry #4: 4000ms
Exception Handling #
try {
final result = await SmartRetry.run(() => api.getData());
} on MaxAttemptsExceededException catch (e) {
// All attempts failed β transient problem persisted too long
print('Gave up after ${e.attempts} attempts in ${e.totalElapsed.inSeconds}s');
print('Last error: ${e.lastException}');
} on NonRetryableException catch (e) {
// retryIf returned false β permanent error, no point retrying
print('Permanent failure: ${e.cause}');
print('Reason: ${e.message}');
}
Delay Schedule Reference #
Default configuration (maxAttempts: 5, initialDelay: 500ms, factor: 2.0, jitter: full):
| Retry | Base Delay | After Full Jitter |
|---|---|---|
| 1st | 500ms | 0 β 500ms |
| 2nd | 1 000ms | 0 β 1 000ms |
| 3rd | 2 000ms | 0 β 2 000ms |
| 4th | 4 000ms | 0 β 4 000ms |
Strategy Comparison #
| BackoffStrategy | JitterStrategy | Best for |
|---|---|---|
exponential |
full |
Default β general network calls |
exponential |
equal |
When a minimum wait is required |
exponential |
decorrelated |
High-concurrency, many clients |
constant |
none |
Tests, deterministic queue consumers |
linear |
full |
APIs with linear rate-limit windows |
Advanced Recipes #
Async retryIf predicate #
await SmartRetry.run(
() => api.upload(file),
retryIf: (e) async {
// Check connectivity before deciding to retry
final connected = await connectivity.checkConnectivity();
return connected && e is SocketException;
},
);
Shared config across a service class #
class ApiService {
static final _opts = RetryOptions(
maxAttempts: 4,
initialDelay: const Duration(milliseconds: 300),
retryIf: (e) => e is SocketException || e is TimeoutException,
onRetry: (ctx) => log.warn('[API] retry ${ctx.attemptNumber}'),
);
Future<User> getUser(int id) =>
SmartRetry.runWithOptions(() => _http.get('/users/$id'), options: _opts);
Future<List<Post>> getPosts() =>
SmartRetry.runWithOptions(() => _http.get('/posts'), options: _opts);
}
Plug in Sentry / Firebase Crashlytics #
void setupRetryMonitoring() {
SmartRetry.events.listen((event) {
if (event is AllAttemptsFailed) {
Sentry.captureException(event.lastException, hint: Hint.withMap({
'attempts': '${event.totalAttempts}',
}));
}
});
}
Contributing #
Pull requests and issues are welcome at github.com/Brah-Timo/smart_retry.
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Run tests:
dart test - Run the linter:
dart analyze - Submit a PR
License #
MIT Β© 2026