A durable job queue with exponential-backoff retries and a dead-letter tier — built entirely out of one Xano table and a handful of endpoints. Push a job, have a worker claim it, report success or failure. Failed jobs get re-queued with backoff; if they exhaust their retries they land in the DLQ where a human can inspect, requeue, or delete.
This is a module, not a full app: no frontend, no workers included. You bring the workers (another Xano task, an external cron, a Lambda, whatever) and call the claim / complete / fail endpoints.
- 1 table
jr_job— one row per job; state machine enforced by the endpoints - HTTP API
job-retry— enqueue, claim, complete, fail, list, DLQ, requeue
npm install -g @xano/cli
xano profile:wizard
cd backend
xano workspace pushCreates 1 table, 1 api group, and 8 endpoints.
+---------+
enqueue --> | queued | <---+
+----+----+ | fail (attempts < max)
| |
claim v | with run_after = now + backoff * 2^(attempts-1)
+---------+ |
| running |-----+
+----+----+
|
complete | fail (attempts == max)
v | v
+-----------+ | +--------+
| completed | +--->| dead | --- requeue ---> queued (attempts reset)
+-----------+ +--------+
POST /api:job-retry/jobs { job_type, queue?, payload?, max_attempts?, backoff_seconds?, run_after? }
GET /api:job-retry/jobs ?status&queue&job_type&page&per_page
GET /api:job-retry/jobs/{job_id} single job
POST /api:job-retry/jobs/claim { queue?, worker_id? } # atomic queued -> running
POST /api:job-retry/jobs/{job_id}/complete { result? }
POST /api:job-retry/jobs/{job_id}/fail { error }
GET /api:job-retry/dlq ?queue&page&per_page # dead-letter list
POST /api:job-retry/dlq/{job_id}/requeue # reset & re-queue
B=https://YOUR-INSTANCE.n7d.xano.io
curl -XPOST $B/api:job-retry/jobs \
-H 'Content-Type: application/json' \
-d '{
"job_type": "send_welcome_email",
"payload": {"user_id": 42},
"max_attempts": 5,
"backoff_seconds": 30
}'while true; do
job=$(curl -sXPOST $B/api:job-retry/jobs/claim -H 'Content-Type: application/json' -d '{"queue":"default","worker_id":"worker-1"}')
if [[ $(echo "$job" | jq .claimed) == "false" ]]; then sleep 5; continue; fi
id=$(echo "$job" | jq .job.id)
if do_work_based_on "$job"; then
curl -sXPOST $B/api:job-retry/jobs/$id/complete -H 'Content-Type: application/json' -d '{"result":{"ok":true}}'
else
curl -sXPOST $B/api:job-retry/jobs/$id/fail -H 'Content-Type: application/json' -d '{"error":"provider 5xx"}'
fi
donecurl -s $B/api:job-retry/dlq
curl -sXPOST $B/api:job-retry/dlq/7/requeue # give it another shotWhen a job fails and retries remain, run_after is set to:
now + backoff_seconds * 2^(attempts - 1)
With default backoff_seconds=30 and max_attempts=5:
| attempt | delay before retry |
|---|---|
| 1 | 30 s |
| 2 | 1 min |
| 3 | 2 min |
| 4 | 4 min |
| (fails a 5th time → dead-letter) |
Tune backoff_seconds and max_attempts per enqueue.
jr_job
| field | type | notes |
|---|---|---|
id |
int | PK |
created_at / updated_at |
timestamp | |
queue |
text | default "default" |
job_type |
text | app-defined discriminator |
payload |
json | whatever the worker needs |
status |
enum | queued / running / completed / dead (no endpoint writes failed; the fail endpoint transitions to queued on retry or dead when exhausted) |
attempts / max_attempts / backoff_seconds |
int | |
run_after |
timestamp | jobs aren't claimable until this time |
claimed_at / claimed_by |
timestamp / text | who has it |
last_attempted_at / last_error / completed_at / result |
audit trail |
Indexed on status, queue, run_after, created_at desc.
claimis the atomic primitive. Flipqueued→runningand return the row in one endpoint. Workers never see jobs that aredead,completed, or scheduled for the future.run_aftergates everything. Schedule a job for later by setting it at enqueue. Retries set it automatically via the backoff formula.- Requeue resets attempts. DLQ requeue is "give up on the retry history and try again" — so use it after you've fixed the bug, not as a way to keep the same retry clock running.
- Delete vs dead-letter. There is no delete endpoint. If you want to drop dead jobs, do it at the DB level. Keeping them around is usually the right default for post-mortems.
- At-least-once, not exactly-once. If a worker dies mid-job the row stays in
runninguntil you build reaping (e.g., a task that re-queuesrunningjobs whoselast_attempted_atis too old). Intentionally out of scope for this module.
MIT.