---
title: "Retries and idempotency"
description: "What is retried, what is deliberately not, and why a retried send cannot duplicate."
url: "https://openemail.uk/docs/python/retries"
area: "Python"
category: "Getting started"
---

# 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.

| Call | Retried | Why |
| --- | --- | --- |
| Every read | Yes | Nothing changes. |
| `emails.send`, `emails.send_batch`, `templates.send`, `broadcasts.send` | Yes | An 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_invitation` | Yes | A pure set of a named state. |
| `threads.update`, `threads.trash`, `threads.restore` | 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`, `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_active` | Yes | A pure set of named fields. |
| `members.grant_address`, `members.grant_domain`, `rules.reorder`, `threads.reorder_notes` | Yes | The grant is an upsert, and the order is stated in full. |
| `templates.publish`, `imports.start` | Yes | Publishing a head that is already published, or starting an import that has already started, returns it unchanged. |
| `templates.preview`, `templates.render`, `broadcasts.preview`, `rules.test` | Yes | They render, count or evaluate, and write nothing. |
| `domains.verify`, `app_host.verify` | Yes | A 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.move` | Yes | Each 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.research` | Yes | A 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_photo` | Yes | Bytes 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.design` | 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.revoke_address`, `temp_mail.delete`, `temp_mail.delete_message` | No | A retry after a lost response reports failure for work that succeeded. |
| `webhooks.rotate_secret` | No | A second rotation invalidates the secret the first attempt returned. |
| `webhooks.test` | No | It would send a second synthetic delivery. |
| `webhooks.replay_delivery` | No | It would send the event to your receiver a second time. |
| `emails.translate` | No | It spends model calls, so a retry after an unanswered request buys the same answer twice. |
| `emails.compose`, `emails.rewrite`, `emails.suggest_subject` | No | Each attempt spends another AI action and comes back with a different answer. |
| Every other call | No | Sent 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.
