Повторы и идемпотентность
Что повторяется, что намеренно не повторяется и почему повторённая отправка не может продублировать письмо.
Отправки
Клиент добавляет Idempotency-Key к каждой отправке (emails->send, emails->sendBatch, templates->send и broadcasts->send). Ключ генерируется один раз на вызов как oe- и случайный UUID и переиспользуется повторами этого вызова. API занимает этот ключ до того, как что-либо отправить, поэтому повтор воспроизводит исходное сообщение, а не отправляет второе, тогда как два намеренных вызова send всё равно отправят письмо дважды. Это разные намерения, и они остаются разными.
Передайте собственный idempotencyKey:, чтобы растянуть эту гарантию на несколько процессов: задание, которое упало и запустилось снова, воспроизведёт свои отправки, а не повторит их. Воспроизведение отвечает с replayed, равным true, и сохранённым сообщением в его текущем состоянии.
$invoiceId = 'inv_2026_09_4192'; $sent = $client->emails->send([ 'from' => '[email protected]', 'to' => '[email protected]', 'subject' => 'Your September invoice', 'text' => 'Invoice attached.',], idempotencyKey: 'invoice:' . $invoiceId); echo $sent['id'], ' ', ($sent['replayed'] ?? false) ? 'replayed' : 'sent', PHP_EOL;Выводите его из того, что сделало отправку необходимой. Никогда из часов. Повторное использование ключа с другим телом отклоняется с 422 idempotency_key_reuse, а не воспроизводится молча. Ключ содержит от 1 до 255 символов из букв, цифр, _, ., : или -, а всё остальное даёт 400 invalid_idempotency_key.
Всё остальное
Каждый GET повторяется. Запись повторяется только там, где второй такой же запрос не может значить ничего иного, чем первый, и отправка подходит под это условие, потому что её ключ идемпотентности превращает повтор в воспроизведение.
| Вызов | Повторяется | Почему |
|---|---|---|
| Каждый GET | Да | Ничего не меняется. |
| emails->send, emails->sendBatch, 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->updateAddress, domains->updateAddressForward, contacts->update, audiences->update, keys->update, forms->update, branding->update, threads->updateNote, chats->rename, account->setEmailNotification, account->setPushMuted, appHost->set, workspaces->setActive | Да | Чистая установка названных полей. |
| members->grantAddress, members->grantDomain, rules->reorder, threads->reorderNotes | Да | Выдача доступа работает как upsert, а порядок всегда задаётся целиком. |
| templates->publish, forms->publish, imports->start | Да | Публикация уже опубликованного или запуск уже начатого импорта возвращает его без изменений. |
| templates->preview, templates->render, broadcasts->preview, rules->test | Да | Они выполняют отрисовку, подсчёт или вычисление и ничего не записывают. |
| domains->verify, appHost->verify, senders->research | Да | Повторная проверка не меняет ничего, кроме времени, когда она была выполнена. |
| contacts->save, contacts->setAudiences, contacts->removePhoto, contacts->block, contacts->unblock, contacts->deleteMany, keys->revoke, files->revokeLink, files->revokeAllLinks, appHost->delete, account->removePhoto, branding->removeImage, domains->removeLogo, domains->removeLogoCertificate, domains->removeAddressPhoto, account->acceptInvitation, account->declineInvitation, forms->approveSubmission, subscriptions->move | Да | Каждый задаёт конечный результат, поэтому второй вызов оставляет то же, что оставил первый. |
| audiences->addContact, audiences->addContacts, audiences->removeContacts, audiences->importContacts, suppressions->add, domains->createAddress | Да | Повтор обнаруживает, что работа первого вызова уже сделана, и сообщает об этом, а не выполняет её дважды. |
| contacts->setPhoto, account->setPhoto, branding->uploadImage, domains->setLogo, domains->setLogoCertificate, domains->setAddressPhoto, imports->uploadChunk | Да | Повторно отправленные байты заменяют то, что сохранила первая попытка. |
| drafts->create, labels->create, webhooks->create, templates->create, rules->create, roles->create, tempMail->create, files->upload | Нет | Повтор оставит два объекта. |
| drafts->update | Нет | Читайте идентификатор из результата каждой записи, а не переиспользуйте тот, что отправили. |
| drafts->delete, labels->delete, webhooks->delete, templates->delete, rules->delete, roles->delete, members->remove, members->revokeAddress, tempMail->delete, tempMail->deleteMessage | Нет | Повтор после потерянного ответа сообщит об ошибке для работы, которая удалась. |
| webhooks->rotateSecret | Нет | Вторая ротация обесценивает секрет, который вернула первая попытка. |
| webhooks->test | Нет | Он отправил бы вторую синтетическую доставку. |
| webhooks->replayDelivery | Нет | Он отправил бы событие вашему получателю второй раз. |
| emails->translate, emails->compose, emails->rewrite, emails->suggestSubject | Нет | Каждый из них тратит вызовы модели, поэтому повтор после запроса без ответа оплачивает один и тот же ответ дважды. |
| security->beginStepUp, security->verifyStepUp | Нет | Повтор мог бы отправить второе письмо или потратить вторую попытку ввода кода. |
| Любой другой вызов, который не является GET | Нет | Отправляется один раз, и о сбое сообщается, а не повторяется запрос. |
Схема отката
- Ограничено
maxRetries:на клиенте, по умолчанию две дополнительные попытки.maxRetries: 0отключает повторы. - Только после сетевого сбоя или
408,500,502,503либо504.429повторяется, только если он несётRetry-After, а этот API его не присылает, поэтому превышение лимита сразу выбрасывает исключение. Любой другой статус выбрасывает исключение немедленно. - Экспоненциально, от половины секунды до восьми секунд, с джиттером: каждое ожидание является случайной точкой между половиной текущего потолка и им самим, поэтому множество клиентов не синхронизируется заново при восстановлении.
- Темп задаётся заголовком
Retry-Afterв любой из его форм: delay-seconds и HTTP-date. Когда сервер называет время ожидания, клиент ждёт ровно столько, а не отступает по своей схеме. - Если сервер просит ждать дольше минуты, это считается указанием клиенту остановиться, а не уснуть, поэтому исключение выбрасывается с
retryAfterSeconds. Вернуться раньше, чем просил сервер, значило бы не выполнить его просьбу. - Таймаут является таким же сетевым сбоем, как любой другой, поэтому вызов, который безопасно повторять, после него выполняется снова, а
timeout:применяется к каждой попытке заново. - Ожидания являются вызовами
sleepиusleepвнутри вызова, поэтому скрипт тоже ждёт, а вызов возвращает результат или выбрасывает исключение только после окончания последней попытки. В веб-запросе, которого ждёт человек, держитеtimeout:иmaxRetries:низкими.