Skip to the documentation
Python

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.send_batch, 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 idempotency_key to stretch that guarantee across processes, so a job that crashed and ran again replays its sends instead of repeating them.

idempotency.py
invoice_id = 'inv_4192' client.emails.send(    {'from': sender, 'to': recipient, 'subject': subject, 'text': text},    idempotency_key=f'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.send_batch, templates.send, broadcasts.sendYesAn idempotency key makes a repeat a replay.
emails.cancel, emails.reschedule, broadcasts.cancel, forms.publish, forms.pause, forms.resume, forms.approve_submission, account.accept_invitation, account.decline_invitationYesA pure set of a named state.
threads.update, threads.trash, threads.restoreYesA 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.update, emails.update, forms.update, branding.update, chats.rename, threads.update_note, domains.update_address, domains.update_address_forward, account.set_email_notification, account.set_push_muted, app_host.set, workspaces.set_activeYesA pure set of named fields.
members.grant_address, members.grant_domain, rules.reorder, threads.reorder_notesYesThe 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.
domains.verify, app_host.verifyYesA repeated check changes nothing but the time of the check.
contacts.save, contacts.set_audiences, contacts.remove_photo, contacts.block, contacts.unblock, contacts.delete_many, keys.revoke, account.remove_photo, branding.remove_image, domains.remove_logo, domains.remove_logo_certificate, domains.remove_address_photo, app_host.delete, files.revoke_link, files.revoke_all_links, subscriptions.moveYesEach states the end result, so a second call leaves what the first one left.
audiences.add_contact, audiences.add_contacts, audiences.remove_contacts, audiences.import_contacts, suppressions.add, domains.create_address, senders.researchYesA repeat finds the first call’s work already done and reports it rather than doing it twice.
contacts.set_photo, imports.upload_chunk, account.set_photo, branding.upload_image, domains.set_logo, domains.set_logo_certificate, domains.set_address_photoYesBytes sent again replace what the first attempt stored.
drafts.create, labels.create, webhooks.create, templates.create, rules.create, roles.create, temp_mail.create, files.upload, templates.design, forms.designNoA 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.revoke_address, temp_mail.delete, temp_mail.delete_messageNoA retry after a lost response reports failure for work that succeeded.
webhooks.rotate_secretNoA second rotation invalidates the secret the first attempt returned.
webhooks.testNoIt would send a second synthetic delivery.
webhooks.replay_deliveryNoIt 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.
emails.compose, emails.rewrite, emails.suggest_subjectNoEach attempt spends another AI action and comes back with a different answer.
Every other callNoSent once, and a failure is reported rather than repeated.

forms.update is retried even with expectedUpdatedAt, so a retry after a lost response can come back 409 version_conflict because the first attempt went through. Read the form before trying again.

client.raw.request retries a GET and sends anything else once, unless you pass repeatable=True.

The backoff

  • Bounded by max_retries 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 raises straight away. Any other status raises 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 retry_after_seconds on it. Coming back sooner than it asked is not honouring it.
  • timeout bounds each attempt, so a call that uses both of its retries can take three timeouts plus the waits between them.
  • Cancelling an AsyncOpenEmail call is never retried. The cancellation propagates at once, from the request or from the wait before the next attempt.