Инженерия · 21 сентября 2026 г.

Ключи идемпотентности в email API

Как избежать дублей писем при сетевых повторах с помощью ключей идемпотентности. Учимся обрабатывать сбои распределённых систем, не заваливая пользователей спамом.

Проблема дублирующихся писем

Дубли писем появляются, когда клиент отправляет запрос, сервер его обрабатывает, но сеть обрывается до того, как клиент получит успешный ответ. Клиент видит тайм-аут или ошибку 5xx и повторяет запрос. Без идемпотентности сервер считает повтор новым запросом и снова отправляет письмо. Ключи идемпотентности решают эту проблему: сервер узнаёт повторный запрос и возвращает исходный результат, не выполняя побочный эффект заново.

Для инженера, отвечающего за очередь инцидентов, нет ничего хуже «шторма дублей». Обычно он случается при частичном сбое вышестоящего провайдера или при взаимной блокировке в базе данных, из-за которой растёт время ответа. Логика повторов, задуманная ради надёжности, превращается в оружие, которое заваливает пользователей спамом и портит репутацию отправителя.

Почему повторы без идемпотентности опасны

В распределённой системе у любого вызова API есть три точки отказа:

  1. Запрос не доходит до сервера.
  2. Сервер обрабатывает запрос, но ответ теряется.
  3. Сервер падает посреди обработки.

В случае 1 повтор безопасен. В случае 2 повтор создаёт дубль. В случае 3 дубль возможен в зависимости от того, в какой момент произошёл сбой.

Отправка письма — внешний побочный эффект. В отличие от изменения имени пользователя в базе данных (которое естественным образом идемпотентно, если вы используете SET name = 'Alice'), отправка письма — аддитивное действие. Каждый вызов эндпоинта send создаёт в мире новое сообщение. Чтобы сделать его идемпотентным, нужно ввести уникальный идентификатор намерения отправить письмо — ключ идемпотентности.

Реализация ключей идемпотентности

Ключ идемпотентности — это уникальное значение (обычно UUID v4), которое клиент генерирует и передаёт в заголовке запроса. Сервер использует этот ключ для отслеживания состояния запроса.

Порядок работы на сервере

  1. Получение запроса: сервер проверяет, есть ли заголовок Idempotency-Key.
  2. Поиск: сервер ищет этот ключ в быстром хранилище (например, Redis).
  3. Попадание в кеш: если ключ найден, сервер сразу возвращает закешированный ответ, не обращаясь к механизму доставки писем.
  4. Промах кеша: сервер блокирует ключ, отправляет письмо, сохраняет ответ и возвращает его клиенту.
  5. Истечение срока: у ключа задаётся срок жизни (например, 24 часа), чтобы база данных не росла бесконечно.

Пример полезной нагрузки

Так должен выглядеть запрос при работе с API вроде SendHQ:

POST /v1/send Host: api.sendhq.cc Content-Type: application/json Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 Authorization: Bearer YOUR_API_KEY { "to": "user@example.com", "template_id": "welcome-email", "variables": { "name": "Alex" } }

Обработка ошибок

Не все повторы одинаковы. Нужно различать ошибки клиента и ошибки сервера.

  • Ошибки 4xx: если сервер возвращает 400 (Bad Request) или 422 (Unprocessable Entity), запрос некорректен. Повтор с тем же ключом должен вернуть ту же ошибку 4xx. Не меняйте полезную нагрузку, сохраняя прежний ключ, — это создаёт конфликт.
  • Ошибки 5xx: если сервер возвращает 500 или 503, клиенту следует повторить запрос. Если сервер уже успешно передал письмо MTA (Mail Transfer Agent, агенту передачи почты), ключ идемпотентности гарантирует, что повтор вернёт 200 OK, а не отправит второе письмо.
  • Одновременные запросы: если два одинаковых запроса с одним ключом приходят в одну и ту же миллисекунду, сервер должен вернуть на второй 409 Conflict, показывая, что первый ещё обрабатывается.

Идемпотентность для ИИ-агентов

ИИ-агенты (использующие MCP-серверы или карточки A2A) добавляют новый уровень риска. LLM могут быть недетерминированными и вызывать один и тот же инструмент несколько раз, если им покажется, что в цикле произошёл сбой.

Создавая интеграции для агентов, никогда не позволяйте агенту запускать действие send без шага подтверждения или детерминированного ключа идемпотентности, сгенерированного оркестратором. Оркестратор должен сопоставлять намерение агента (например, «Отправь еженедельный отчёт Бобу») со стабильным ключом на основе ID отчёта и даты. Так агент не отправит один и тот же отчёт пять раз только потому, что ему «показалось», будто первый вызов не удался.

Цена ошибки: сравнение провайдеров

Без идемпотентности вы не только раздражаете пользователей, но и теряете деньги. Некоторые провайдеры дешевле, но стоимость дублей быстро растёт.

По данным официальных страниц цен (на сентябрь 2026 года):

  • Amazon SES: 0.10 USD за 1 000 писем при оплате a la carte (цены Amazon SES). Новые тарифы, введённые 21 июля 2026 года: Essentials (0.16 USD за 1 000), Pro (0.22 USD за 1 000 плюс 105 USD в месяц за регион) и Enterprise (0.23 USD за 1 000 плюс 500 USD в месяц).
  • Resend: бесплатный тариф — 3 000 писем в месяц, не более 100 в день. Pro — 20 USD в месяц за 50 000 писем, превышение — 0.90 USD за 1 000 (цены Resend).
  • SendGrid: бесплатный тариф теперь — 60-дневный пробный период, тарифы Essentials — от 19.95 USD в месяц (цены SendGrid).
  • Mailgun: 15 USD в месяц за 10 000 писем, превышение — от 1.80 до 1.10 USD за 1 000 (цены Mailgun).
  • Postmark: 15 USD в месяц за 10 000 писем, превышение — от 1.80 до 1.20 USD за 1 000 (цены Postmark).

Для наглядности: отправка 50 000 писем стоит около 5 USD на SES a la carte и около 66 USD на тарифах Postmark. Если цикл повторов без идемпотентности случайно увеличит объём в 10 раз, разница между провайдерами станет заметной строкой в отчёте об инциденте.

Доставляемость и приём

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

  1. Приём: API принимает ваш запрос и возвращает 200 OK. Ключи идемпотентности работают на этом уровне.
  2. Доставка: API передаёт письмо принимающему серверу (например, Gmail). Здесь важны SPF и DKIM/DMARC.
  3. Попадание во «Входящие»: принимающий сервер решает, попадёт письмо во «Входящие» или в «Спам».

Ключ идемпотентности гарантирует лишь то, что вы примете запрос один раз. Он не гарантирует, что письмо будет доставлено или не попадёт в спам. Чтобы убедиться, что инфраструктура правильно настроена для доставки, проверьте записи с помощью инструментов вроде Email DNS Checker от SendHQ.

Чек-лист внедрения для инженеров

Если вы сегодня проверяете логику отправки писем, используйте этот чек-лист:

  • Генерация ключей на стороне клиента: генерируете ли вы UUID v4 для каждого уникального намерения отправить письмо?
  • Реализация заголовка: передаётся ли ключ в стандартном заголовке (например, Idempotency-Key), а не в теле запроса?
  • Уровень хранения: задан ли TTL (Time To Live) для ключей идемпотентности, чтобы предотвратить разрастание хранилища?
  • Атомарная блокировка: использует ли ваш сервер распределённую блокировку (например, SET NX в Redis), чтобы предотвратить состояния гонки для одного ключа?
  • Кэширование ответов: сохраняете ли вы полный ответ (код состояния и тело), чтобы возвращать его клиенту при повторных попытках?
  • Ограничения для агентов: если используются ИИ-агенты, генерирует ли ключ системный оркестратор, а не LLM?

Пример кода: middleware идемпотентности на Node.js

Упрощённый пример того, как можно реализовать эту логику в среде Node.js с Redis.

const redis = require('redis'); const client = redis.createClient(); async function sendEmailHandler(req, res) { const idempotencyKey = req.headers['idempotency-key']; if (!idempotencyKey) { return res.status(400).json({ error: 'Idempotency-Key header is required' }); } // Try to acquire a lock and check for existing response const cachedResponse = await client.get(`idempotency:${idempotencyKey}`); if (cachedResponse) { const { status, body } = JSON.parse(cachedResponse); return res.status(status).json(body); } // Set a lock to prevent concurrent requests const lock = await client.set(`lock:${idempotencyKey}`, 'true', 'NX', 'EX', 30); if (!lock) { return res.status(409).json({ error: 'Request is currently being processed' }); } try { // Actual email sending logic const result = await emailProvider.send(req.body); const responsePayload = { status: 200, body: result }; // Cache the result for 24 hours await client.set(`idempotency:${idempotencyKey}`, JSON.stringify(responsePayload), 'EX', 86400); return res.status(200).json(result); } catch (error) { return res.status(500).json({ error: 'Internal Server Error' }); } finally { await client.del(`lock:${idempotencyKey}`); } }

Выводы

Для транзакционной почты идемпотентность — не «приятное дополнение», а обязательное требование для любой системы, которой важны пользовательский опыт и контроль затрат. Перекладывая ответственность за уникальность на клиента и давая серверу механизм её отслеживания, вы устраняете риск повторных отправок при нестабильной сети.

Создаёте ли вы классический SaaS-продукт или автономного ИИ-агента, отношение к письму как к критически важному побочному эффекту сохраняет надёжность системы и довольство пользователей. Email API, ориентированный на разработчиков и берущий эти сложности на себя, — на https://sendhq.cc.