Guide 09
Error taxonomy, retries, and DLQ
Audience: Anyone configuring worker retries. Goal: Retry what heals; fail fast on poison; never infinite loops.
TL;DR
| Error class | Action |
|---|---|
| Transient | Retry with backoff + jitter |
| Permanent | Mark failed; do not retry |
| Unknown | Limited retries, then DLQ + alert |
| Poison message | DLQ after max attempts; fix and redrive |
Contents
---
1. Taxonomy
| Class | Examples | Retry? |
|---|---|---|
| Transient network | 503, timeout, connection reset | Yes |
| Transient overload | 429, broker full | Yes (honor Retry-After) |
| Dependency down | DB failover in progress | Yes (bounded) |
| Validation / schema | Bad payload, missing field | No |
| Business rule | Invoice already void | No (or no-op success) |
| Auth config | Wrong API key | No until fixed |
| Bug / NPE | Unexpected exception | Limited then DLQ |
Encode classes as exceptions or error codes your middleware understands.
2. Retry policy
Recommended defaults (tune with data):
text
max_attempts: 5
backoff: exponential
base: 1s
cap: 10m
jitter: full or equal jitterpython
# PSEUDOCODE
delay = min(cap, base * 2 ** attempt) * random(0.5, 1.5)Do not retry non-idempotent permanent side effects without a guard.
3. Timeouts
Every external call needs a timeout:
- HTTP client: connect + read
- DB statement timeout
- Soft/hard task time limits (Celery/Dramatiq)
A task without timeouts becomes a stuck consumer (prefetch blocked).
4. Dead-letter queues
RabbitMQ
- Queue with DLX →
jobs.dlq - Reject/nack without requeue after max attempts
- Redrive tool for operators
Kafka
- Error topic
billing.events.errors - Or stop-the-world + alert on poison (document choice)
5. Pseudocode
python
# PSEUDOCODE
async def handle(job_id: str):
try:
await run(job_id)
await mark_succeeded(job_id)
except PermanentError as e:
await mark_failed(job_id, str(e))
# ack / no retry
except TransientError as e:
attempts = await bump(job_id, str(e))
if attempts >= max_attempts:
await mark_failed(job_id, str(e))
await publish_dlq(job_id)
return
raise Retry(delay=backoff(attempts))
except Exception as e:
attempts = await bump(job_id, "unknown:" + str(e))
if attempts >= max_attempts:
await mark_failed(job_id, str(e))
await publish_dlq(job_id)
await alert("poison_or_bug", job_id)
return
raise Retry(delay=backoff(attempts))6. Alerting
| Signal | Severity |
|---|---|
| DLQ depth > 0 for critical queues | High |
| Retry rate spike | Medium |
| Success rate drop | High |
| Time-to-complete SLO breach | Medium |
| Zero consumers | Critical |
7. Anti-patterns
except Exception: retryforever- No jitter (thundering herd)
- Retrying after non-idempotent charge without guard
- Silent drop of poison messages
- Using DLQ as a black hole with no runbook
Related: 03 Lifecycle · 10 Testing