Delayed retries beyond 7 days: chaining hops on a job queue

Message queues cap how far ahead you can delay a job. Infrai's ceiling is 604800 seconds. Here's the hop pattern that carries a retry to 30 days, and when a due-date column wins.

Every broker that offers delayed delivery puts a ceiling on it, and sooner or later a retry schedule walks into that ceiling. On Infrai’s queue the number is 604800 seconds — exactly seven days — and a publish asking for one second more is rejected outright. The way past it isn’t a bigger number. It’s a chain of hops: the message wakes up, notices it isn’t due yet, and re-publishes itself with a fresh delay.

That trick costs one metered publish per hop and no infrastructure at all.

Where the ceiling actually sits

Delayed delivery on this queue is a per-message field on publish, not a queue setting. In our testing a message published with a 45-second delay was invisible to queue.consume immediately after publish and delivered on the next poll after it matured, which is the behaviour you want. Push it to 604801 and the API returns HTTP 400.

The rejection is worth seeing, because the message you get is confusing:

{
  "ok": false,
  "error": {
    "code": "INVALID_ARGUMENT",
    "http_status": 400,
    "message": "queue.publish failed: queue 'retry-hops' already exists",
    "retryable": false
  }
}

Nothing about that text mentions the delay. The documented failure for an out-of-range value is QUEUE_DELAY_INVALID; what comes back today is a generic INVALID_ARGUMENT wearing a misleading message about the queue already existing. Treat any 400 on a publish that carries a large delay as a range error and check your arithmetic first.

BrokerFurthest a single message can be delayedWho holds the pending jobPast the cap
Amazon SQS900 seconds (15 minutes)the brokerpair it with a scheduler
Infrai queue604800 seconds (7 days)the brokerHTTP 400 at publish time
BullMQ on Redisno documented ceilingyour Redis instance, in memoryRAM, and a broker to run
Temporaldurable timers measured in monthsthe workflow historynothing, but it’s a heavier system

BullMQ genuinely wins the raw-number contest here, and if a Redis instance is already part of the deployment, a delay of 45 days is one line. Temporal is the right answer when the schedule is one step of a long-running business process with compensation logic attached. What follows suits the case where you’d rather not add either.

The hop pattern

Keep the true target time in the payload, not in the delay. Each delivery compares the target against now, and either does the work or books the next hop.

export INFRAI_API_KEY="your_infrai_api_key"

PAYLOAD=$(cat <<'JSON'
{"queue":"retry-hops","body":{"job_id":"inv_9931","target_at":"2026-08-25T09:00:00Z","hop":0},"delay_seconds":604800}
JSON
)

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "$PAYLOAD"

A 30-day schedule is four hops of seven days plus a short final one. Five publishes, no timers held in your own process, and the state that survives a deploy lives in the message.

import process from "node:process";

const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");

const MAX_DELAY = 604800;                      // the hard ceiling, in seconds
const QUEUE = "retry-hops";

/** Publish with the largest delay that doesn't overshoot the target. */
export async function schedule(job, targetAt, hop = 0) {
  const seconds = Math.max(0, Math.floor((targetAt.getTime() - Date.now()) / 1000));
  const message = {
    queue: QUEUE,
    body: { ...job, target_at: targetAt.toISOString(), hop },
    delay_seconds: Math.min(seconds, MAX_DELAY),
  };
  const res = await fetch(`${API}/v1/queue/publish`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify(message),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(`${out.error.code}: ${out.error.message}`);
  return { messageId: out.data.message_id, hop, seconds: message.delay_seconds };
}

const target = new Date(Date.now() + 30 * 86400_000);
console.log(await schedule({ job_id: "inv_9931", kind: "dunning_retry" }, target));

The worker that re-books itself

import process from "node:process";
import { schedule } from "./schedule.mjs";

const API = "https://api.infrai.cc";
const headers = {
  Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
  "Content-Type": "application/json",
};

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

async function runJob(job) {
  const res = await fetch(process.env.DUNNING_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ job_id: job.job_id }),
    signal: AbortSignal.timeout(20_000),
  });
  if (!res.ok) throw new Error(`dunning endpoint returned ${res.status}`);
}

export async function tick() {
  const { items } = await call("/v1/queue/consume", { queue: "retry-hops", max_messages: 10 });
  for (const msg of items) {
    const job = msg.payload;
    const remaining = new Date(job.target_at).getTime() - Date.now();
    try {
      if (remaining > 60_000) {
        await schedule(job, new Date(job.target_at), (job.hop ?? 0) + 1);
        console.log(`job ${job.job_id} re-booked, hop ${(job.hop ?? 0) + 1}`);
      } else {
        await runJob(job);
        console.log(`job ${job.job_id} ran after ${job.hop} hops`);
      }
      await call("/v1/queue/ack", { queue: "retry-hops", receipt_handle: msg.message_id });
    } catch (err) {
      console.error(`hop failed for ${job.job_id}: ${err.message}`);
      await call("/v1/queue/nack", { queue: "retry-hops", message_id: msg.message_id });
    }
  }
  return items.length;
}

Acking only after the next hop is safely booked is the whole reliability argument. If the re-publish throws, the nack sends the same message back for another delivery, and the third consecutive failure parks it in retry-hops.dlq where a human can see it — messages don’t evaporate between hops.

Confirm a message is really parked

The publish response reports "status": "available" even for a message that’s delayed, which is a reporting bug you should not design around. Ask the queue instead:

curl -sS "https://api.infrai.cc/v1/queue/stats/retry-hops" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "queue": "retry-hops",
    "message_count": 0,
    "available_count": 0,
    "in_flight_count": 0,
    "delayed_count": 1,
    "dlq_count": 0
  }
}

delayed_count is the field that tells the truth. Note too that the queue-level default, delivery_delay_seconds on PATCH /v1/queue/update/{queue}, is accepted and then reported back as 0 — it doesn’t take, so put the delay on each publish.

When the hop pattern is the wrong choice

Three caveats, and they’re the reason we wouldn’t push this past about a month.

A delayed message can’t be edited or cancelled. If the customer pays their invoice on day 9, the hop still fires on day 30 and your worker has to re-check state before acting — which means you have a database row anyway, so you may as well let the row be the schedule. Retention is the second limit: queues keep messages for 14 days, comfortable for a seven-day hop and uncomfortable for anything you’d want parked longer without touching. And a chain of hops has no calendar sense — “the 1st of every month” is not something a delay in seconds can express.

For those, the shape that scales is a due_at column with an index, swept every few minutes by a small query that publishes only what’s due in the next window. The queue then holds seconds of work rather than weeks, cancellation is an UPDATE, and the sweep is trivially resumable. Use the hop chain for retry ladders that genuinely have no cancel path — dunning, trial nudges, escalation timers — and a table for everything a user can change their mind about.

What the hops cost

Publishing is the only metered call in this design: $0.00002 per message, verified 2026-07-26. Consuming, acking, nacking and stats are free within rate limits. A 30-day schedule is 5 publishes, so ten thousand of them is about $1 — the $2 in trial credit a new account carries covers a lot of experimenting.

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

That command is the current figure; rates on this platform have drifted downwards and campaigns run, so the number above is an illustration rather than a promise.

References

Browse more queue developer guides