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.
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#
| Response | Retry? | Why |
|---|---|---|
429 rate_limit_exceeded | Yes, after retry-after | Too many requests this second. |
500 internal_server_error | Yes, with backoff | Something went wrong on our side. |
502 · 503 application_error | Yes, with backoff | A provider is temporarily unavailable. |
429 daily_quota_exceeded · monthly_quota_exceeded | No | The quota resets at midnight UTC or on the 1st; retrying sooner won't help. |
403 suspended | No | Sending is paused for account health. Contact support. |
401 · 403 · 422 | No | The request itself needs fixing. |
failed.A retry helper#
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:
| Header | Meaning |
|---|---|
ratelimit-limit | Requests allowed per second. |
ratelimit-remaining | Requests left in the current second. |
ratelimit-reset | Seconds until the limit resets. |
retry-after | Sent 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 inerrorswith theirindex, 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.
scheduled_at up to 30 days ahead and the email goes out at its time without your server having to be busy then.