Skip to the documentation
TypeScript

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, templates.send and broadcasts.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 nextmail.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, broadcasts.sendYesAn idempotency key makes a repeat a replay.
emails.cancel, emails.reschedule, broadcasts.cancelYesA pure set of a named state.
threads.update, threads.trashYesA label set. Applying it twice is applying it once.
threads.snooze, threads.unsnoozeYesThe wake-up instant is in the body, not derived from arrival time.
labels.update, webhooks.update, settings.update, roles.update, members.update, domains.update, contacts.update, audiences.update, keys.updateYesA pure set of named fields.
members.grantAddress, rules.reorderYesThe grant is an upsert, and the order is stated in full.
templates.publish, imports.startYesPublishing a head that is already published, or starting an import that has already started, returns it unchanged.
templates.preview, templates.render, broadcasts.preview, rules.testYesThey render, count or evaluate, and write nothing.
contacts.save, contacts.setAudiences, contacts.removePhoto, contacts.block, contacts.unblock, contacts.deleteMany, keys.revokeYesEach states the end result, so a second call leaves what the first one left.
audiences.addContact, audiences.addContacts, audiences.removeContacts, audiences.importContacts, suppressions.addYesA repeat finds the first call’s work already done and reports it rather than doing it twice.
contacts.setPhoto, imports.uploadChunkYesBytes sent again replace what the first attempt stored.
drafts.create, labels.create, webhooks.create, templates.create, rules.create, roles.create, tempMail.create, files.uploadNoA retry leaves two objects.
drafts.updateNoRead 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.deleteMessageNoA retry after a lost response reports failure for work that succeeded.
webhooks.rotateSecretNoA retry would rotate a second time and mint another secret.
webhooks.testNoIt would send a second synthetic delivery.
webhooks.replayDeliveryNoIt would send the event to your receiver a second time.
emails.translateNoIt 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 NextmailNetworkError at once, from the request or from the wait before the next attempt.