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. A message published with a 45-second delay stays invisible to queue.consume until it matures, then turns up on the next poll. Ask for one second past the ceiling and the rejection names the field and the range:
{
"ok": false,
"error": {
"code": "QUEUE_DELAY_INVALID",
"http_status": 400,
"message": "delay_seconds must be 0..604800 (7 days)",
"docs_url": "https://docs.infrai.cc/errors/QUEUE_DELAY_INVALID",
"retryable": false,
"hint": "Queue delay_seconds is outside 0..604800."
}
}
retryable: false is the flag your error handler should branch on. No amount of exponential backoff turns 604801 into a legal value, so a publish that fails this way belongs in your bug tracker, not in your retry loop.
| Broker | Furthest a single message can be delayed | Who holds the pending job | Past the cap |
|---|---|---|---|
| Amazon SQS | 900 seconds (15 minutes) | the broker | pair it with a scheduler |
| Infrai queue | 604800 seconds (7 days) | the broker | HTTP 400 at publish time |
| BullMQ on Redis | no documented ceiling | your Redis instance, in memory | RAM, and a broker to run |
| Temporal | durable timers measured in months | the workflow history | nothing, 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 — buy that if you already run Redis and want the ceiling to be somebody else’s problem. A workflow engine 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","payload":{"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,
payload: { ...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", message_id: 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 tells you when the message matures — available_at carries the future timestamp, seven days out for a full hop. For the queue’s own view of how many are parked, ask it:
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 number to alert on: a hop chain that stops growing means your worker stopped re-booking. There’s also a queue-level default — delivery_delay_seconds on PATCH /v1/queue/update/{queue} — which persists and applies to every publish that doesn’t set its own. For a hop chain you want the per-message field instead, because each hop asks for a different remainder.
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 on today’s reading. Consuming, acking, nacking and stats are free within rate limits, so a 30-day schedule costs you five publishes and nothing else. Don’t copy that rate into a spreadsheet; ask the account what it actually spent.
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That command is the current figure, and it’s also where the accounting gets easy. The hop publishes, the POST /v1/cron/create sweep you’ll switch to when the ladder outgrows this pattern, and the dunning message the schedule eventually sends are all metered against one account and returned by that one call — so there’s a single bill to reconcile and per-tenant cost attribution becomes a group-by rather than a data-import project. Split the same ladder across three products and answering “what does dunning cost us per customer” turns into an afternoon of CSV joins every month.