Retries done right: idempotency keys and rate-limit headers

Which errors to retry, how to retry without sending twice, and how to stay inside your plan's requests per second.

Reliability4 min read

Networks fail. A request can time out after the email was accepted, and if you simply try again, your customer gets two receipts. The Developer API is built so retrying is safe, as long as you send an idempotency key and only retry the errors that are worth retrying.

Send an Idempotency-Key with every POST#

Add an Idempotency-Key header (1 to 256 characters) to any POST. For 24 hours, repeating the same request with the same key returns the first response instead of doing the work again, with an idempotent-replayed: true header so you can tell.

  • Build the key from something unique in your system, such as order-1042-receipt, not a random value per attempt. A new random key on each retry defeats the point.
  • The key is matched together with the method, path and body. The same key with a different body returns 409 idempotency_conflict; that's a bug in your code, not something to retry.
  • A request whose first try is still running also returns 409 idempotency_conflict. Wait a moment and retry with the same key.
  • Error responses (4xx) are replayed too. Server errors (5xx) and rate limits (429) aren't stored, so you can retry them with the same key.

What to retry, and what not to#

ResponseRetry?Why
429 rate_limit_exceededYes, after retry-afterToo many requests this second.
500 internal_server_errorYes, with backoffSomething went wrong on our side.
502 · 503 application_errorYes, with backoffA provider is temporarily unavailable.
429 daily_quota_exceeded · monthly_quota_exceededNoThe quota resets at midnight UTC or on the 1st; retrying sooner won't help.
403 suspendedNoSending is paused for account health. Contact support.
401 · 403 · 422NoThe request itself needs fixing.
Note
Once an email is accepted, delivery retries are our job. If our email provider has a temporary problem, we retry after 30 seconds, 2, 10 and 30 minutes and 2 hours before marking the email failed.

A retry helper#

Send with retries
const RETRYABLE = new Set(["rate_limit_exceeded", "internal_server_error", "application_error"])

export async function sendEmail(email, idempotencyKey, attempts = 5) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch("https://api.iresendly.com/emails", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.IRESENDLY_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey, // the same key on every attempt
      },
      body: JSON.stringify(email),
    })
    if (res.ok) return res.json() // { id }

    const error = await res.json()
    const retryable = res.status >= 500 || RETRYABLE.has(error.name)
    if (!retryable || attempt === attempts) throw new Error(`${error.name}: ${error.message}`)

    // Use retry-after when we send it; otherwise back off: 1s, 2s, 4s, 8s…
    const wait = Number(res.headers.get("retry-after")) || 2 ** (attempt - 1)
    await new Promise((r) => setTimeout(r, wait * 1000))
  }
}

await sendEmail(
  { from: "Acme <billing@acme.com>", to: ["jane@example.com"], subject: "Receipt for order 1042", html: "<p>Thanks!</p>" },
  "order-1042-receipt"
)

Stay inside your rate limit#

Each account can make 2 (Free), 10 (Pro) or 25 (Scale) requests a second, across all its keys. Every authenticated response tells you where you stand:

HeaderMeaning
ratelimit-limitRequests allowed per second.
ratelimit-remainingRequests left in the current second.
ratelimit-resetSeconds until the limit resets.
retry-afterSent with a 429: how many seconds to wait.
  • When you have many emails to send at once, use POST /emails/batch: up to 100 emails in one request, counted as one request.
  • With x-batch-validation: permissive, valid emails in a batch are sent and invalid ones come back in errors with their index, so one bad address doesn't hold up the rest.
  • Put sends on a queue and let a worker pace itself from ratelimit-remaining, rather than firing everything from a web request.
  • All your keys share the account's limit. A busy script with one key can slow down your app's other keys.
Tip
Scheduling helps too: set scheduled_at up to 30 days ahead and the email goes out at its time without your server having to be busy then.