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

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

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

Отправки

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

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

idempotency.php
$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: низкими.