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.
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.
| Call | Retried | Why |
|---|---|---|
| Every read | Yes | Nothing changes. |
| `emails.send`, `emails.sendBatch`, `templates.send` | Yes | An idempotency key makes a repeat a replay. |
| `emails.cancel`, `emails.reschedule` | Yes | A pure set of a named state. |
| `threads.update`, `threads.trash` | Yes | A label set. Applying it twice is applying it once. |
| `threads.snooze`, `threads.unsnooze` | Yes | The wake-up instant is in the body, not derived from arrival time. |
| `labels.update`, `webhooks.update`, `settings.update`, `roles.update`, `members.update` | Yes | A pure set of named fields. |
| `members.grantAddress`, `rules.reorder` | Yes | The grant is an upsert, and the order is stated in full. |
| `templates.publish` | Yes | Publishing a head that is already published returns it unchanged. |
| `templates.preview`, `rules.test` | Yes | They render or evaluate, and write nothing. |
| `drafts.create`, `labels.create`, `webhooks.create`, `templates.create`, `rules.create`, `roles.create`, `tempMail.create` | No | A retry leaves two objects. |
| `drafts.update` | No | Read 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` | No | A retry after a lost response reports failure for work that succeeded. |
| `webhooks.rotateSecret` | No | A second rotation invalidates the secret the first attempt returned. |
| `webhooks.test` | No | It would send a second synthetic delivery. |
| `emails.translate` | No | It spends model calls, so a retry after an unanswered request buys the same answer twice. |
| Every other write | No | Sent once, and a failure is reported rather than repeated. |
The backoff
- Bounded by
maxRetrieson the client, defaulting to two extra attempts. - Only after a network failure or a
408,500,502,503or504. A429is retried only when it carries aRetry-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-Afterin 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
retryAfterSecondson it. Coming back sooner than it asked is not honouring it. - A caller's
AbortSignalis never retried. Aborting throws anOpenEmailNetworkErrorat once, from the request or from the wait before the next attempt.