Handling 429 Rate Limits and Retry-After in Email APIs — Retries Without Duplicate Emails
Every email API throttles. The good ones tell you how — with a 429 status and a Retry-After header. The bad ones just drop your request. Either way, the difference between a reliable sender and a flaky one is two things: respecting the throttle signal, and making retries idempotent so a duplicate never lands in a customer's inbox.
What a rate limit actually looks like
A typical rate-limited response from a transactional email API:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0
{ "error": { "code": "rate_limited", "message": "Rate limit exceeded. Retry after 1s" } }
Three signals matter here:
Retry-After— how many seconds to wait before trying again. This is the contract. Read it.X-RateLimit-Remaining— how much headroom you have before the next request. Useful for deciding whether to pre-emptively slow down.rate_limitederror code — the machine-readable version of the same fact, in case you parse the body rather than the headers.
MailAnvil rate-limits per API key with a sliding one-second window (10 requests/second per key by default). Each key gets its own counter, so one misbehaving client never throttles another — isolation matters when you share infra across tenants.
The wrong way: blind retry
// Anti-pattern: retry immediately, duplicate on every attempt
for (let i = 0; i < 3; i++) {
const res = await fetch(API + '/v1/send', { ... });
if (res.status !== 429) return res.json();
}
This sends the same email up to three times. For an OTP that means the user gets three codes; for an invoice, three copies. Even a "successful" retry is a bug.
The right way: honor Retry-After, then replay safely
async function sendWithRetry(payload, maxAttempts = 3) {
// Generate once, reuse across every attempt — this is the idempotency key.
const idemKey = crypto.randomUUID();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(API + '/v1/send', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': idemKey, // <- makes the retry safe
},
body: JSON.stringify(payload),
});
if (res.ok) {
const body = await res.json();
// If the first attempt actually landed but the response was lost,
// the API returns the SAME email with idempotent_replay: true.
console.log(body.idempotent_replay ? 'replayed (no dup)' : 'sent');
return body;
}
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After') || 1);
await sleep(retryAfter * 1000); // respect the throttle
continue;
}
// 4xx = permanent, don't retry. 5xx = retryable, back off.
if (res.status >= 500) {
await sleep(backoff(attempt));
continue;
}
return { error: await res.json() };
}
throw new Error('send failed after ' + maxAttempts + ' attempts');
}
function backoff(attempt) {
return Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 500; // exp + jitter
}
Two parts do the work:
Retry-After— you wait exactly as long as the API says. Not longer (you lose latency), not shorter (you get throttled again).Idempotency-Key— the same key on every attempt. The API records the first successful send against that key. If a retry arrives after the first attempt already succeeded (e.g. the response got lost in the network), the API returns the original email withidempotent_replay: trueinstead of sending a duplicate.
Why jitter matters
When many clients get throttled at once, they all see Retry-After: 1 and all retry at second 2. The thundering herd re-throttles itself. Add jitter to any exponential backoff so retries spread out:
wait = min(base * 2^attempt, cap) + random(0..500ms)
For email this is less critical than for, say, a shared queue, but it's one line and it costs nothing.
Python equivalent
import time, uuid, random, httpx
def send_with_retry(payload, max_attempts=3):
idem_key = str(uuid.uuid4())
for attempt in range(max_attempts):
r = httpx.post(
"https://api.mailanvil.com/v1/send",
json=payload,
headers={
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": idem_key,
},
)
if r.status_code == 200:
return r.json()
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 1)))
continue
if r.status_code >= 500:
time.sleep(min(2 ** attempt, 8) + random.random() * 0.5)
continue
return r.json()
raise RuntimeError("send failed after retries")
Three rules to ship with
- Never retry a 4xx.
400/401/422are your bug, not the API's. Retrying doesn't fix them. - Retry 5xx with backoff, retry 429 by
Retry-After. Different signals, different pacing. - Always send
Idempotency-Key. It's the only thing standing between "reliable sender" and "customer got three OTPs."
Handle the throttle signal instead of fighting it, and make every retry safe to replay. That's the whole recipe — everything else is just timeouts and logging.