●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Isolating Poison Messages in a Claude Async Pipeline: A Dead-Letter Queue Implementation Note
How one broken input can stall an entire batch — and how to isolate these 'poison messages' with a Cloudflare Queues dead-letter queue. Covers classifying Claude API failures and safe redrive, all from production experience.
I once tried to tag a fresh batch of wallpaper metadata by sending 800 image records to Claude in one go. Around record 300 the pipeline froze. Digging through the logs, a single malformed JSON record had crashed the consumer with an exception, and every healthy record behind it got swept into a retry traffic jam. The culprit was one row where a past export bug had left raw binary inside the description field.
This pattern — one broken input dragging down all the healthy ones — is known in the messaging world as a poison message. It stays invisible while you loop over the API synchronously, but the moment you move to an async queue in production, you will hit it at least once. This article is an implementation note on isolating it with a dead-letter queue (DLQ), drawn from what I actually ran into in my wallpaper app backend and the content pipelines behind my four sites.
How a Poison Message Kills the Whole Pipeline
The basic shape of an async pipeline is simple: push jobs onto a queue, and a consumer pulls them one at a time to call the Claude API. It is elegant while everything works — the problem is what happens when the consumer fails.
Most queues use at-least-once delivery: a failed message is not acked, and it gets redelivered after some delay. That is great for transient network blips and 429 rate limits. But when the message itself is broken and fails no matter how many times you process it, that same message gets redelivered forever.
In my first implementation, this redelivery swept up the rest of the work in three ways. First, the one broken record sat at the head of the batch, and because I called retryAll(), the nine healthy records in that batch got resent along with it. Second, every resend burned Claude API tokens, so one poison message quietly inflated my bill. Third, with no retry cap, the poison message lived in the queue forever, eating throughput during its visibility timeout.
The essence of handling poison messages is to separate "failures that heal" from "failures that don't," and physically eject the latter from the healthy flow.
First, Separate Healing Failures from Permanent Ones
Retrying every failure uniformly amplifies poison messages. For a pipeline calling the Claude API, it helps to sort errors into three buckets.
Transient and worth retrying: 429 rate limits, 529 overloaded, 500/503, network timeouts. These heal with time, so exponential backoff is worth it.
Input-caused and permanent (poison): 400 invalid_request (the request body is broken), message deserialization failures, structured outputs that fail schema validation, and inputs too large to ever fit within max_tokens. These should go straight to the DLQ.
Then there is a gray zone: a response truncated with stop_reason: max_tokens, or a refusal. Retrying rarely changes the outcome, but reshaping the input might fix it — so a capped number of retries followed by a DLQ handoff fits best.
Closing this decision inside a single function keeps the consumer clean.
// classify.ts — sort a failure into "retry" or "poison"import Anthropic from "@anthropic-ai/sdk";export type Verdict = "retry" | "poison";export function classifyError(err: unknown): Verdict { // The SDK's APIError carries a status code if (err instanceof Anthropic.APIError) { const s = err.status; if (s === 429 || s === 529 || s === 500 || s === 503) return "retry"; if (s === 400 || s === 422) return "poison"; // the input is broken if (s === 401 || s === 403) return "retry"; // key/IP issues; fixable in config } // Network-class errors (abort / ETIMEDOUT) are transient const name = (err as Error)?.name ?? ""; if (name === "AbortError" || /timeout|ECONNRESET|ETIMEDOUT/i.test(String(err))) { return "retry"; } // JSON.parse failures and schema violations are poison if (err instanceof SyntaxError) return "poison"; // Unknown errors lean to the safe side (retry); the retry cap drops them to DLQ return "retry";}
The key is to not treat the unknown as "poison by default." Unknown errors lean toward retry and naturally fall into the DLQ via the max_retries cap described below. This avoids the accident of throwing away a transient failure you never anticipated by mislabeling it poison.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A decision function that mechanically separates transient failures (retry) from poison messages (isolate) using stop_reason and error types
✦An isolation pipeline built with Cloudflare Queues max_retries and dead_letter_queue so one broken item never stalls the whole batch — with Before/After code
✦A redrive workflow that classifies dead-lettered messages by cause and uses idempotency keys to prevent double-billing on reprocessing
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Since I run all four sites on Cloudflare Workers, my async work also runs on Cloudflare Queues. The nice part is that a DLQ is a first-class infrastructure feature — you do not need to maintain your own retry-count column in the app.
The configuration is just two fields on the consumer. max_retries is the maximum retries per message (defaults to 3), and dead_letter_queue is the queue to send a message to once that cap is reached. If the target queue does not exist, it is created automatically.
# wrangler.tomlname = "wallpaper-tagger"main = "src/index.ts"[[queues.producers]]queue = "tag-jobs"binding = "TAG_QUEUE"[[queues.consumers]]queue = "tag-jobs"max_retries = 3 # after 3 failures, go to the DLQmax_batch_size = 10dead_letter_queue = "tag-jobs-dlq"# Attach a consumer to the DLQ too, so you can observe and redrive[[queues.consumers]]queue = "tag-jobs-dlq"max_batch_size = 5
If you do not define dead_letter_queue, messages past the cap are silently discarded. This is easy to miss on first encounter; I spent a few hours confused about why "I set a retry cap, yet failed jobs left no trace anywhere." If you do not want to lose failures, the DLQ declaration is mandatory.
The Consumer Implementation — Before / After
My first consumer called retryAll() on any failure — a textbook poison-message factory.
// Before — one failure drags down the whole batchexport default { async queue(batch: MessageBatch, env: Env) { try { for (const msg of batch.messages) { await tagOneImage(msg.body, env); // if any one throws... } batch.ackAll(); } catch (e) { batch.retryAll(); // ...the nine healthy records get resent too } },};
This has two sins: it resends healthy messages on a single failure, and it does not distinguish a transient failure from a poison message.
The fixed version acks/retries each message independently and immediately acks poison messages after recording them. On Cloudflare Queues, acking a message without calling message.retry() marks it processed; calling retry() redelivers it, and hitting max_retries routes it to the DLQ automatically.
// After — process each message independently and isolate only the poisonimport { classifyError } from "./classify";export default { async queue(batch: MessageBatch, env: Env) { for (const msg of batch.messages) { try { await tagOneImage(msg.body, env); msg.ack(); // only this one is marked successful } catch (e) { const verdict = classifyError(e); if (verdict === "poison") { // Poison: ack immediately to remove it from the normal flow, // and record it with its failure reason in a DLQ-equivalent log await env.DLQ_LOG.put( `poison:${msg.id}`, JSON.stringify({ body: msg.body, reason: String(e), at: Date.now() }), { expirationTtl: 60 * 60 * 24 * 30 } ); msg.ack(); } else { // Transient: send to retry. Hitting max_retries routes it to the DLQ msg.retry({ delaySeconds: backoff(msg.attempts) }); } } } },};// Exponential backoff (attempts is 1-based) + jitterfunction backoff(attempts: number): number { const base = Math.min(2 ** attempts, 60); return base + Math.floor(Math.random() * base * 0.3);}
Being able to pass delaySeconds to msg.retry() matters more than it looks. Without it, you resend immediately after a rate limit and hit another 429 — a wasteful retry loop. The jitter on the backoff prevents multiple messages in the same batch from being resent at the exact same instant and re-congesting.
Observing the DLQ and Redriving Safely
A DLQ is not "where failures go to die" — it is "where failures are parked until you understand the cause and send them back." In my DLQ consumer I classify incoming messages by cause before deciding what to do next.
// dlq-consumer.ts — classify dead-lettered poison messages for triageexport async function handleDlq(batch: MessageBatch, env: Env) { for (const msg of batch.messages) { const job = msg.body as TagJob; const reason = await diagnose(job); // re-assess the cause await env.DLQ_INDEX.put(`${reason}:${job.id}`, JSON.stringify(job)); msg.ack(); // ack once pulled from the DLQ (don't keep piling up) }}// Redrive: send only the repairable ones back to the main queueexport async function redrive(env: Env, reason: string) { const list = await env.DLQ_INDEX.list({ prefix: `${reason}:` }); for (const key of list.keys) { const job = JSON.parse((await env.DLQ_INDEX.get(key.name))!); const repaired = repairJob(job); // e.g. strip invalid bytes, trim description if (!repaired) continue; // don't send back what you can't fix await env.TAG_QUEUE.send({ ...repaired, idemKey: `redrive:${job.id}` }); await env.DLQ_INDEX.delete(key.name); }}
The most important thing in a redrive is idempotency. Some jobs in the DLQ may have "succeeded in calling Claude but failed while saving the result." Reprocessing them unconditionally calls Claude twice for the same image and double-bills you. I attach an idemKey to each job and guard the result-save step with "if already saved under this key, skip the API call." With an idempotency key in place, you can rerun a redrive as many times as you like without fear.
Poison-Message Patterns Specific to the Claude API
On top of generic queue design, a Claude pipeline has its own sources of poison messages. When I took inventory of what actually accumulated in my DLQ, most fell into one of these.
Inputs that exceed the max_tokens ceiling and cannot fit into a single request no matter how you slice them. In my wallpaper metadata, a handful of images had enormous EXIF comments embedded — exactly this case. The fix is to measure input length before enqueueing and route over-limit items to a separate lane (summarize first, then send).
Structured outputs via tool_use where Claude's returned JSON does not match your schema. The same input tends to produce the same structure on retry, so treating it as poison is reasonable. I send schema violations to the DLQ while flagging the prompt instructions for review.
Responses with stop_reason: refusal, where Claude declined to generate for content-policy reasons. Retrying does not solve this, so it goes straight to the DLQ for a human to check. In my use case (wallpaper tagging), art-style image descriptions occasionally tripped this.
Given all this, one discipline pays off: always inspect stop_reason, and never treat anything but end_turn as success. Many incidents start by assuming "a 200 came back, so it succeeded" and then saving an incomplete response that was truncated by max_tokens.
// Judge success by stop_reason, not just statusconst res = await client.messages.create({ /* ... */ });if (res.stop_reason !== "end_turn" && res.stop_reason !== "tool_use") { // max_tokens / refusal is not a "successful 200" throw new IncompleteResponseError(res.stop_reason);}
What Worked in Production
After running this setup for about half a year across content generation for four sites and my wallpaper app backend, a few conclusions of my own emerged.
Don't be greedy with max_retries — 3 is practical. Most rate limits and overloads recover within three backoffs, and going higher only let poison messages linger longer. I started at 5; after dropping to 3, the throughput dips became noticeably shallower.
Treat the DLQ as a monitored signal. I send an alert when the DLQ message count crosses a threshold. A quietly growing DLQ was almost always the sign of a new kind of breakage appearing in the input data. Isolating failures and then ignoring them means missing the very failures you worked to eject.
And ultimately, handling poison messages is the design of "how you treat broken inputs" itself. Both of my grandfathers were temple carpenters, and I'm told they inspected each piece of timber before joining it. Just as you don't quietly mix rotted or knotted wood into a structure, not mixing one broken record into a healthy flow is what extends the life of the whole pipeline. Running an app portfolio with 50 million cumulative downloads on my own since 2014, I've felt the value of "moving broken things aside quickly, quietly, and reliably" again and again.
If you want to try this, I'd suggest starting by adding a classifyError-style split to a synchronous loop you already have. Just seeing failures as "healing vs. not" changes the stability of a pipeline more than you'd expect. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.