Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Идемпотентность в API: как убрать двойные списания и дубликаты заказов

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

• Оглавление

  • Зачем нужна идемпотентность и откуда берутся дубликаты
  • Какие операции делать идемпотентными
  • Протокол: заголовок Idempotency-Key, хеш запроса, TTL и ответы
  • Реализация на PostgreSQL: схема таблицы и конкурентные запросы
  • Полный пример на FastAPI + PostgreSQL
  • Быстрый вариант с Redis: когда уместен
  • Длинные операции и ожидание результата
  • Метрики и алерты для контроля
  • Частые ошибки и как их избежать
  • Чек-лист внедрения

Зачем нужна идемпотентность и откуда берутся дубликаты

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

  • двойные списания и возвраты,
  • дубли заказов и путаницу в логистике,
  • рост нагрузки от «шторма» ретраев.

Идемпотентность гарантирует: если пришло два (или двадцать) одинаковых запроса с одним и тем же смыслом, система выполнит операцию ровно один раз и вернёт один и тот же ответ.

Какие операции делать идемпотентными

  • Создание заказа/платежа/счёта (POST /orders, POST /payments).
  • Списание/зачисление средств, бонусов, купонов.
  • Дорогие операции с внешними провайдерами (например, отправка платёжной инструкции).
  • Любые POST/PUT/PATCH, где «повтор» недопустим.

GET по контракту и так идемпотентен и безопасен; DELETE — спорно, зависит от модели (чаще нужен ключ).

Протокол: заголовок Idempotency-Key, хеш запроса, TTL и ответы

Базовая идея простая:

  • Клиент генерирует уникальный ключ (UUID v4) и отправляет его в заголовке Idempotency-Key.
  • Сервер записывает ключ и «отправленный смысл» (хеш запроса) в хранилище, выполняет операцию и кэширует ответ.
  • При повторе с тем же ключом сервер возвращает тот же ответ без повторного побочного эффекта.

Рекомендации по контракту:

  • Область действия ключа — в рамках аккаунта/мерчанта/пользователя. Не делайте ключ глобальным.
  • Привязывайте ключ к хешу запроса: метод + путь + тело (в каноническом виде). Если ключ совпадает, а хеш — нет, верните 409 Conflict с объяснением.
  • Срок жизни ключа (TTL) — обычно 24–72 часа. Хранить дольше — дороже, смысла мало.
  • Отдавайте Idempotency-Key в ответах и кешируйте статус/тело/заголовки ответа.
  • Если первая обработка ещё идёт, можно вернуть 202 Accepted с Retry-After (или недолго подождать и вернуть итоговый ответ).

Пример запросов от клиента:

# Первая попытка
curl -X POST https://api.example.com/orders \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2c9a1e9b-4b9e-4d6e-9e67-6e3b2e7c1e32" \
  -d '{"items":[{"sku":"SKU-1","qty":2}],"amount":1200,"currency":"RUB"}'

# Повтор той же операции (после таймаута)
curl -X POST https://api.example.com/orders \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2c9a1e9b-4b9e-4d6e-9e67-6e3b2e7c1e32" \
  -d '{"items":[{"sku":"SKU-1","qty":2}],"amount":1200,"currency":"RUB"}'

Реализация на PostgreSQL: схема таблицы и конкурентные запросы

Мы будем хранить ключи в таблице с уникальным ограничением по (account_id, idempotency_key). В записи держим хеш запроса, статус, итоговый ответ и время истечения.

-- Таблица идемпотентности
CREATE TABLE IF NOT EXISTS idempotency_keys (
  account_id       TEXT        NOT NULL,
  idempotency_key  TEXT        NOT NULL,
  request_hash     TEXT        NOT NULL,
  status           TEXT        NOT NULL CHECK (status IN ('processing','completed','failed')),
  response_status  INT,
  response_headers JSONB,
  response_body    JSONB,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at       TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (account_id, idempotency_key)
);

-- Индекс для быстрой очистки по TTL
CREATE INDEX IF NOT EXISTS idempotency_keys_expires_at_idx
  ON idempotency_keys (expires_at);

Алгоритм обработки запроса:

  1. Попытаться вставить запись с (status='processing'). Если получилось — мы «владельцы» ключа и выполняем бизнес-логику.
  2. Если запись уже есть:
  • если request_hash совпадает и status='completed' — вернуть кэшированный ответ;
  • если совпадает и status='processing' — подождать (коротко) и перечитать; если всё ещё processing — вернуть 202 с Retry-After;
  • если не совпадает — 409 Conflict.
  1. После успешной операции обновить запись: сохранить статус=completed и полный ответ.

Важно: вся логика — внутри транзакции. Уникальный ключ гарантирует отсутствие гонок на вставке.

Полный пример на FastAPI + PostgreSQL

Ниже минимальный рабочий пример: один эндпоинт POST /orders, асинхронный драйвер asyncpg, простая «бизнес-логика» создания заказа. Для краткости аккаунт берём из заголовка X-Account-Id.

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

import asyncio
import json
import os
import signal
import hashlib
from datetime import datetime, timedelta, timezone

import asyncpg
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse

DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres")
IDEMPOTENCY_TTL_HOURS = int(os.getenv("IDEMPOTENCY_TTL_HOURS", "48"))

app = FastAPI(title="Idempotent Orders API")
pool: asyncpg.Pool | None = None

CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS idempotency_keys (
  account_id       TEXT        NOT NULL,
  idempotency_key  TEXT        NOT NULL,
  request_hash     TEXT        NOT NULL,
  status           TEXT        NOT NULL CHECK (status IN ('processing','completed','failed')),
  response_status  INT,
  response_headers JSONB,
  response_body    JSONB,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at       TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (account_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS idempotency_keys_expires_at_idx
  ON idempotency_keys (expires_at);
"""

async def init_db():
    global pool
    pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=10)
    async with pool.acquire() as conn:
        await conn.execute(CREATE_TABLE_SQL)

@app.on_event("startup")
async def on_startup():
    await init_db()

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

# Утилита: канонический хеш запроса (метод + путь + тело JSON с отсортированными ключами)
async def request_hash(req: Request, body: dict) -> str:
    payload = {
        "method": req.method.upper(),
        "path": req.url.path,
        "body": body,
    }
    # Стабильная сериализация
    data = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return hashlib.sha256(data).hexdigest()

# Пример бизнес-операции: создаём заказ (на самом деле просто генерим id)
async def create_order(account_id: str, body: dict) -> dict:
    # Тут могла бы быть работа с вашей БД/платёжкой/складом
    order_id = hashlib.sha1(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
    return {
        "order_id": order_id,
        "account_id": account_id,
        "amount": body.get("amount"),
        "currency": body.get("currency", "RUB"),
        "items": body.get("items", []),
        "status": "created",
    }

@app.post("/orders")
async def create_order_endpoint(request: Request):
    if pool is None:
        raise HTTPException(500, detail="DB pool not initialized")

    account_id = request.headers.get("X-Account-Id")
    if not account_id:
        raise HTTPException(400, detail="Missing X-Account-Id header")

    idemp_key = request.headers.get("Idempotency-Key")
    if not idemp_key:
        raise HTTPException(400, detail="Missing Idempotency-Key header for POST /orders")

    try:
        body = await request.json()
        if not isinstance(body, dict):
            raise ValueError
    except Exception:
        raise HTTPException(400, detail="Invalid JSON body")

    rhash = await request_hash(request, body)
    expires_at = datetime.now(timezone.utc) + timedelta(hours=IDEMPOTENCY_TTL_HOURS)

    async with pool.acquire() as conn:
        async with conn.transaction():
            # 1) Пытаемся вставить ключ в статусе processing
            inserted = await conn.execute(
                """
                INSERT INTO idempotency_keys(account_id, idempotency_key, request_hash, status, expires_at)
                VALUES ($1, $2, $3, 'processing', $4)
                ON CONFLICT DO NOTHING
                """,
                account_id, idemp_key, rhash, expires_at
            )
            if inserted and inserted.startswith("INSERT"):
                # Мы владельцы ключа — выполняем операцию
                try:
                    result = await create_order(account_id, body)
                    response_body = result
                    response_status = 201
                    response_headers = {"Content-Type": "application/json"}

                    await conn.execute(
                        """
                        UPDATE idempotency_keys
                        SET status='completed', response_status=$5, response_headers=$6, response_body=$7
                        WHERE account_id=$1 AND idempotency_key=$2 AND request_hash=$3
                        """,
                        account_id, idemp_key, rhash, response_status, json.dumps(response_headers), json.dumps(response_body)
                    )
                except Exception as e:
                    # Помечаем как failed, но сохраняем хеш для конфликтов
                    await conn.execute(
                        """
                        UPDATE idempotency_keys
                        SET status='failed'
                        WHERE account_id=$1 AND idempotency_key=$2 AND request_hash=$3
                        """,
                        account_id, idemp_key, rhash
                    )
                    raise

            else:
                # Ключ уже существует — проверяем состояние
                row = await conn.fetchrow(
                    """
                    SELECT request_hash, status, response_status, response_headers, response_body
                    FROM idempotency_keys
                    WHERE account_id=$1 AND idempotency_key=$2
                    """,
                    account_id, idemp_key
                )
                if not row:
                    # Маловероятно из-за PRIMARY KEY, но проверим
                    raise HTTPException(500, detail="Idempotency state lost")

                if row["request_hash"] != rhash:
                    raise HTTPException(409, detail="Idempotency-Key already used with different request")

                if row["status"] == "completed":
                    # Отдаём кэшированный ответ
                    headers = row["response_headers"] or {}
                    resp = JSONResponse(content=row["response_body"], status_code=row["response_status"] or 200)
                    for k, v in headers.items():
                        resp.headers[k] = v
                    resp.headers["Idempotency-Key"] = idemp_key
                    return resp

                if row["status"] == "processing":
                    # Небольшое ожидание результата (лонг-поллинг ~1 сек)
                    for _ in range(10):
                        await asyncio.sleep(0.1)
                        row2 = await conn.fetchrow(
                            """
                            SELECT status, response_status, response_headers, response_body
                            FROM idempotency_keys
                            WHERE account_id=$1 AND idempotency_key=$2
                            """,
                            account_id, idemp_key
                        )
                        if row2 and row2["status"] == "completed":
                            headers = row2["response_headers"] or {}
                            resp = JSONResponse(content=row2["response_body"], status_code=row2["response_status"] or 200)
                            for k, v in headers.items():
                                resp.headers[k] = v
                            resp.headers["Idempotency-Key"] = idemp_key
                            return resp
                    # Если не дождались — просим клиента повторить
                    resp = JSONResponse({"detail": "Processing"}, status_code=202)
                    resp.headers["Retry-After"] = "2"
                    resp.headers["Idempotency-Key"] = idemp_key
                    return resp

    # Если мы владельцы ключа и дошли сюда — отдаём свежесформированный ответ
    response = JSONResponse(content=response_body, status_code=response_status)
    response.headers.update({"Idempotency-Key": idemp_key})
    return response

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8000)))

Пояснения:

  • Уникальный PRIMARY KEY на (account_id, idempotency_key) снимает гонки при конкурентных запросах.
  • Мы храним полный ответ, чтобы быстро возвращать копию без повторной бизнес-логики.
  • Короткий лонг-поллинг помогает вернуть готовый ответ «сразу», если второй запрос пришёл секундой позже.
  • TTL реализуется полем expires_at: удаляйте просроченные ключи джобой раз в час.

Очистка по TTL (cron/джоб):

DELETE FROM idempotency_keys WHERE expires_at < now();

Быстрый вариант с Redis: когда уместен

Если нужен сверхбыстрый доступ и хватает краткоживущих ключей (минуты-часы), можно использовать Redis с атомарной командой SET NX PX — она вставит ключ, если его ещё нет, и задаст TTL:

# Пример команды: установить ключ, если не существует, с TTL=2 дня
SET idem:{account_id}:{key} {request_hash} NX PX 172800000

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

Длинные операции и ожидание результата

Если операция может идти минуты (например, платёж у провайдера):

  • Сразу верните 202 Accepted и id операции, а ключ свяжите с этим id.
  • Клиент опрашивает статус отдельным GET /orders/{id}; повторные POST с тем же ключом возвращают 202 и тот же id.
  • Храните финальный ответ и возвращайте его, когда статус станет готов.

Метрики и алерты для контроля

  • Доля повторов по ключу (repeat_rate) — сколько запросов вернулось из кэша.
  • Конфликты ключей (409) — сигнал о багах клиента или злоупотреблениях.
  • Среднее время ожидания «processing» и доля ответов 202.
  • Наполнение и рост таблицы ключей, эффективность очистки по TTL.

Алерты:

  • Резкий рост 409 — проверяйте клиента и интеграции.
  • Утечки «processing» дольше N минут — возможны зависания бизнес-логики.

Частые ошибки и как их избежать

  • Глобальная область ключа. Делайте область в рамках аккаунта/пользователя.
  • Игнорирование хеша запроса. Нужна проверка «тот же смысл», иначе ключ можно «украсть» для другого тела.
  • Хранение только флага, без ответа. Тогда придётся повторно вызывать внешние системы при повторе — теряется смысл.
  • Нулевой TTL. Минимум 24–48 часов, чтобы покрыть ретраи в реальной жизни.
  • Идемпотентность только на «нашем» шаге, а у провайдера — нет. Для критичных операций используйте и там ключ (многие платёжные API поддерживают idempotency-key).

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

  • Выбраны операции, требующие идемпотентности (все «создания» с побочными эффектами).
  • Определён формат ключа (UUIDv4) и область действия (account_id + key).
  • Реализована таблица в PostgreSQL с уникальным ключом и TTL.
  • Сохранение: статус, хеш запроса, полный ответ (код/заголовки/тело).
  • Обработаны конкурентные запросы: вставка DO NOTHING, короткое ожидание, 202.
  • Возврат 409 при несовпадении хеша с тем же ключом.
  • Очистка протухших ключей джобой.
  • Метрики, алерты и дашборд.

Итог для бизнеса: дубликаты заказов и двойные списания исчезают, саппорт получает меньше обращений, финансы — меньше возвратов, а платёжная конверсия растёт за счёт безопасных ретраев клиента. И всё это — с парой таблиц, небольшим куском кода и понятным контрактом в API.


postgresqlидемпотентностьapi