Повторы и идемпотентность
Что повторяется, что намеренно не повторяется и почему повторённая отправка не может продублировать письмо.
Отправки
Клиент добавляет Idempotency-Key к каждой отправке (emails.send, emails.send_batch, templates.send и broadcasts.send). Ключ генерируется один раз на **вызов** как oe- и случайный UUID и переиспользуется повторами этого вызова. API занимает этот ключ до того, как что-либо отправить, поэтому повтор воспроизводит исходное сообщение, а не отправляет второе, тогда как два намеренных вызова send всё равно отправят письмо дважды. Это разные намерения, и они остаются разными.
Передайте собственный idempotency_key:, чтобы растянуть эту гарантию на несколько процессов: задание, которое упало и запустилось снова, воспроизведёт свои отправки, а не повторит их. Воспроизведение отвечает с replayed, равным true, и сохранённым сообщением в его текущем состоянии.
invoice_id = "inv_2026_09_4192" sent = client.emails.send( from: "[email protected]", to: "[email protected]", subject: "Your September invoice", text: "Invoice attached.", idempotency_key: "invoice:#{invoice_id}") puts sent[:id], sent[:replayed]Выводите его из того, что сделало отправку необходимой. Никогда из часов. Повторное использование ключа с другим телом отклоняется с 422 idempotency_key_reuse, а не воспроизводится молча. Ключ содержит от 1 до 255 символов из букв, цифр, _, ., : или -, а всё остальное даёт 400 invalid_idempotency_key.
Всё остальное
Каждый GET повторяется. Запись повторяется только там, где второй такой же запрос не может значить ничего иного, чем первый, и отправка подходит под это условие, потому что её ключ идемпотентности превращает повтор в воспроизведение.
| Вызов | Повторяется | Почему |
|---|---|---|
| Каждый GET | Да | Ничего не меняется. |
| emails.send, emails.send_batch, templates.send, broadcasts.send | Да | Ключ идемпотентности превращает повтор в воспроизведение. |
| emails.cancel, emails.reschedule, broadcasts.cancel, forms.pause, forms.resume, threads.restore | Да | Чистая установка названного состояния. |
| threads.update, threads.trash | Да | Установка набора ярлыков. Применить её дважды равносильно тому, чтобы применить её один раз. |
| threads.snooze, threads.unsnooze | Да | Момент пробуждения задан в теле, а не выводится из времени поступления запроса. |
| emails.update, labels.update, webhooks.update, settings.update, roles.update, members.update, domains.update, domains.update_address, domains.update_address_forward, contacts.update, audiences.update, keys.update, forms.update, branding.update, threads.update_note, chats.rename, account.set_email_notification, account.set_push_muted, app_host.set, workspaces.set_active | Да | Чистая установка названных полей. |
| members.grant_address, members.grant_domain, rules.reorder, threads.reorder_notes | Да | Выдача доступа работает как upsert, а порядок всегда задаётся целиком. |
| templates.publish, forms.publish, imports.start | Да | Публикация уже опубликованного или запуск уже начатого импорта возвращает его без изменений. |
| templates.preview, templates.render, broadcasts.preview, rules.test | Да | Они выполняют отрисовку, подсчёт или вычисление и ничего не записывают. |
| domains.verify, app_host.verify, senders.research | Да | Повторная проверка не меняет ничего, кроме времени, когда она была выполнена. |
| contacts.save, contacts.set_audiences, contacts.remove_photo, contacts.block, contacts.unblock, contacts.delete_many, keys.revoke, files.revoke_link, files.revoke_all_links, app_host.delete, account.remove_photo, branding.remove_image, domains.remove_logo, domains.remove_logo_certificate, domains.remove_address_photo, account.accept_invitation, account.decline_invitation, forms.approve_submission, subscriptions.move | Да | Каждый задаёт конечный результат, поэтому второй вызов оставляет то же, что оставил первый. |
| audiences.add_contact, audiences.add_contacts, audiences.remove_contacts, audiences.import_contacts, suppressions.add, domains.create_address | Да | Повтор обнаруживает, что работа первого вызова уже сделана, и сообщает об этом, а не выполняет её дважды. |
| contacts.set_photo, account.set_photo, branding.upload_image, domains.set_logo, domains.set_logo_certificate, domains.set_address_photo, imports.upload_chunk | Да | Повторно отправленные байты заменяют то, что сохранила первая попытка. |
| drafts.create, labels.create, webhooks.create, templates.create, rules.create, roles.create, temp_mail.create, files.upload | Нет | Повтор оставит два объекта. |
| drafts.update | Нет | Читайте идентификатор из результата каждой записи, а не переиспользуйте тот, что отправили. |
| drafts.delete, labels.delete, webhooks.delete, templates.delete, rules.delete, roles.delete, members.remove, members.revoke_address, temp_mail.delete, temp_mail.delete_message | Нет | Повтор после потерянного ответа сообщит об ошибке для работы, которая удалась. |
| webhooks.rotate_secret | Нет | Вторая ротация обесценивает секрет, который вернула первая попытка. |
| webhooks.test | Нет | Он отправил бы вторую синтетическую доставку. |
| webhooks.replay_delivery | Нет | Он отправил бы событие вашему получателю второй раз. |
| emails.translate, emails.compose, emails.rewrite, emails.suggest_subject | Нет | Каждый из них тратит вызовы модели, поэтому повтор после запроса без ответа оплачивает один и тот же ответ дважды. |
| security.begin_step_up, security.verify_step_up | Нет | Повтор мог бы отправить второе письмо или потратить вторую попытку ввода кода. |
| Любой другой вызов, который не является GET | Нет | Отправляется один раз, и о сбое сообщается, а не повторяется запрос. |
Схема отката
- Ограничено
max_retries:на клиенте, по умолчанию две дополнительные попытки.max_retries: 0отключает повторы. - Только после сетевого сбоя или
408,500,502,503либо504.429повторяется, только если он несётRetry-After, а этот API его не присылает, поэтому превышение лимита сразу выбрасывает исключение. Любой другой статус выбрасывает исключение немедленно. - Экспоненциально, от половины секунды до восьми секунд, с джиттером: каждое ожидание является случайной точкой между половиной текущего потолка и им самим, поэтому множество клиентов не синхронизируется заново при восстановлении.
- Темп задаётся заголовком
Retry-Afterв любой из его форм: delay-seconds и HTTP-date. Когда сервер называет время ожидания, клиент ждёт ровно столько, а не отступает по своей схеме. - Если сервер просит ждать дольше минуты, это считается указанием клиенту остановиться, а не уснуть, поэтому ошибка выбрасывается с
retry_after_seconds. Вернуться раньше, чем просил сервер, значило бы не выполнить его просьбу. - Таймаут является таким же сетевым сбоем, как любой другой, поэтому вызов, который безопасно повторять, после него выполняется снова, а
timeout:применяется к каждой попытке заново. - Ожидания являются вызовами
sleepвнутри вызова, поэтому вызывающий поток тоже ждёт, а вызов возвращает результат или выбрасывает исключение только после окончания последней попытки.