Reminder fan-out throttled: 429 backoff, dead-letter and redrive in Node

Your SMS or email provider starts returning 429 halfway through a reminder batch. How to pace the worker, park what won't send, and get those messages back.

A 429 from your SMS or email provider means “not yet”, not “never”. So the reminder that got throttled should go back on the queue with its attempt counter incremented, not into a catch block that swallows it. On Infrai that costs you nothing extra: don’t ack the message, let the lease expire, and the queue redelivers it. Consuming, acking and dead-lettering are free routes; only the original publish is metered.

The part people get wrong is where the waiting happens.

The retry belongs outside the send call

A for loop with await sleep(backoff) inside the request handler works right up until the process restarts, at which point every pending reminder in memory is gone and nobody knows which ones were sent. Infrai’s queue moves that state out of the worker: a consumed message is invisible for the visibility timeout, and if you never ack it, it comes back with delivery_count bumped by one.

Where the retry livesSurvives a worker crashWhat you writeWhat it costs
In-process sleep and retryno — pending sends are lostbackoff maths, a jitter helpernothing, until it fails
Queue redelivery (no ack)yes — lease expires, message returnsone if in the workerfree; the publish was already paid for
Dead-letter after N deliveriesyes — parked in a separate queuenothing, it’s a create-time settingfree

Three deliveries is the default ceiling. After that the message lands in the dead-letter queue instead of cycling forever, which is what you want when the “429” is really a suspended account rather than a rate limit.

Create the queue and its failure lane

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":"reminders-outbound","type":"standard","dlq":"reminders-outbound.dlq"}'

The response is the contract you’ll be reasoning about all night:

{
  "ok": true,
  "data": {
    "name": "reminders-outbound",
    "type": "standard",
    "visibility_timeout_default": 300,
    "max_receive_count": 3,
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "dlq_name": "reminders-outbound.dlq"
  }
}

Two numbers matter here. visibility_timeout_default of 300 seconds is your redelivery delay, and max_receive_count of 3 is how many 429s a single reminder gets before it’s parked.

Publish one message per recipient

Keep the payload to identifiers. The worker can look up the phone number when it’s ready to send, and a payload that doesn’t carry personal data is a payload you don’t have to think about at deletion time.

import process from "node:process";

const BASE = "https://api.infrai.cc";
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 call(path, payload) {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers,
    body: JSON.stringify(payload),
  });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

export async function scheduleReminders(recipients) {
  for (const r of recipients) {
    const msg = await call("/v1/queue/publish", {
      queue: "reminders-outbound",
      body: { user_id: r.userId, channel: r.channel, template: "appointment_24h" },
    });
    console.log(`queued ${msg.message_id} for ${r.userId}`);
  }
}

POST /v1/queue/publish_batch takes an array if you’re loading tens of thousands at once — same billing, fewer round trips.

The worker that reads Retry-After

Here’s the whole trick, in one function. When the provider says 429, parse Retry-After, stop sending for that long, and leave the message unacked so the queue owns it again.

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

const BASE = "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(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

function retryAfterMs(res) {
  const raw = res.headers.get("retry-after");
  if (!raw) return 30_000;
  const seconds = Number(raw);
  return Number.isFinite(seconds) ? seconds * 1000 : Math.max(0, Date.parse(raw) - Date.now());
}

export async function runWorker(sendReminder) {
  for (;;) {
    const { items } = await call("/v1/queue/consume", { queue: "reminders-outbound", max_messages: 10 });
    if (items.length === 0) { await sleep(5000); continue; }

    for (const msg of items) {
      const res = await sendReminder(msg.payload);
      if (res.status === 429) {
        const waitMs = retryAfterMs(res);
        console.warn(`throttled on attempt ${msg.delivery_count}; pausing ${waitMs}ms, message stays queued`);
        await sleep(waitMs);
        break;
      }
      if (!res.ok) { console.error(`send failed ${res.status}; leaving ${msg.message_id} for redelivery`); continue; }
      await call("/v1/queue/ack", { queue: "reminders-outbound", receipt_handle: msg.message_id });
    }
  }
}

break rather than continue is deliberate: once the provider has throttled you, the other nine messages in that batch are going to be throttled too, so hand them all back at once. They’ll reappear in 300 seconds. If your provider’s Retry-After is routinely shorter than that, POST /v1/queue/nack returns a message immediately — the trade-off is that an immediate nack still consumes one of the three deliveries, so a tight nack loop dead-letters a reminder in about a second.

What’s actually in the dead-letter queue

Check the parent queue’s counters first:

curl -sS "https://api.infrai.cc/v1/queue/stats/reminders-outbound" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "queue": "reminders-outbound",
    "message_count": 0,
    "available_count": 0,
    "in_flight_count": 0,
    "dlq_count": 4,
    "oldest_message_age_seconds": 0
  }
}

Worth flagging, because it cost us time: on 2026-07-26 GET /v1/queue/dlq/list/{queue} returned an empty items array on a queue whose dlq_count was 4, and POST /v1/queue/dlq/redrive/{queue} answered INVALID_ARGUMENT on every queue we tried. The dead-letter queue is an ordinary queue with its own name, though, so the reliable path is to consume from it directly.

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

Redriving by hand

Drain the dead-letter queue and republish onto the live one. It’s a dozen lines, it’s idempotent enough for a morning cleanup, and it works today.

import process from "node:process";

const BASE = "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(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

const parked = await call("/v1/queue/consume", { queue: "reminders-outbound.dlq", max_messages: 10 });
for (const msg of parked.items) {
  await call("/v1/queue/publish", { queue: "reminders-outbound", body: msg.payload });
  await call("/v1/queue/ack", { queue: "reminders-outbound.dlq", receipt_handle: msg.message_id });
  console.log(`redrove ${msg.message_id}`);
}

What a throttled night costs

Publishing is the only billed route in this workflow: $0.00002 per message, verified 2026-07-26. Consume, ack, nack and the dead-letter routes are free and rate-limited rather than metered, which is the whole reason redelivery is a sane retry strategy — 40,000 reminders is $0.80 whether each one sends first time or bounces off a 429 three times. New accounts start with $2 of credit.

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

Run that rather than trusting this page — rates here drift downwards and discount campaigns run, so today’s number may well be lower. The durable point isn’t the rate. It’s that the SMS send, the email fallback, the error you capture when a reminder dead-letters and the per-tenant cost attribution your finance lead asks for in October all sit behind this same key.

When something else is the better pick

If you’re already running Sidekiq against Redis with a Ruby fleet, the rate-limiting middleware there is more mature than anything you’ll assemble from consume-and-ack. If the reminder is nothing but an HTTP POST on a delay and you want no worker at all, qstash is purpose-built for exactly that. And SQS earns its place when IAM is doing your access control anyway.

The limitations to weigh before you commit: 10 messages per consume call, no server-side rate limiter (the pacing above is yours to write), and the redrive route’s current state.

References

Browse more queue developer guides