Dead-letter handling
When a job exhausts its retry budget it is marked Failed and its dead-letter handler runs, giving you a chance to alert, compensate, or forward the failure before it goes silent. Handlers are optional: a job without one is simply Failed and stays visible in the dashboard.
IDeadLetterHandler<TJob>¶
Register a dead-letter handler to react when a job permanently fails. The handler runs in an isolated DI scope — exceptions are logged and swallowed, so a failing handler never crashes the dispatcher or prevents other jobs from running.
public sealed class PaymentDeadLetterHandler : IDeadLetterHandler<ProcessPaymentJob>
{
private readonly IAlertService _alerts;
private readonly IRefundService _refunds;
public PaymentDeadLetterHandler(IAlertService alerts, IRefundService refunds)
{
_alerts = alerts;
_refunds = refunds;
}
public async Task HandleAsync(
JobRecord failedJob,
Exception lastException,
CancellationToken cancellationToken)
{
await _alerts.SendAsync(
$"Payment job {failedJob.Id} failed after {failedJob.Attempts} attempts: {lastException.Message}",
cancellationToken);
// Optionally trigger compensation logic
// var input = JsonSerializer.Deserialize<PaymentInput>(failedJob.InputJson);
// await _refunds.InitiateAsync(input!.OrderId, cancellationToken);
}
}
// Register in Program.cs
builder.Services.AddTransient<IDeadLetterHandler<ProcessPaymentJob>, PaymentDeadLetterHandler>();
What the JobRecord contains¶
The JobRecord passed to your handler gives you everything you need to diagnose the failure or trigger compensation:
-
Id
The failed job's unique identifier.
-
Attempts / MaxAttempts
How many times the job ran and what the configured limit was.
-
LastErrorMessage
The exception message from the final failure.
-
LastErrorStackTrace
Full stack trace from the final failure.
-
InputJson
Serialized input — deserialize with
System.Text.Jsonto recover the original payload. -
Queue / Tags / CreatedAt
Full execution context for routing, filtering, and auditing.
When the node dies¶
A job can also run out of attempts without ever reporting an error: the process running it died (out of memory, kill -9, a lost machine) and each recovery used up an attempt. When the orphan watcher finds such a job with no attempts left, it marks it Failed and runs the same dead-letter handling: the typed IDeadLetterHandler<TJob> and every IDeadLetterForwarder.
When several services share one database, the watcher of every node scans every job, but a node fails a job without attempts left only if it can load the job type. The job stays Processing until a node of the service that owns it finds it, so its typed handler runs there (before v6.3.0 any node could fail it, and a node of another service skipped the handler). See Multi-service.
The exception you receive is an OrphanedJobException, because the job never threw one. It carries the job id and how many attempts were used, so a handler can tell a crash from an ordinary failure:
public Task HandleAsync(JobRecord failedJob, Exception lastException, CancellationToken ct)
{
if (lastException is OrphanedJobException)
{
// The node died while running this job: likely a poison input (for example, out of memory).
}
return Task.CompletedTask;
}
- Once per job. The storage reports a job to the node whose scan moved it, so several nodes running the watcher never dead-letter the same job twice.
- After the move. The job is already
Failedin storage when the handler runs. If the handler throws, or the node stops right then, the job staysFailedand the handler is not called again. - Custom storage providers. Dead-letter handling for crashed jobs needs the storage to implement
IOrphanedJobReporter. A provider that does not still fails those jobs, without calling any handler.
Safety guarantees¶
Note
- Dead-letter handlers are optional. Jobs without a registered handler are simply marked
Failedand remain visible in the dashboard for manual requeue. - Handlers run in an isolated DI scope. An exception thrown inside a handler is logged and swallowed — it never crashes the dispatcher.
- The
IDeadLetterHandler<TJob>generic parameter is the job type, not the input type. - Handlers work for both
IJobandIJob<TInput>implementations.
How long failed jobs are kept¶
Warning
Jobs in the Failed state are retained for 30 days by default, then automatically purged. Once a job is purged, its history and input are gone permanently. If you need longer retention for audit purposes, increase options.RetentionFailed — or set it to TimeSpan.Zero to keep failed jobs indefinitely.
Next¶
- Dead-letter forwarding: copy an exhausted job to another topic or queue, or to any destination.
- Alerts: be notified when a job fails for good.
- Retries: the policy that decides when a job is exhausted.