Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Идемпотентность в API: как исключить двойные заказы и списания — меньше споров и возвратов

Разработка и технологии4 апреля 2026 г.
Повторы запросов из‑за сетевых сбоев часто приводят к дублям заказов и повторным списаниям. Разбираем, как спроектировать идемпотентность в API, хранилище и обработчиках событий, чтобы операции выполнялись ровно один раз с предсказуемым результатом.
Идемпотентность в API: как исключить двойные заказы и списания — меньше споров и возвратов

  • Содержание
    • Что на самом деле ломается при повторах запросов
    • Что такое идемпотентность и где она нужна
    • Дизайн API с Idempotency-Key: контракт и границы
    • Хранение результатов и дедупликация в БД (Postgres)
    • Пример реализации на FastAPI + asyncpg
    • Вебхуки и очереди: inbox/outbox и ровно-однажды на практике
    • Бизнес‑идентификаторы и естественные ключи
    • Наблюдаемость и аудит
    • Типичные ошибки и как их избежать
    • Масштабирование, TTL и уборка
    • Чек‑лист внедрения

Что на самом деле ломается при повторах запросов

Поведение клиентов и сети непредсказуемо: у мобильного интернета плавают задержки, браузер может «повторить отправку», прокси отрежет ответ на таймауте, а SDK платежного шлюза автоматически делает ретраи. В результате:

  • заказ создаётся дважды, менеджер путается в отгрузке;
  • платёж списывается два раза — спор, chargeback, комиссия и испорченный NPS;
  • купон помечен «израсходованным» дважды, хотя скидка одна;
  • пользователю уходит два письма и два пуша — раздражение и лишняя нагрузка.

Все эти проблемы происходят не из‑за «плохих пользователей», а из‑за отсутствия чёткого контракта идемпотентности между клиентом, API и хранилищем.

Что такое идемпотентность и где она нужна

Идемпотентность — свойство операции: повторение одного и того же запроса с одними и теми же данными приводит к одному и тому же наблюдаемому результату. Примеры:

  • безопасно: запрос «получить статус заказа»; обновление ресурса по ключу (PUT) при одинаковом теле;
  • опасно: «создать заказ», «списать платёж», «выдать купон», «начислить бонус». Эти операции меняют состояние и без защиты легко повторяются.

Где она критична:

  • создание заказов, счетов, подписок;
  • проведение и подтверждение платежей (authorize/capture);
  • списание лимитов, промокоды, инвентарь;
  • обработка вебхуков и событий из очередей (доставка «минимум один раз» почти всегда).

Дизайн API с Idempotency-Key: контракт и границы

Классический подход — ввести явный ключ идемпотентности, передаваемый с запросом. Например, заголовок Idempotency-Key. Важно не только «принять ключ», но и формализовать правила.

Что должно быть в контракте API:

  • Где передаётся ключ: заголовок Idempotency-Key (до 256 символов) или параметр запроса.
  • Область действия (scope): обычно «пользователь/тенант + эндпоинт + метод». Один и тот же ключ нельзя использовать на разных операциях.
  • Что именно фиксируется: хэш запроса (метод, путь, нормализованное тело) и результирующий ответ (статус + тело), сохраняемые на стороне сервера.
  • Поведение при повторах:
    • если ключ и хэш совпадают — вернуть тот же статус и то же тело ответа, без повторного выполнения бизнес‑логики;
    • если ключ совпадает, а хэш другой — 422 Unprocessable Entity с пояснением, что ключ уже занят другой операцией;
    • если операция ещё в процессе — 202 Accepted или 409 Conflict с советом повторить позже.
  • TTL хранения результатов: сколько времени сервер держит запись (например, 24–72 часа для платежей, 7 дней для заказов).
  • Какие ошибки разрешено ретраить: сетевые таймауты, 5xx. 4xx — не ретраить.

Бонус: клиент может генерировать ключи заранее (UUID v7/ULID) и повторять запрос до получения стабильного ответа, не боясь двойного эффекта.

Хранение результатов и дедупликация в БД (Postgres)

Минимальные требования к хранилищу идемпотентности:

  • уникальность ключа в рамках области (пользователь/тенант + эндпоинт);
  • атомарность записи «начали обрабатывать» и «получили окончательный ответ»;
  • возможность безопасно вернуть кэшированный ответ.

Схема таблицы в Postgres:

create table if not exists idempotency_keys (
  key            text        not null,
  user_id        bigint      not null,
  endpoint       text        not null,
  request_hash   bytea       not null,
  response_status int        not null default 0, -- 0 = в процессе
  response_body  jsonb       not null default '{}'::jsonb,
  created_at     timestamptz not null default now(),
  ttl_seconds    int         not null default 86400,
  primary key (key, user_id, endpoint)
);

create index if not exists idx_idem_created_at on idempotency_keys (created_at);

Ключевые приёмы:

  • Вставка «заглушки» до выполнения бизнес‑операции. Только тот, кто успел вставить, выполняет логику. Остальные видят «в процессе».
  • Сопоставление хэша запроса. Повтор с иным телом по тому же ключу — ошибка валидации.
  • Обновление записи финальным ответом в рамках одной транзакции с бизнес‑действием.

Пример реализации на FastAPI + asyncpg

Ниже — рабочий пример: POST /orders создаёт заказ. Сервер обеспечивает идемпотентность по заголовку Idempotency-Key, уникальным для пользователя и эндпоинта.

# requirements:
# fastapi==0.110.0
# uvicorn[standard]==0.27.1
# asyncpg==0.29.0
# pydantic==2.6.1

import hashlib
import json
import os
from typing import Optional

import asyncpg
from fastapi import FastAPI, Header, HTTPException, Request, Response, status
from pydantic import BaseModel, conint

DATABASE_URL = os.getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/app")

app = FastAPI(title="Orders API with Idempotency")

pool: Optional[asyncpg.Pool] = None

class OrderIn(BaseModel):
    customer_id: conint(gt=0)
    items: list[dict]
    currency: str

class OrderOut(BaseModel):
    order_id: int
    status: str
    amount: int
    currency: str

async def ensure_schema(conn: asyncpg.Connection):
    await conn.execute(
        """
        create table if not exists idempotency_keys (
          key             text        not null,
          user_id         bigint      not null,
          endpoint        text        not null,
          request_hash    bytea       not null,
          response_status int         not null default 0, -- 0 = processing
          response_body   jsonb       not null default '{}'::jsonb,
          created_at      timestamptz not null default now(),
          ttl_seconds     int         not null default 86400,
          primary key (key, user_id, endpoint)
        );

        create table if not exists orders (
          id           serial primary key,
          user_id      bigint      not null,
          customer_id  bigint      not null,
          amount       int         not null,
          currency     text        not null,
          status       text        not null,
          created_at   timestamptz not null default now()
        );
        """
    )

@app.on_event("startup")
async def on_startup():
    global pool
    pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=5)
    async with pool.acquire() as conn:
        await ensure_schema(conn)

@app.on_event("shutdown")
async def on_shutdown():
    if pool:
        await pool.close()


def canonical_request_hash(method: str, path: str, body: bytes) -> bytes:
    h = hashlib.sha256()
    h.update(method.upper().encode())
    h.update(b"\n")
    h.update(path.encode())
    h.update(b"\n")
    # Тело нормализуем: JSON без пробелов, стабильный порядок ключей
    try:
        as_json = json.loads(body.decode() or "{}")
        body_norm = json.dumps(as_json, separators=(",", ":"), sort_keys=True).encode()
    except Exception:
        body_norm = body
    h.update(body_norm)
    return h.digest()


@app.post("/orders", response_model=OrderOut)
async def create_order(
    request: Request,
    idempotency_key: str = Header(alias="Idempotency-Key"),
    x_user_id: int = Header(alias="X-User-Id")
):
    if not idempotency_key or len(idempotency_key) > 256:
        raise HTTPException(status_code=400, detail="Отсутствует или слишком длинный Idempotency-Key")

    raw_body = await request.body()
    req_hash = canonical_request_hash(request.method, request.url.path, raw_body)

    async with pool.acquire() as conn:
        async with conn.transaction():
            # Попытаемся вставить заглушку. Если получилось — мы «владельцы» выполнения.
            inserted = await conn.execute(
                """
                insert into idempotency_keys (key, user_id, endpoint, request_hash)
                values ($1, $2, $3, $4)
                on conflict do nothing
                """,
                idempotency_key, x_user_id, request.url.path, req_hash
            )

            if inserted == "INSERT 0 1":
                # Впервые видим этот ключ: выполняем бизнес-логику
                try:
                    payload = await request.json()
                except Exception:
                    raise HTTPException(status_code=400, detail="Невалидный JSON")

                order_in = OrderIn(**payload)
                amount = sum(int(item.get("price", 0)) * int(item.get("qty", 1)) for item in order_in.items)

                row = await conn.fetchrow(
                    """
                    insert into orders (user_id, customer_id, amount, currency, status)
                    values ($1, $2, $3, $4, 'created')
                    returning id, status, amount, currency
                    """,
                    x_user_id, order_in.customer_id, amount, order_in.currency
                )

                response = {
                    "order_id": row[0],
                    "status": row[1],
                    "amount": row[2],
                    "currency": row[3],
                }

                # Сохраняем финальный ответ для будущих повторов
                await conn.execute(
                    """
                    update idempotency_keys
                    set response_status = $1, response_body = $2
                    where key = $3 and user_id = $4 and endpoint = $5
                    """,
                    201, json.dumps(response), idempotency_key, x_user_id, request.url.path
                )

                # Возвращаем обычный ответ первого выполнения
                return Response(
                    content=json.dumps(response),
                    status_code=201,
                    media_type="application/json"
                )

            # Ключ уже существует — проверим хэш и вернём кэшированный ответ
            existing = await conn.fetchrow(
                """
                select request_hash, response_status, response_body
                from idempotency_keys
                where key = $1 and user_id = $2 and endpoint = $3
                """,
                idempotency_key, x_user_id, request.url.path
            )

            if not existing:
                # Теоретически не должно случиться: гонка на разделах/узлах
                raise HTTPException(status_code=409, detail="Идемпотентный ключ в конфликте, попробуйте позже")

            if bytes(existing[0]) != req_hash:
                raise HTTPException(status_code=422, detail="Idempotency-Key уже использован с другим запросом")

            status_code = int(existing[1])
            body = existing[2]

            if status_code == 0:
                # Операция ещё выполняется в другом воркере
                raise HTTPException(status_code=409, detail="Операция в обработке, повторите позже")

            return Response(
                content=json.dumps(body),
                status_code=status_code,
                media_type="application/json"
            )

Особенности:

  • Вставка заглушки делает операцию «владение ключом» атомарной благодаря уникальному ключу в БД.
  • Ответ кэшируется и возвращается при повторениях с тем же телом.
  • Конфликт по ключу и разным телам — явная 422, чтобы клиент не «ломал» защиту.

Вебхуки и очереди: inbox/outbox и «ровно‑однажды» на практике

Транспортные системы почти всегда дают доставку «минимум один раз». Мы достигаем «ровно один раз с точки зрения бизнеса» с помощью inbox/outbox.

Inbox (для входящих событий, вебхуков):

create table if not exists payment_events (
  provider      text        not null,
  event_id      text        not null,
  received_at   timestamptz not null default now(),
  payload       jsonb       not null,
  processed_at  timestamptz,
  primary key (provider, event_id)
);

Алгоритм обработчика вебхука:

  1. попытаться вставить (provider, event_id, payload) — on conflict do nothing;
  2. если вставили — обрабатываем бизнес‑действие в транзакции, обновляем processed_at;
  3. если запись уже есть и processed_at заполнен — игнорируем повтор.

Outbox (для исходящих событий): в той же транзакции, что и изменение данных, пишем запись в outbox, а отдельный воркер надёжно публикует событие и помечает «отправлено». Это устраняет «сделали в БД, не отправили в шину» и наоборот.

Бизнес‑идентификаторы и естественные ключи

Иногда проще всего сделать операцию идемпотентной, выбрав правильный ключ:

  • заказ создаётся по клиентскому order_uid (UUID v7/ULID), который генерируется на стороне клиента ещё до запроса;
  • повторный POST с тем же order_uid просто возвращает тот же заказ, а не создаёт новый;
  • в БД — уникальный индекс по (user_id, order_uid).

Преимущества — меньше таблиц сопроводительных ключей и проще отладка. Недостаток — нужен продуманный формат ключа и проверка коллизий.

Наблюдаемость и аудит

  • Коррелируйте логи по Idempotency-Key и trace-id: удобно искать «первый» и «повторные» вызовы.
  • Считайте метрики: доля повторов, средняя длительность «в процессе», количество 422 по идемпотентности.
  • В аудитах храните исходный запрос и ответ: полезно при разборе спорных списаний.

Типичные ошибки и как их избежать

  • Один и тот же ключ на разные эндпоинты. Решение: включайте endpoint и user в первичный ключ.
  • Слишком короткий ключ (например, инкремент из браузера) — коллизии. Используйте UUID v4/v7 или ULID.
  • Храним только статус без тела — клиенты получают разные ответы при повторах. Сохраняйте и статус, и тело.
  • Нет проверки хэша запроса — клиент может «переиспользовать» ключ с другим телом и получить мусор.
  • TTL 5 минут для платежей — пользователи часто ретраят позже. Ставьте 24–72 часа, а лучше ориентируйтесь на бизнес‑процесс.
  • Идемпотентность «для БД», но не для побочных эффектов: письма/пуши улетают дважды. Дедуплицируйте побочные действия по тому же ключу или бизнес‑идентификатору.
  • Возвращаем 200 при повторе, а первый раз был 201 — клиенты путаются. Возвращайте тот же статус, что и в первый успешный раз.

Масштабирование, TTL и уборка

  • Хранилище ключей: Postgres — хороший базовый выбор; при экстремальной нагрузке можно вынести в Redis с персистентностью (RDB+AOF), но помните про согласованность и репликацию.
  • Шардирование: шард по хэшу (user_id, endpoint, key) распределяет нагрузку ровно.
  • Очистка старых ключей: периодический джоб удаляет записи старше created_at + ttl_seconds.

Пример SQL‑уборки:

delete from idempotency_keys
where created_at + make_interval(secs => ttl_seconds) < now()
limit 10000; -- батчево, чтобы не блокировать
  • Индексы: created_at для уборки, PK на (key, user_id, endpoint). При больших объёмах — партиционирование по дате.

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

  • В API описан контракт Idempotency-Key: где, для чего, на сколько хранится, какие коды ошибок возможны.
  • Реализован сторедж ключей с уникальным первичным ключом и хранением статуса и тела ответа.
  • Хэш запроса считается одинаково во всех инстансах сервиса.
  • Бизнес‑операция и запись результата — в одной транзакции.
  • Для вебхуков и очередей — inbox/outbox, уникальные идентификаторы событий и повторобезопасные обработчики.
  • Побочные эффекты (email, пуши, интеграции) — тоже идемпотентны.
  • Метрики и логи коррелируются по идемпотентным ключам.
  • Есть фоновая уборка и разумный TTL.
  • Документация для интеграторов: когда ретраить, какой бэк‑офф, какие ответы ожидать.

Итог для бизнеса

Идемпотентность — это не про «красивый HTTP», а про деньги и доверие. Правильный дизайн ключей и обработчиков убирает дубли заказов и повторные списания, снижает количество споров и обращений в поддержку, делает интеграции с партнёрами стабильными, а отчёты — согласованными. Потратите 2–3 дня на архитектуру и внедрение — сэкономите месяцы на разборе инцидентов и репутацию у клиентов.


APIидемпотентностьплатежи