DLQ redrive or a Postgres retry table: which one is cheaper to run
A cost and failure-mode comparison of managed dead-letter redrive against a hand-rolled jobs table polled from your own database, with runnable examples for both.
If your failure rate is low and you already run Postgres, a jobs table polled with SELECT … FOR UPDATE SKIP LOCKED is genuinely cheap, and nobody should talk you out of it. What tips the balance is triage: the moment you need to see what died, read the payloads, and replay a subset, a managed dead-letter queue earns its keep. On Infrai every part of that recovery path — create, consume, ack, dead-letter reads, redrive — is free; only publish is metered.
The interesting comparison isn’t the invoice. It’s what each option costs you at 3 a.m.
The hand-rolled version, done properly
A retry table is more code than people remember. You need a lease column so two workers don’t grab the same row, an attempt counter, a next-attempt timestamp for backoff, and an index that keeps the claim query off a sequential scan once the table has a few million dead rows in it.
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
attempts int NOT NULL DEFAULT 0,
next_at timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz,
dead boolean NOT NULL DEFAULT false
);
CREATE INDEX jobs_claimable ON jobs (next_at) WHERE NOT dead;
-- one worker's claim, safe under concurrency
UPDATE jobs SET locked_until = now() + interval '5 minutes', attempts = attempts + 1
WHERE id IN (
SELECT id FROM jobs
WHERE NOT dead AND next_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY next_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
RETURNING id, payload, attempts;
That query is correct, and it’s also a poller: every worker runs it on a loop whether or not there’s work. Ten workers on a one-second tick is 864,000 statements a day against the same database serving your users, plus the index bloat from rows that churn through locked_until. Small apps survive this easily. The bill arrives later, as a Postgres upgrade you make for a reason that has nothing to do with your product.
The managed version, in four calls
A queue with a dead-letter lane is one create. Pass dlq as the name of the failure queue — booleans are rejected with CAPABILITY_NOT_IMPLEMENTED (HTTP 501), which is a confusing way to learn the field wants a string.
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"retry-jobs","type":"standard","dlq":"retry-jobs-dead"}'
{
"ok": true,
"data": {
"name": "retry-jobs",
"type": "standard",
"message_retention_days": 14,
"max_message_size_kb": 256,
"visibility_timeout_default": 300,
"max_receive_count": 3,
"dlq_name": "retry-jobs-dead"
}
}
Publishing is the producer side. Note the asymmetry that costs people an afternoon: the request field is body, the response field is payload.
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"retry-jobs","body":{"invoice_id":"inv_881","action":"charge"}}'
Three failed deliveries and the message lands in retry-jobs-dead. Reading it back is where the documented route and reality part company — GET /v1/queue/dlq/list/retry-jobs returned an empty array in our testing even with a non-zero dlq_count, so consume the dead queue by name instead.
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"retry-jobs-dead","max_messages":10}'
Then replay by id, against the parent queue’s name.
A Python worker that drains and replays
This is the whole triage loop — read the dead payloads, decide, replay the ones worth replaying, and leave the malformed ones alone so a human sees them.
import os
import sys
import requests
BASE = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
sys.exit("INFRAI_API_KEY is not set")
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
PARENT = "retry-jobs"
DEAD = "retry-jobs-dead"
def call(path, payload):
r = requests.post(f"{BASE}{path}", headers=H, json=payload, timeout=20)
out = r.json()
if not out.get("ok"):
raise RuntimeError(f"{path}: {out['error']['code']} - {out['error']['message']}")
return out["data"]
def replayable(payload):
# A payload that died because it is malformed will die again. Skip it.
return isinstance(payload, dict) and isinstance(payload.get("invoice_id"), str)
moved, skipped = 0, 0
while True:
batch = call("/v1/queue/consume", {"queue": DEAD, "max_messages": 10})
items = batch.get("items", [])
if not items:
break
for msg in items:
if not replayable(msg.get("payload")):
skipped += 1
print("left in place:", msg["message_id"], file=sys.stderr)
continue
call(f"/v1/queue/dlq/redrive/{PARENT}", {"message_id": msg["message_id"]})
moved += 1
print(f"redriven {moved}, skipped {skipped}")
Run it by hand after an incident and it’s a runbook; run it on a schedule and it’s a service. Either way it’s about forty lines, against the several hundred a correct retry table plus its own dead-row handling ends up being.
Check the result with the stats route, which is free to call as often as you like:
curl -sS "https://api.infrai.cc/v1/queue/stats/retry-jobs" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Where the money actually goes
| Postgres retry table | Infrai queue + DLQ | Amazon SQS redrive | BullMQ on managed Redis | |
|---|---|---|---|---|
| Marginal cost per job | $0 (already paying for the DB) | $0.00002 to publish, retries free | Per request, both queues | $0 per job, fixed Redis bill |
| Fixed monthly floor | Your existing database | None | None | Roughly $10 for the smallest useful instance |
| Replay one message | Your own UPDATE | POST /v1/queue/dlq/redrive/{queue} | Message move task | job.retry() |
| Replay in bulk | One UPDATE | Not working today | Supported | Supported |
| Read dead payloads | SELECT | Consume the -dead queue | Receive from the DLQ | Bull Board |
| Who gets paged | You | Nobody | Nobody | You, for Redis |
Those figures were verified 2026-07-26, and rates on this platform have moved down over time, so read the current ones rather than trusting a table in an article:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id | startswith("queue.")) | {id, billable: .billing.is_billable, price: .billing.price_usd}'
New accounts start with $2 free credit, which at the publish rate is a hundred thousand messages — enough that a side project may never see an invoice. The durable point isn’t the rate, though. It’s that the same key already reaches email, storage and cron, so “queue the retry, then email the customer when it finally succeeds” doesn’t add a second vendor, a second SDK or a second invoice line to reconcile.
The limitations, stated plainly
The delivery budget is fixed at three attempts. max_receive_count is accepted at create and through PATCH /v1/queue/update/{queue}, and in our testing the queue reported 3 regardless — if your policy needs ten tries before a job is declared dead, count them in your handler.
Bulk redrive doesn’t work today; per-message redrive does, and it resets the delivery counter so a replayed message gets a full budget again. A message over the 256 KB ceiling fails with a 400 whose text says the queue “already exists”, which is misleading enough to be worth writing down. And there’s no per-queue region pin — western and China are both served, but a strict EU-only data path isn’t something we’d claim.
If you’re already on Celery or Sidekiq, their dead sets and dashboards are more mature than this, and swapping queue vendors purely for redrive is a poor trade-off. If you need bulk redrive as a first-class operation this quarter, SQS has it and we don’t.
Otherwise: keep the table if failures are rare and you never inspect them; take the managed dead-letter lane the first time someone asks “what actually failed last night?”