lenec ru

← все посты

Идемпотентность в HTTP API: ключи, хранение и ретраи на практике

16K

POST на /payments вернул 504. Клиент не знает, прошёл платёж или нет, и нажимает кнопку ещё раз. Через минуту в базе два списания на одного пользователя. Классика, которую я разбирал в трёх компаниях за десять лет — и каждый раз корень один: API не идемпотентен, а ретрай спрятан где-то внутри клиента, прокси или service mesh.

Идемпотентность — это не «плюс одна фича», а свойство, без которого распределённая система не выживает. HTTP-методы тут помогают только частично: GET, PUT, DELETE по спецификации идемпотентны, POST — нет. А именно POST'ом мы создаём заказы, платежи, инвойсы — то есть ровно то, что дублировать нельзя.

Разберу, как сделать POST идемпотентным на уровне сервиса: что такое idempotency key, где его хранить, как обрабатывать гонки и ретраи. Покажу подводные камни, на которых я лично терял сон.

Что значит «идемпотентный POST»

Формальное определение: повторный вызов с теми же параметрами не меняет состояние сильнее, чем первый. На практике мы хотим большего — чтобы повторный вызов вернул тот же ответ, что и первый. Иначе клиент после ретрая увидит 409 «уже создано» и не получит ID созданного ресурса.

Без этого ретрай-логика на стороне клиента превращается в кошмар: «если 504 — повторить, но если 409 — значит, прошло, но я не знаю ID, надо отдельным GET'ом искать по бизнес-полям». Никто такого писать не хочет.

Поэтому стандартный контракт выглядит так: клиент присылает заголовок Idempotency-Key с уникальным значением (UUID), сервер запоминает (key, response) и при повторе возвращает сохранённый ответ. Stripe, GitHub, PayPal — все держат именно такой контракт.

Контракт заголовка

Я обычно фиксирую такие правила и пишу их в OpenAPI:

  • Idempotency-Key — необязательный для безопасных операций, обязательный для финансовых.
  • Длина 8–128 символов, ASCII, регистрозависимый. Лучше всего UUID v4.
  • Время жизни ключа — 24 часа. Дольше держать нет смысла, любой нормальный клиент успеет переретраиться.
  • Ключ скоупится по (user_id, route, key). Иначе один клиент может перетереть ключ другого, или ключ от /payments срикошетит на /refunds.
  • При совпадении ключа, но разном теле запроса — 422 с пояснением. Это защита от программной ошибки клиента.

Последний пункт спорный. Stripe, например, при несовпадении тела возвращает 400. Я на практике видел оба подхода рабочими — главное, чтобы клиент мог отличить «сетевой ретрай» от «логическая ошибка».

Минимальная схема хранения

Я держу ключи в Postgres, отдельной таблицей. Redis тоже работает, но даёт хуже гарантии — про это ниже.

CREATE TABLE idempotency_keys (
    user_id      UUID        NOT NULL,
    route        TEXT        NOT NULL,
    key          TEXT        NOT NULL,
    request_hash BYTEA       NOT NULL,
    status       SMALLINT,
    response     JSONB,
    state        TEXT        NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    completed_at TIMESTAMPTZ,
    PRIMARY KEY (user_id, route, key)
);

CREATE INDEX idem_cleanup_idx
    ON idempotency_keys (created_at)
    WHERE state = 'completed';

Поля status и response — это закешированный HTTP-ответ. request_hash нужен для проверки, что повторное обращение пришло с тем же телом. state — машина состояний из трёх значений: in_progress, completed, failed. Без неё гонки не закроешь.

Алгоритм обработки

Шаги для одного запроса с ключом:

  1. Считать Idempotency-Key. Если нет и операция не требует ключ — обычная обработка.
  2. Внутри одной транзакции попытаться вставить строку с state = 'in_progress'. Используем INSERT ... ON CONFLICT DO NOTHING RETURNING ....
  3. Если вставили — значит, мы первые. Идём выполнять бизнес-логику. По завершении пишем результат и переводим в completed.
  4. Если не вставили — читаем существующую строку. Дальше три ветки в зависимости от state.

В Kotlin это выглядит примерно так:

fun handle(req: PaymentRequest, key: String, userId: UUID): Response {
    val hash = sha256(req.canonicalBytes())
    val acquired = idempotencyRepo.tryInsert(userId, "/payments", key, hash)

    if (acquired) {
        return try {
            val result = paymentService.charge(req)
            idempotencyRepo.complete(userId, "/payments", key, 201, result)
            Response(201, result)
        } catch (e: BusinessError) {
            idempotencyRepo.complete(userId, "/payments", key, 422, e.body)
            Response(422, e.body)
        } catch (e: Exception) {
            idempotencyRepo.markFailed(userId, "/payments", key)
            throw e
        }
    }

    val existing = idempotencyRepo.find(userId, "/payments", key)
        ?: throw IllegalStateException("race lost but row missing")

    if (!existing.requestHash.contentEquals(hash)) {
        return Response(422, "key reused with different body")
    }
    return when (existing.state) {
        "completed" -> Response(existing.status, existing.response)
        "in_progress" -> Response(409, "request in progress, retry later")
        "failed" -> Response(500, "previous attempt failed, use new key")
        else -> throw IllegalStateException("unknown state")
    }
}

Ключевая деталь — состояние in_progress. Если первый запрос ещё не завершился, а второй уже пришёл, мы не блокируем его на долгий timeout, а сразу возвращаем 409. Клиент по этому коду понимает: «повторю через 200 мс». В обратном случае два запроса синхронно выполнят бизнес-логику и оба зальют деньги.

Хеш тела запроса

Чтобы хеш был стабильным, тело надо канонизировать. Я обычно:

  • Парсю JSON в DOM, сериализую обратно с отсортированными ключами.
  • Игнорирую пробелы и порядок полей, которые не влияют на смысл.
  • Не включаю в хеш timestamps клиента и его trace-id — они меняются между ретраями.

Если хешировать сырые байты, то два валидных JSON с разной расстановкой пробелов посчитаются разными. Я на это нарывался: клиент на iOS делал ретрай через NSURLSession, она пересобирала тело и меняла порядок ключей. Все запросы внезапно стали «новыми».

Где хранить

Выбор хранилища не очевиден. Я разбирал три варианта.

Postgres

Плюсы: транзакционная согласованность с самой бизнес-операцией. Если выполнение платежа и запись результата в idempotency-таблицу происходят в одной транзакции, нельзя получить ситуацию «бизнес-операция прошла, ключ не сохранился» или наоборот.

Минусы: дополнительная нагрузка на основную БД. На 5000 RPS это начинает чувствоваться.

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

Redis

Плюсы: быстро. SET key value NX EX 86400 — это одна команда, одна сетевая итерация.

Минусы: Redis — отдельное хранилище, и согласованность между ним и Postgres вы делаете сами. Падение Redis между «вставили ключ» и «выполнили операцию» создаёт окно для дублей. Persistence у Redis по умолчанию не fsync на каждую запись.

Когда выбирать: некритичные операции, где дубли допустимы как редкое исключение. Лайки, отметки прочтения, отправка уведомлений. Не для платежей.

Внешний idempotency-сервис

Отдельный сервис с собственной БД — на крупных проектах оправдан. Все остальные сервисы ходят к нему за ключом перед бизнес-операцией.

Плюсы: единое место для очистки, метрик, алертов. Удобно прикручивать к API gateway.

Минусы: добавляет сетевой хоп, требует своей надёжности. Если он лёг — все POST'ы стоят.

Когда выбирать: 50+ микросервисов, унифицированный контракт по всему домену. На 5–10 сервисах оверкилл.

Гонки, которые я ловил в проде

Несколько сценариев, которые в учебниках не описывают.

Двойной клик в браузере. Фронт сгенерировал ключ при монтировании формы и не пересоздаёт его на повторный submit. Хорошо. Но если фронт делает оптимистичный апдейт и при ошибке пересобирает форму, ключ пересоздаётся, и идемпотентность ломается. Правило: ключ живёт ровно столько, сколько живёт «попытка» с точки зрения пользователя.

Ретрай на уровне service mesh. Istio или Linkerd могут ретраить 5xx по своей политике. Если внутренний вызов между сервисами не идемпотентен, mesh устроит дубль. Идемпотентность нужна не только на внешнем периметре API.

Тайм-аут клиента короче, чем хендлер сервера. Клиент отвалился по таймауту через 5 секунд, а платёж на стороне сервера ушёл в банк и завершился через 10. Клиент ретраит с тем же ключом — попадает в in_progress и получает 409. Через ещё несколько секунд приходит результат. Без in_progress-ветки клиент бы запустил вторую попытку платежа.

Очистка таблицы. 24-часовое TTL надо чем-то выполнять. У меня был случай, когда cron-job упал, таблица доросла до 200 ГБ, autovacuum не успевал, latency на API подскочил вдвое. С тех пор очистка — отдельный процесс с метриками и алертами по размеру таблицы.

Что не лечится идемпотентностью

Несколько вещей, которые я видел в выдаче и которые надо разделять.

  • Бизнес-дубли. Если пользователь сам нажал «оплатить» дважды с двумя разными ключами — это не проблема API, это проблема UX. Технически оба запроса валидны. Дедуп тут — на уровне бизнес-правил (например, «один платёж в минуту на инвойс»).
  • Eventual consistency между сервисами. Идемпотентность ключа защищает один эндпоинт. Если платёж триггерит цепочку из пяти асинхронных шагов — на каждом шаге своя идемпотентность.
  • Финальный статус операции. Идемпотентность гарантирует, что POST /payments с тем же ключом вернёт тот же ответ. Но если ответ был «принято в обработку», финальный успех/провал — это отдельный флоу с polling или webhook'ом.

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

Когда я добавляю идемпотентность в существующее API, прохожу по такому списку:

  1. Контракт ключа описан в OpenAPI и в гайде для клиентов.
  2. Хранилище выбрано исходя из критичности операций, не «чтобы быстрее».
  3. Машина состояний с in_progress реализована и протестирована на гонках.
  4. Канонизация тела запроса согласована с клиентами всех платформ.
  5. Скоуп ключа включает пользователя и роут — не только сам ключ.
  6. TTL и очистка таблицы — отдельный процесс с алертами.
  7. В service mesh и retry-политиках клиентов поведение согласовано.

Если из этих пунктов не закрыто хотя бы что-то, идемпотентность работает на 80% — а оставшиеся 20% и есть те самые ночные инциденты с двойными списаниями. Идемпотентность стоит дешевле, чем разбор инцидента и возврат денег пользователям. Платите этот налог сразу.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.