Перейти к документации
Ruby

Повторы и идемпотентность

Что повторяется, что намеренно не повторяется и почему повторённая отправка не может продублировать письмо.

Отправки

Клиент добавляет Idempotency-Key к каждой отправке (emails.send, emails.send_batch, templates.send и broadcasts.send). Ключ генерируется один раз на **вызов** как oe- и случайный UUID и переиспользуется повторами этого вызова. API занимает этот ключ до того, как что-либо отправить, поэтому повтор воспроизводит исходное сообщение, а не отправляет второе, тогда как два намеренных вызова send всё равно отправят письмо дважды. Это разные намерения, и они остаются разными.

Передайте собственный idempotency_key:, чтобы растянуть эту гарантию на несколько процессов: задание, которое упало и запустилось снова, воспроизведёт свои отправки, а не повторит их. Воспроизведение отвечает с replayed, равным true, и сохранённым сообщением в его текущем состоянии.

idempotency.rb
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 внутри вызова, поэтому вызывающий поток тоже ждёт, а вызов возвращает результат или выбрасывает исключение только после окончания последней попытки.