Cleanup jobs that retry themselves: Node queue, DLQ, redrive

The smallest background-job setup on Infrai that survives failure: three deliveries, a dead-letter queue you get for free, and a redrive you run one message at a time.

For a nightly cleanup — expired sessions, orphaned uploads, stale export files — you want three things and nothing else: a place to park the work, automatic retries when a delete fails, and somewhere for the jobs that will never succeed to go and be looked at later. On Infrai that’s one POST /v1/queue/create, and the dead-letter queue comes with it whether you ask or not.

No Redis. No broker to run. The retry policy is already configured, which is either a relief or the first thing you’ll want to change.

The setup is one call

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"sweep-jobs","type":"standard","dlq":"sweep-jobs-dlq"}'

Omit dlq and one is still created for you, named sweep-jobs.dlq. Naming it yourself is only worth doing if you already have log dashboards keyed on a particular string.

Enqueueing a unit of work is the other half of the setup. Keep each message small and let it describe a slice of the cleanup rather than the whole thing:

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"sweep-jobs","body":{"task":"expire_sessions","before":"2026-07-19T00:00:00Z","limit":5000}}'
{
  "ok": true,
  "data": {
    "message_id": "qmsg_U3xizhWT5pcgdQ0OyuVdnMFZ",
    "queue": "sweep-jobs",
    "payload": { "task": "expire_sessions", "before": "2026-07-19T00:00:00Z", "limit": 5000 },
    "status": "available",
    "delivery_count": 0,
    "published_at": "2026-07-26T01:17:35Z"
  }
}

The field you sent as body came back as payload. That’s deliberate: the endpoint’s own name for it is payload, body is accepted as an alias, and metadata.warnings in the full response says so out loud. Worth flagging because the alias is top-level only — inside a publish_batch array each element must use payload.

Fire the whole thing on a schedule with POST /v1/cron/create, pointing an http_url task at your own enqueue endpoint. The cron reference documents the exact request shape; the field for the target URL is not spelled the way you’d guess from the response body, so copy it from the reference rather than from a cron.list result.

What “retry” means here, precisely

A message is delivered, becomes invisible for the visibility timeout (300 seconds by default), and one of three things happens. You ack it and it’s gone. You nack it and it goes straight back to available. Or your worker dies holding it, the lease expires, and the queue hands it to somebody else.

Every one of those redeliveries increments delivery_count. Reach three and the message is moved to the dead-letter queue instead of being offered again.

import process from "node:process";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function pull() {
  const res = await fetch("https://api.infrai.cc/v1/queue/consume", {
    method: "POST",
    headers,
    body: JSON.stringify({ queue: "sweep-jobs", max_messages: 10 }),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(`consume failed: ${out.error.code}`);
  return out.data.items;
}

async function done(id) {
  const res = await fetch("https://api.infrai.cc/v1/queue/ack", {
    method: "POST",
    headers,
    body: JSON.stringify({ queue: "sweep-jobs", receipt_handle: id }),
  });
  const out = await res.json();
  return out.data.acked === true;
}

async function retryLater(id) {
  await fetch("https://api.infrai.cc/v1/queue/nack", {
    method: "POST",
    headers,
    body: JSON.stringify({ queue: "sweep-jobs", message_id: id }),
  });
}

async function runSlice(payload) {
  // Your delete. Anything that throws gets retried; anything that returns is done.
  const res = await fetch("https://ops.internal.example.com/cleanup", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`cleanup endpoint returned ${res.status}`);
}

const batch = await pull();
for (const message of batch) {
  if (message.delivery_count === 3) {
    console.warn(`last chance for ${message.message_id}: ${JSON.stringify(message.payload)}`);
  }
  try {
    await runSlice(message.payload);
    if (!(await done(message.message_id))) console.warn(`ack refused for ${message.message_id}`);
  } catch (err) {
    console.error(`slice failed (${err.message}) — returning it to the queue`);
    await retryLater(message.message_id);
  }
}
console.log(`handled ${batch.length} messages`);

The value you pass as receipt_handle is the message_id from consume; there’s no separate handle to keep. And an ack for an id the queue doesn’t recognise comes back HTTP 200 with acked: false rather than an error, which is why the code above checks the flag instead of the status code.

Getting a message back out

Here’s the part that surprised us in testing. GET /v1/queue/dlq/list/sweep-jobs returns an empty items array even when dlq_count is 1 — the listing route doesn’t currently see what the dead-letter queue holds. The messages are genuinely there, and the way to read them is to consume the DLQ as an ordinary queue by name:

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"sweep-jobs-dlq","max_messages":10}'
{
  "ok": true,
  "data": {
    "items": [
      {
        "message_id": "qmsg_U3xizhWT5pcgdQ0OyuVdnMFZ",
        "queue": "sweep-jobs-dlq",
        "payload": { "task": "expire_sessions", "before": "2026-07-19T00:00:00Z", "limit": 5000 },
        "status": "in_flight",
        "delivery_count": 1
      }
    ],
    "next_cursor": null
  }
}

Once you’ve fixed whatever broke, put it back with a redrive — one message at a time, by id:

curl -sS -X POST "https://api.infrai.cc/v1/queue/dlq/redrive/sweep-jobs" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"message_id":"qmsg_U3xizhWT5pcgdQ0OyuVdnMFZ"}'

That returns {"redriven": 1}, moves the message back to the main queue, and resets delivery_count, so it gets a fresh set of three attempts. Calling the same route with an empty body to drain the whole DLQ in one shot does not work today — it fails with an internal backend error rather than a clean argument error. Script the loop yourself over the ids you consumed.

Fixed, tunable, or absent

KnobDefaultCan you change it?
Deliveries before dead-lettering3No — the update route accepts a new value and ignores it
Visibility timeout300 sYes, per queue
Message retention14 daysYes, per queue
Max message size256 KBNo
Backoff between retriesnone — a nack requeues immediatelyOnly by publishing a delayed copy yourself

That last row is the real limitation. There’s no exponential backoff built in: if you want attempt two to wait 30 seconds, ack the original and publish a new message with delay_seconds set, which costs one more publish and gives up the delivery counter. For a nightly cleanup where the failure is “the database was busy”, three immediate retries and a DLQ is usually enough.

Cost, and when to use something else

Every call in this article is free except the publish, which is $0.00002 per message, verified 2026-07-26 — a cleanup that enqueues 2,000 slices a night runs about $1.20 a month. Consume, ack, nack, stats and redrive are rate-limited rather than metered, so an aggressive polling loop doesn’t show up on the bill. New accounts start with $2 of credit. Prices here trend down over time, so check rather than budget from this page:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.data.capabilities[] | select(.id == "queue.publish") | .billing'

BullMQ gives you per-job backoff strategies, priorities and a UI, and if Redis is already in your stack it’s the richer tool — its retry documentation is worth reading whichever queue you pick. SQS is the right answer when the cleanup runs inside AWS and the workers are Lambdas. Celery fits a Python codebase better than any HTTP API will. The case for keeping it here is narrower and durable: one credential also covers the storage the cleanup deletes from, the email that reports what it removed, and the error tracking that catches the job that keeps dying — one account, one bill, no second SDK.

References

Browse more queue developer guides