smart_retry πŸ”„

Stop writing the same try/catch/for-loop boilerplate in every project.

pub.flutter-io.cn Dart SDK License: MIT style: lints

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
  • βœ… retryIf predicate β€” only retry transient errors, abort on permanent ones
  • βœ… onRetry callback β€” 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

  1. Installation
  2. Quick Start
  3. API Reference
  4. Exception Handling
  5. Delay Schedule Reference
  6. Strategy Comparison
  7. Advanced Recipes
  8. Contributing
  9. 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.

  1. Fork the repository
  2. Create a feature branch: git checkout -b feat/my-feature
  3. Run tests: dart test
  4. Run the linter: dart analyze
  5. Submit a PR

License

MIT Β© 2026

Libraries

smart_retry
smart_retry β€” Intelligent async retry with exponential backoff & jitter.