Skip to the documentation
SDK

Retries and idempotency

What is retried, what is deliberately not, and why a retried send cannot duplicate.

Sends

The client attaches an Idempotency-Key to every send (emails.send, emails.sendBatch and templates.send), generated once per **call** and reused by that call's retries. The API claims that key before it dispatches anything, so a retry replays the original message rather than sending a second one, while two deliberate send() calls still send twice. Those are different intentions and they stay different.

Pass your own idempotencyKey to stretch that guarantee across processes, so a job that crashed and ran again replays its sends instead of repeating them.

idempotency.ts
await openemail.emails.send(message, { idempotencyKey: `invoice:${invoice.id}` })

Derive it from what made the send necessary. Never from a clock. Reusing a key with a different body is refused with idempotency_key_reuse rather than silently replayed.

Everything else

Every read is retried. A write is retried only where a second identical request cannot mean anything different from the first, and a send qualifies because its idempotency key turns a repeat into a replay.

CallRetriedWhy
Every readYesNothing changes.
`emails.send`, `emails.sendBatch`, `templates.send`YesAn idempotency key makes a repeat a replay.
`emails.cancel`, `emails.reschedule`YesA pure set of a named state.
`threads.update`, `threads.trash`YesA label set. Applying it twice is applying it once.
`threads.snooze`, `threads.unsnooze`YesThe wake-up instant is in the body, not derived from arrival time.
`labels.update`, `webhooks.update`, `settings.update`, `roles.update`, `members.update`YesA pure set of named fields.
`members.grantAddress`, `rules.reorder`YesThe grant is an upsert, and the order is stated in full.
`templates.publish`YesPublishing a head that is already published returns it unchanged.
`templates.preview`, `rules.test`YesThey render or evaluate, and write nothing.
`drafts.create`, `labels.create`, `webhooks.create`, `templates.create`, `rules.create`, `roles.create`, `tempMail.create`NoA retry leaves two objects.
`drafts.update`NoRead the id off the result of every write rather than reusing the one you sent.
`drafts.delete`, `labels.delete`, `webhooks.delete`, `templates.delete`, `rules.delete`, `roles.delete`, `members.remove`, `members.revokeAddress`, `tempMail.delete`, `tempMail.deleteMessage`NoA retry after a lost response reports failure for work that succeeded.
`webhooks.rotateSecret`NoA second rotation invalidates the secret the first attempt returned.
`webhooks.test`NoIt would send a second synthetic delivery.
`emails.translate`NoIt spends model calls, so a retry after an unanswered request buys the same answer twice.
Every other writeNoSent once, and a failure is reported rather than repeated.

The backoff

  • Bounded by maxRetries on the client, defaulting to two extra attempts.
  • Only after a network failure or a 408, 500, 502, 503 or 504. A 429 is retried only when it carries a Retry-After, and this API does not send one, so a rate limit throws straight away. Any other status throws at once.
  • Exponential from half a second up to eight, with jitter, so a fleet does not resynchronise on the recovery.
  • Paced by Retry-After in either of its forms, delay-seconds and HTTP-date. When the server names a wait, the client waits exactly that long instead of backing off.
  • A server asking for longer than a minute is treated as telling the client to stop rather than to sleep, so the error is raised with retryAfterSeconds on it. Coming back sooner than it asked is not honouring it.
  • A caller's AbortSignal is never retried. Aborting throws an OpenEmailNetworkError at once, from the request or from the wait before the next attempt.