Every guide to agent reliability tells you to retry with backoff. That advice is right, and it quietly assumes something that isn't always true: that failures are transient. A blip, a rate limit, a cold start — try again in a second and it works.

Some tool calls don't work that way. The model generates an argument that fails a schema check, and it generates the same argument on the next turn. A resource got deleted and it's not coming back. The agent is asking for a scope it will never be granted. These fail deterministically — the identical call, retried, fails the identical way. Backoff doesn't help; it just makes the doomed retries politer while they drain your token budget and your API spend.

And on a single-lane worker, the poison task does something worse than waste money: it blocks the line. Every good task queued behind it waits while your loop retries the one call that will never succeed. This is the failure mode that turns a self-healing agent into a 3am page.

The pattern: retry N times, then dead-letter#

A dead-letter queue (DLQ) is the standard fix from message-queue systems — SQS, RabbitMQ, every serious job runner has one. The rule is simple: after a message fails processing more than N times, stop retrying it and move it to a separate queue instead of dropping it or looping forever. The main lane keeps flowing. The bad task is parked somewhere you can look at it.

In SQS the knob is maxReceiveCount. You attach a redrive policy to your main queue that points at a DLQ and sets the ceiling:

{
  "RedrivePolicy": {
    "deadLetterTargetArn": "arn:aws:sqs:...:agent-tasks-dlq",
    "maxReceiveCount": 5
  }
}

Each time a consumer receives a message and fails to delete it — because processing threw — the receive count increments. Once it crosses maxReceiveCount, SQS moves the message to the DLQ automatically. You don't write the move; you set the threshold and the queue enforces it.

If you're running your own queue on Postgres (the right call for most solo builders — see Postgres LISTEN/NOTIFY vs Redis Streams vs SQS for agent job fan-out), you replicate this with an attempts counter:

-- on failure
UPDATE agent_tasks
   SET attempts = attempts + 1,
       last_error = $2,
       status = CASE WHEN attempts + 1 >= 5 THEN 'dead_letter' ELSE 'queued' END
 WHERE id = $1;

Rows that reach status = 'dead_letter' drop out of the worker's WHERE status = 'queued' pickup query. Same behavior, one column.

What the classic DLQ pattern leaves out for agents#

Copying maxReceiveCount verbatim gets you the shape but misses three things that matter specifically because the task is an AI agent's tool call.

1. Capture the whole failing turn, not just the payload

A normal DLQ stores the message body. For an agent, the bug is usually in what the model generated, so the body isn't enough. Dead-letter the full context: the tool name, the exact arguments the model produced, the surrounding messages, and every attempt's error — not just the last one.

dead_letter({
    "task_id": task.id,
    "tool": call.name,
    "arguments": call.arguments,      # what the model actually produced
    "messages": ctx.messages,         # the turn that led here
    "attempts": [
        {"n": 1, "error": "503 overloaded"},
        {"n": 2, "error": "503 overloaded"},
        {"n": 3, "error": "400 invalid: 'amount' must be an integer"},
    ],
    "dead_lettered_at": now(),
})

When you open the DLQ later, that third error tells you the real story: two transient blips, then the model settled on a malformed amount and kept sending it. You fix the tool schema or the prompt, not the retry policy.

2. Classify retryable vs terminal — don't burn attempts on the wrong failures

Not every failure should count the same toward the ceiling. A transient 503 might succeed next attempt; a 400 the model produced will not. If you let both spend the retry budget at the same rate, a flaky provider can dead-letter a task that would have recovered, and a genuinely poison task wastes five full attempts before you park it.

def is_retryable(err):
    if err.status in (408, 429, 500, 502, 503, 504):
        return True                    # transient — the world might fix it
    if err.status in (400, 401, 403, 404, 422):
        return False                   # deterministic — only a change fixes it
    return err.is_timeout or err.is_connection_reset

# terminal errors skip the retry budget and dead-letter on the first hit
if not is_retryable(err):
    dead_letter(task, err); return
if task.attempts >= MAX_ATTEMPTS:
    dead_letter(task, err); return
schedule_retry(task, backoff(task.attempts))   # transient — retry with backoff

The rule in one line: retry what the world might fix; dead-letter what only a code or prompt change can fix. For a model-generated 400, you have a third option between the two — repair-prompt the model once with the validation error before you give up. But do it once. A repair loop with no ceiling is just retry-forever wearing a disguise. (This is the same discipline as a retry budget for LLM calls: a hard cap on wasted work.)

3. Make replay idempotent, or the DLQ becomes a double-charge machine

The whole point of dead-lettering instead of dropping is that you can replay after a fix. But a side-effecting tool call that got dead-lettered may have already run on the server before the response was lost — the classic lost-response-after-commit failure. Replay it naively and you charge the card a second time.

So every side-effecting tool in the replayed path needs a stable idempotency key derived from the operation's identity, not minted fresh on replay:

key = stable_key(task.id, call.name, call.arguments)   # same every replay
result = charge(amount, idempotency_key=key)           # server dedupes

With the key in place, replaying a dead-lettered task is safe: if the original charge went through, the server returns the stored result instead of charging again. Without it, your incident-recovery tool becomes the incident. (See how to make agent tool calls idempotent for the key-derivation details.)

Treat the DLQ as an inbox, not an auto-retry#

The one operational mistake that undoes all of this: draining the DLQ back into the main queue on a timer. That doesn't recover anything — it re-poisons the main lane on a schedule. The whole value of dead-lettering is that the bad task stops until a human (or a fix) intervenes.

So wire the DLQ like an incident inbox:

The whole thing, in one sentence#

Retries keep a transient failure from killing a run; a dead-letter queue keeps a deterministic failure from killing everything behind it. Configure the ceiling (maxReceiveCount, or an attempts column), capture the full failing turn so you can diagnose what the model did, classify errors so you only retry what a retry could fix, and gate replay behind idempotent tools. Do that, and the poison tool call stops being a 3am runaway and becomes what it should be: a logged incident you fix on your own time.