← Back to all posts
2026-09-06 · MailAnvil Team

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:

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:

  1. Retry-After — you wait exactly as long as the API says. Not longer (you lose latency), not shorter (you get throttled again).
  2. 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 with idempotent_replay: true instead 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

  1. Never retry a 4xx. 400/401/422 are your bug, not the API's. Retrying doesn't fix them.
  2. Retry 5xx with backoff, retry 429 by Retry-After. Different signals, different pacing.
  3. 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.