Rate limits, backoff and safe retries for notification sends

A Node 22 retry layer for SMS and email notifications: classify the response, back off with full jitter, cap the budget, and make every retry safe to repeat.

Three decisions, in this order: classify the response, wait the right amount of time, and make sure repeating the call can’t double-charge a user. Getting the first one wrong is the common failure — a worker that retries every 5xx, including the permanent errors that happen to arrive as one, burns its whole budget on requests that were never going to succeed. Infrai’s error envelope gives you enough to classify properly, but you have to read more of it than the status line.

So the retry layer starts with a classifier, not a sleep loop. Every route returns the same envelope — code, http_status, retryable, message, request_id — and the signal you need is spread across all of those fields.

Classify first

ResponseWhat it meansDo this
HTTP 429, SMS_RATE_LIMITYou’re over the per-account limit for a routeBack off, then retry the same request
HTTP 503, VENDOR_DOWN, message about carrier or timeoutUpstream carrier wobbledRetry with backoff, up to your budget
HTTP 503, VENDOR_DOWN, message quoting a bad valueInput rejected through the vendor channelFail permanently, alert on it
HTTP 503, VENDOR_NOT_CONFIGUREDNo usable vendor key for that capabilityStop; retrying can’t fix configuration
HTTP 402, PRO_REQUIREDPlan boundaryStop
HTTP 404 on a readUnknown idStop

That third row is the one that surprises people. The retryable flag describes the transport channel the error came through, so a payload the gateway will reject every single time can still arrive marked true. Pin your classifier to the message text for those, or you’ll spend 12 attempts on a phone number with brackets in it.

{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "message": "sms.send failed: to must be str or list[str]",
    "retryable": true,
    "docs_url": "https://docs.infrai.cc/errors",
    "trace_id": "trc_20b9ead825cd40ec97b1037a",
    "request_id": "req_5349a40eca7f4eff9dab9d93"
  }
}

You don’t get a quota header, so measure your own rate

Worth flagging up front: responses carry x-request-id, x-trace-id and x-total-latency-ms, but no X-RateLimit-Remaining. There’s no header to read your remaining budget from, which means the limiter has to live on your side and 429 handling is reactive rather than predictive. If your architecture depends on server-advertised quota — say you’re building a multi-tenant scheduler that shapes traffic per customer — that’s a real gap, and a specialist like Sinch or Twilio exposing explicit rate headers may suit you better.

A token bucket in front of the sender costs twenty lines and removes most of the problem:

// limiter.mjs — Node 22, ESM. Simple token bucket, no dependencies.
export function createLimiter({ ratePerSecond = 5, burst = 10 } = {}) {
  let tokens = burst;
  let last = Date.now();

  return async function acquire() {
    for (;;) {
      const now = Date.now();
      tokens = Math.min(burst, tokens + ((now - last) / 1000) * ratePerSecond);
      last = now;
      if (tokens >= 1) {
        tokens -= 1;
        return;
      }
      const waitMs = Math.ceil(((1 - tokens) / ratePerSecond) * 1000);
      await new Promise((r) => setTimeout(r, waitMs));
    }
  };
}

Five requests per second with a burst of 10 is a sane starting point for a notification worker; raise it when you observe no 429s under load rather than before.

Full jitter, capped budget

Exponential backoff without randomisation just re-synchronises every worker onto the same retry instant. Full jitter — pick uniformly between zero and the current ceiling — spreads them out, and it’s the variant that survives a thundering herd. Resend’s engineering write-up on idempotency keys makes the companion point: backoff is only safe when the retried call is repeatable.

// send-with-retry.mjs — Node 22, ESM
import { createLimiter } from "./limiter.mjs";

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

const acquire = createLimiter({ ratePerSecond: 5, burst: 10 });
const PERMANENT_TEXT = /E\.164|must be str|not configured|Pro-only|segment limit/i;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function verdict(status, error) {
  if (status === 429) return "retry";
  if (status === 402 || status === 404 || status === 400) return "stop";
  if (status >= 500) {
    return PERMANENT_TEXT.test(error?.message ?? "") ? "stop" : "retry";
  }
  return "stop";
}

export async function post(path, body, { attempts = 5, baseMs = 400, ceilMs = 20000 } = {}) {
  let lastError = null;
  for (let attempt = 0; attempt < attempts; attempt++) {
    await acquire();
    const res = await fetch(`${API}${path}`, {
      method: "POST",
      headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
      body: JSON.stringify(body),
    });
    const payload = await res.json().catch(() => ({}));
    if (res.ok) return payload.data;

    lastError = payload.error ?? { message: `HTTP ${res.status}` };
    if (verdict(res.status, lastError) === "stop") break;

    const ceiling = Math.min(ceilMs, baseMs * 2 ** attempt);
    await sleep(Math.floor(Math.random() * ceiling));
  }
  const err = new Error(lastError?.message ?? "request failed");
  err.code = lastError?.code;
  err.requestId = lastError?.request_id;
  throw err;
}

Five attempts with a 400 ms base and a 20 s ceiling gives roughly half a minute of patience — enough to ride out a carrier hiccup, short enough that a queued job doesn’t sit for an hour.

The retry has to be safe to repeat

A retry after a socket timeout is the dangerous case: the request may well have succeeded before the connection dropped. Batch sends take an idempotency_key, and a deterministic one built from the job identity turns “did that land?” into a non-question.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/batch/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"to": "+14155550142", "body": "Order A-2291 is out for delivery.", "from": "AcmeShip"},
      {"to": "+447700900123", "body": "Order A-2292 is out for delivery.", "from": "AcmeShip"}
    ],
    "idempotency_key": "dispatch-2026-07-26-wave-3"
  }'

Single sends through POST /v1/sms/send have no such key, so the trade-off there is yours to manage: either accept a small duplicate risk on timeout, or record the send in your own store before the call and reconcile after. For anything above a handful of recipients, batching is the safer shape anyway.

Check the outcome instead of guessing

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

Status reads are free and rate-limited rather than metered, which is what makes “poll after a retry storm” a viable strategy instead of an expensive one. Same for suppression and template reads.

What retries cost

Only sends are billed — $0.007475 per message, verified 2026-07-26 and marked approximate, with $2 of free credit on a new account covering about 267 messages. A failed request that never reached a carrier isn’t charged; a genuine duplicate send is. Watch the ratio directly:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '{total_calls, total_failed_calls, total_cost, sms: [.breakdown[] | select(.key | startswith("sms."))]}'

Rates here drift downward over time and discount campaigns run, so treat the figure above as a reading rather than a constant.

When a queue beats a retry loop

In-process retries are the wrong tool once the failure lasts longer than a request timeout. If a carrier is down for 20 minutes, you want the job durable somewhere, not held in a worker’s memory — and that’s the second question the same key answers, since the queue and cron surfaces sit on the same account and the same invoice as the send. Plivo and Twilio both offer their own scheduling and messaging-service abstractions, and if SMS is the whole product their tooling is deeper than this; the argument for consolidating is that your alert path, its retry queue, its error capture and its cost attribution stop being four separate vendors.

References

Browse more sms developer guides