Retries, acks and dead letters
This page is the rulebook for what happens to a message after its handler runs. The concept page Retries and failure handling explains when to use each tool.
import 'dart:io';
import 'package:gisila_queue/gisila_queue.dart';
app.task<void>('emails.send', (ctx) async { try { await mailer.send(ctx.arg(0) as String); } on SocketException catch (e) { ctx.retry(cause: e); // backoff: ~2 s, 4 s, 8 s, … }}, maxRetries: 5, retryBackoff: const Duration(seconds: 2));Outcomes
Section titled “Outcomes”| Handler outcome | Retries left? | Stored state | Message | Event |
|---|---|---|---|---|
| Returns a value | n/a | success (with result) |
acked | taskSucceeded |
ctx.retry() / throws RetryTask |
yes | retry |
new copy enqueued with retries + 1 and ETA = now + delay; original acked |
taskRetried |
ctx.retry() |
no | failure (retries exhausted (n): cause) |
dead-lettered, acked | taskFailed |
Throws anything else, autoRetry: true |
yes | retry |
as for ctx.retry() with the task’s backoff |
taskRetried |
| Throws anything else | no, or autoRetry: false |
failure (error.toString()) |
dead-lettered, acked | taskFailed |
Passes timeLimit |
as above | handled like a thrown TaskTimeLimitExceededException |
as above | as above |
Throws RejectTask(requeue: true) |
n/a | unchanged | re-enqueued with requeues + 1 (no delay); original acked. Past maxRequeues: failure, dead-lettered |
taskRetried (requeued: true) |
Throws RejectTask() |
n/a | revoked (with the reason) |
acked, not dead-lettered | taskRevoked |
| Worker crashes mid-task | n/a | stays started |
unacked; redelivered by the broker with redeliveries + 1. Past maxRedeliveries: failure, dead-lettered |
taskReceived again |
The retry delay is countdown if passed to ctx.retry(), otherwise
Task.backoffFor(retries + 1): retryBackoff × 2^(n-1),
capped at retryBackoffMax, jittered into [d/2, d] when retryJitter is on.
maxRetries comes from the message, not the registry. See the caution on
Tasks.
Ack timing
Section titled “Ack timing”The worker acks after the outcome is recorded and any retry copy is enqueued, in a
finally. Two consequences:
- A crash after the handler finished but before the ack runs the task again on another worker. Design handlers to be idempotent (see Delivery guarantees).
- A retry copy enqueued just before a crash and the redelivered original can both run.
InMemoryBroker removes a message when it is reserved, so a crash loses it.
Time limits
Section titled “Time limits”Task.timeLimit makes the worker stop waiting: the run is recorded as timed out and
retried or failed right away, and ctx.isAbandoned becomes true. Dart cannot preempt a
running handler in an isolate, so the handler keeps running and its concurrency slot stays
busy until it returns; the worker logs a warning every timeLimit while it does. Anything
it returns afterwards is discarded.
Task.softTimeLimit is only a flag: poll ctx.softTimeLimitExceeded and wind down.
Expiry and revocation
Section titled “Expiry and revocation”A message past its expires time, or whose id was revoked (live broadcast or revoked
in the backend), is recorded as revoked and acked without running. These checks happen
before the handler starts; a running handler only sees ctx.isRevoked.
Dead letters
Section titled “Dead letters”A message that fails for good is copied to the worker’s deadLetterQueue
('dead_letter' by default) with its ETA cleared and deadLetter set to:
{"reason": "retries exhausted (5): ...", "queue": "emails", "at": "2026-10-31T09:00:00.000Z"}Reasons: failure with no retries left, unknown task name after maxRequeues, too many
RejectTask(requeue: true), and more than maxRedeliveries broker redeliveries. A
RejectTask() without requeue, an expiry or a revocation is not dead-lettered.
The dead-letter queue is an ordinary queue on the same broker. Don’t consume it with a
normal worker. Inspect its depth with broker.length('dead_letter') or
gisila_queue inspect --queues=dead_letter.
GisilaQueue.replayDeadLetters()
Section titled “GisilaQueue.replayDeadLetters()”Future<int> replayDeadLetters({String queue = Worker.defaultDeadLetterQueue, int limit = 100})Moves up to limit messages back to the queue in their deadLetter['queue'] (or the app’s
default queue), with retries, requeues and redeliveries reset to 0 and
deadLetter cleared, stores pending for each, and returns the count.
final moved = await app.replayDeadLetters();print('replayed $moved');See also: Handle retries and failures, Workers, Errors.