A simple queue retry policy in Node: backoff without a delay parameter

Infrai's queue retries by lease expiry, not by a per-message delay. How to build a 30s/5m/30m ladder with jitter out of tier queues, and when to keep the default instead.

The shortest correct retry policy on Infrai’s queue is: don’t ack. A consumed message is leased for the queue’s visibility timeout, and when the lease expires without an ack the message becomes available again with delivery_count incremented, up to max_receive_count — 3 on a created queue — after which it drops into the dead-letter queue on its own. That’s three attempts and a dead-letter lane with no retry code at all.

What that gives you is fixed spacing, not exponential backoff, because the lease length is a queue attribute rather than a per-message field. Getting a real ladder takes about forty lines, and this page has them.

The one knob you get for free

Visibility timeout defaults to 300 seconds and is editable in place:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X PATCH "https://api.infrai.cc/v1/queue/update/orders-retry-30s" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"visibility_timeout_default":30}'
{
  "ok": true,
  "data": {
    "name": "orders-retry-30s",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 30,
    "delivery_delay_seconds": 0,
    "max_receive_count": 3,
    "dlq_name": "orders-retry-30s-dlq"
  }
}

Set it to 30 and a failing job is retried every 30 seconds, three times, then dead-lettered — total exposure roughly 90 seconds. Set it to 900 and the same job spreads over 45 minutes but every transient blip also waits a quarter of an hour. One number can’t be both, which is the whole reason exponential backoff exists.

Worth flagging before you design around it: POST /v1/queue/nack returns immediately with {"nacked": true, "requeue": true}, and requeue means now. It’s a “put this back for another worker” signal during a rolling deploy, not a backoff primitive. Nacking a job that fails deterministically gives you a hot loop that burns all three deliveries in a second.

A ladder out of tier queues

Since the delay lives on the queue, give each retry stage its own queue and let the message move down the ladder as it fails.

StageQueueVisibility timeoutCumulative wait
First failureorders-retry-30s30 s~30 s
Second failureorders-retry-5m300 s~5.5 min
Third failureorders-retry-30m1800 s~36 min
Fourth failureorders-work-dlqn/amanual

Create the tiers once:

for tier in 30s 5m 30m; do
  curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
    -H "Authorization: Bearer ${INFRAI_API_KEY}" \
    -H "Content-Type: application/json" \
    -d "{\"name\":\"orders-retry-${tier}\",\"type\":\"standard\",\"dlq\":\"orders-retry-${tier}-dlq\"}"
done

The worker classifies the failure, republishes into the next tier with an incremented attempt counter, and acks the original so it stops consuming deliveries on the main queue. Everything the retry needs travels inside the payload, because that’s the field you control:

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
const MAIN = "orders-work";
const LADDER = ["orders-retry-30s", "orders-retry-5m", "orders-retry-30m"];
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY must be set");
const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(`${path}: ${json?.error?.code ?? res.status} ${json?.error?.message ?? ""}`);
  return json.data;
}

// Full jitter: sleep anywhere in [0, base * 2^attempt], capped. Deterministic
// ladders synchronise every failed job onto the same second.
function jitteredDelay(attempt, baseSeconds = 30, capSeconds = 1800) {
  const window = Math.min(capSeconds, baseSeconds * 2 ** attempt);
  return Math.round(Math.random() * window);
}

async function scheduleRetry(message) {
  const attempt = (message.payload.attempt ?? 0) + 1;
  if (attempt > LADDER.length) {
    console.error(`giving up on ${message.message_id} after ${attempt - 1} retries`);
    return false;
  }
  const target = LADDER[attempt - 1];
  const notBefore = new Date(Date.now() + jitteredDelay(attempt - 1) * 1000).toISOString();
  await call("/v1/queue/publish", {
    queue: target,
    body: { ...message.payload, attempt, not_before: notBefore, origin: MAIN },
  });
  console.log(`retry ${attempt} for order ${message.payload.order_id} queued on ${target} after ${notBefore}`);
  return true;
}

async function handle(message) {
  const { order_id } = message.payload;
  const res = await fetch("https://api.example.com/orders/" + order_id + "/settle", {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": `settle:${order_id}` },
    body: JSON.stringify({ order_id }),
    signal: AbortSignal.timeout(8000),
  });
  if (res.status >= 400 && res.status < 500 && res.status !== 429) {
    throw Object.assign(new Error(`permanent ${res.status}`), { permanent: true });
  }
  if (!res.ok) throw new Error(`transient ${res.status}`);
}

export async function runWorker() {
  for (;;) {
    const { items } = await call("/v1/queue/consume", { queue: MAIN, max_messages: 10 });
    if (!items.length) { await sleep(2000); continue; }
    for (const message of items) {
      try {
        await handle(message);
      } catch (err) {
        if (err.permanent) console.error(`dropping ${message.message_id}: ${err.message}`);
        else await scheduleRetry(message);
      }
      await call("/v1/queue/ack", { queue: MAIN, receipt_handle: message.message_id });
    }
  }
}

await runWorker();

Notice what the ack is doing there. Acking after a scheduled retry is deliberate — the job now lives on the tier queue, and leaving the original leased as well would give you two copies racing each other. If you’d rather keep the free three-deliveries behaviour instead, skip scheduleRetry entirely and simply don’t ack.

The drainer that feeds the tiers back into the main queue is small enough to run from a cron job every minute:

import process from "node:process";

const BASE = "https://api.infrai.cc";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY must be set");
const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(`${path}: ${json?.error?.message ?? res.status}`);
  return json.data;
}

export async function drainTier(tier) {
  const { items } = await call("/v1/queue/consume", { queue: tier, max_messages: 10 });
  let moved = 0;
  for (const message of items) {
    const ready = !message.payload.not_before || Date.parse(message.payload.not_before) <= Date.now();
    if (!ready) continue; // no ack: it hides again for this tier's visibility timeout
    await call("/v1/queue/publish", { queue: message.payload.origin ?? "orders-work", body: message.payload });
    await call("/v1/queue/ack", { queue: tier, receipt_handle: message.message_id });
    moved++;
  }
  console.log(`${tier}: ${moved}/${items.length} moved back`);
  return moved;
}

for (const tier of ["orders-retry-30s", "orders-retry-5m", "orders-retry-30m"]) {
  await drainTier(tier);
}

One caveat with the “leave it unacked” branch: each hide costs a delivery, and the tier queue’s own max_receive_count of 3 will dead-letter a message that waits too many rounds. Size each tier’s visibility timeout close to its nominal delay and that never bites.

Watching the ladder

GET /v1/queue/stats/{queue} is the cheapest observability you’ll get — available_count, in_flight_count, delayed_count and dlq_count in one call:

curl -sS "https://api.infrai.cc/v1/queue/stats/orders-retry-5m" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

When something does exhaust the ladder, read the dead-letter queue by consuming it under its own name, then push a fixed message back with a redrive:

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"orders-work-dlq","max_messages":10}'

An undocumented delay_seconds on publish does hold a message — we watched one sit in delayed_count for 30 seconds before becoming available on 2026-07-26 — but it isn’t in the documented request body, so don’t build a retry policy on it yet. Bad values surface as QUEUE_DELAY_INVALID.

What retries cost

Each republish is a publish: $0.00002 per message, verified 2026-07-26. Consume, ack, stats, update and dead-letter reads are free and rate-limited. A ladder that retries 5,000 failed jobs three times costs 15,000 extra publishes, or $0.30 — which is why the “just don’t ack” policy, where redeliveries are free, is worth keeping for anything that doesn’t need increasing spacing.

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

Rates on this surface have moved down rather than up, and new accounts start with $2 of free credit, so read the live figure before you plan a budget.

When to use something else

BullMQ ships backoff as configuration — attempts: 5, backoff: { type: "exponential", delay: 1000 } — and if you already run Redis, that’s strictly less machinery than tier queues. SQS gives you DelaySeconds per message up to 15 minutes plus a maxReceiveCount redrive policy, which covers most ladders natively; it’s the better pick if your workers already live in AWS. Temporal is the answer when a “retry” means resuming a multi-step workflow rather than redelivering a message.

Infrai’s queue earns its place when you want HTTP-only workers, a dead-letter lane, and the storage, email and error-tracking that the failing job needs next on the same credential. The limitations are honest ones: no per-message delay in the documented body, max_receive_count fixed at 3, max_messages capped at 10 per consume, and no built-in backoff curve — the ladder above is yours to run.

References

Browse more queue developer guides