Skip to content

Retries and failure handling

A task can fail for reasons that go away (a timeout, a 503, a lock) or reasons that won’t (bad input, a bug). gisila_queue gives you tools for each:

Tool Use for
ctx.retry() A failure you recognise as temporary. You choose which errors retry.
autoRetry: true Handlers where any exception is probably temporary.
RejectTask() The task is pointless now (the record was deleted). Drop it quietly.
RejectTask(requeue: true) Try again right away without using a retry (a resource is busy).
timeLimit / softTimeLimit Stop waiting on hung work / let long work wind down.
Dead-letter queue Keep what failed for good so you can fix and replay it.
  • Network calls to services that blip: ctx.retry(cause: e) on timeouts and 5xx.
  • Rate-limited APIs that answer 429 with a Retry-After: ctx.retry(countdown: ...).
  • Idempotent tasks with simple failure modes: autoRetry: true plus maxRetries.
  • Anything that must eventually succeed: keep the default dead-letter queue and alert on its depth.
  • Don’t retry validation errors or bugs; they fail the same way every time and delay the dead letter. Let them throw (with autoRetry: false) or RejectTask().
  • Don’t use retries to poll for something (“is the export ready?”) with a huge maxRetries. Use a periodic task or a chain.
  • Don’t retry non-idempotent side effects without a guard (see Delivery guarantees).

A retry is a new copy of the message with retries + 1 and an ETA; the original is acked. The task’s state becomes retry, then started again when the copy runs. No worker slot is held during the wait.

The delay is countdown if you passed one, otherwise exponential backoff:

delay(n) = min(retryBackoff × 2^(n-1), retryBackoffMax) then, with retryJitter, uniform in [delay/2, delay]

With defaults (1 s base, 10 min cap, jitter on), retries land at roughly 0.5–1 s, 1–2 s, 2–4 s, … Jitter spreads retries from many failing tasks so they don’t hit a recovering service at once.

When retries reaches maxRetries, the next failure is terminal: state failure, error stored, message dead-lettered. AsyncResult.get() then throws TaskFailureException.

maxRetries travels in the message. TaskRef.delay/applyAsync copy the task’s setting; app.send(), Beat and Rose’s fire action send 0 unless told otherwise.

Time limits: timeLimit makes the worker give up waiting and treat the run as a failure (so it retries with autoRetry), but Dart cannot kill the handler, which keeps its slot until it returns. Poll ctx.isAbandoned or ctx.softTimeLimitExceeded in loops.

import 'package:gisila_queue/gisila_queue.dart';
import 'package:http/http.dart' as http;
app.task<void>('webhooks.deliver', (ctx) async {
final res = await http.post(Uri.parse(ctx.arg(0) as String), body: ctx.arg(1) as String);
if (res.statusCode == 429) {
final wait = int.tryParse(res.headers['retry-after'] ?? '') ?? 30;
ctx.retry(countdown: Duration(seconds: wait));
}
if (res.statusCode >= 500) ctx.retry(cause: 'HTTP ${res.statusCode}');
if (res.statusCode == 410) throw const RejectTask(reason: 'endpoint gone');
return null;
}, maxRetries: 8, retryBackoff: const Duration(seconds: 5), timeLimit: const Duration(seconds: 30));