A function call failed — a Kafka handler, an SQS consumer, any func(args).
Hand rdq the handler and its payload; rdq retries it on your schedule, or parks it in a
dead-letter queue with its full failure history, ready to inspect and redrive.
Bring-your-own-storage · broker-agnostic · polyglot · at-least-once · horizontally scalable
Embed the Go or Java SDK, or run rdq-server as a central retry hub for any language.
Retry queues and DLQs live in the datastore you already run — PostgreSQL first, more via a documented SPI. No new stateful infra.
Kafka, SQS, Redpanda, RabbitMQ, or no broker at all. rdq accepts failures from any source and never sits on the happy path.
Every attempt's error, stack trace, and timestamp travels into the DLQ — browse, filter, and safely single- or bulk-redrive.
Stateless workers, atomic claims, and leases. Add a node to scale; a kill -9 mid-task is a non-event.
rdq is not a broker and not on the happy path. A failed call is submitted to storage you already run; stateless workers claim due tasks, re-invoke the handler on your backoff schedule, and park exhausted work in the DLQ — inspectable and replayable.
// Register a handler under a stable name — it survives
// deploys, restarts, and language boundaries.
rdq.Register("charge-payment", func(ctx context.Context,
t envelope.Envelope) error {
if err := charge(t.Payload()); err != nil {
return err // non-nil → retried on your backoff schedule
}
return nil // nil → SUCCEEDED
})
// A worker claims due tasks atomically, invokes the
// handler, and records every attempt.
w, _ := rdq.NewWorker(store, rdq.WithQueue("payments.charge",
rdq.MaxAttempts(5),
rdq.BackoffExponential(time.Second, 2.0, 5*time.Minute),
))
w.Run(ctx) Register handlers under stable names, wrap an existing consumer, and let the worker retry with backoff and dead-letter with full context. The only dependency is the storage you already run.
Go SDK guide →$ rdq dlq list --queue payments.charge
ID ERROR ATTEMPTS AGE
01J8Z…K3 TimeoutException 5 2h14m
01J8Z…M7 TimeoutException 5 1h58m
# read one task's full failure history
$ rdq dlq get 01J8Z…K3
# fix shipped — redrive everything that timed out
$ rdq dlq redrive --queue payments.charge \
--error TimeoutException --since 14:00
redriven 128 tasks (audit id 01J90…) Every attempt's error, stack, and timestamp lands in the DLQ. Browse and filter by queue, error type, handler, or time — then single- or bulk-redrive after a fix ships. Every mutation is audit-logged.
DLQ & redrive guide →Run the central retry hub — any language talks to it over REST or gRPC.
# 1. Point rdq at storage you already run, and start the hub
docker run -e RDQ_DSN=postgres://user:pass@host/db \
-p 8080:8080 ghcr.io/srjn45/rdq-server:latest
# 2. Submit a failed unit of work
curl -X POST localhost:8080/v1/queues/payments.charge/tasks \
-d '{"handler_ref":"charge-payment","payload":"eyJvcmRlciI6NDJ9"}'
# 3. Later: inspect what never succeeded, then redrive after the fix ships
rdq dlq list --queue payments.charge
rdq dlq redrive --queue payments.charge --error TimeoutException Every event-driven system eventually asks the same question — a handler failed, now what? Today you answer it with broker-locked, language-locked, hand-rolled plumbing, and the DLQ you land in is a graveyard with no failure context and no safe replay. rdq is the missing bolt-on for that one narrow job.
Read post →rdq spreads work across a fleet of stateless workers with no leader and no membership protocol. The entire correctness argument rests on two primitives — an atomic claim and a lease. This is how they work, and the story of a UNIQUE collision that only appeared when a redriven task's lease expired.
Read post →