Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

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

Разработка и технологии16 декабря 2025 г.
Повторные запросы происходят постоянно: мобильная сеть «плавает», платежные шлюзы переотправляют вебхуки, фронтенд делает повторное нажатие по кнопке. Если бэкенд не идемпотентен, получаются двойные списания и дубликаты заказов. Разбираем, как внедрить идемпотентность на практике — с примерами кода, схемами БД и метриками для контроля.
Идемпотентность в API и вебхуках: как прекратить двойные списания и экономить на поддержке

Оглавление

  • Зачем бизнесу идемпотентность
  • Где берутся повторы и чем они опасны
  • Базовый паттерн: ключ идемпотентности + уникальный индекс
  • Реализация на PostgreSQL + FastAPI (готовый проект)
    • docker-compose.yml
    • requirements.txt
    • main.py
    • Как запустить
  • Дедупликация вебхуков: безопасная обработка и валидация подписи
  • Redis для краткоживущих ключей: когда нужен и как не ошибиться
  • Как внедрять без остановки: план по шагам
  • Метрики, алерты и операционные практики
  • Частые ошибки и как их избежать
  • Чек‑лист перед релизом

Зачем бизнесу идемпотентность

Идемпотентность — это свойство операции давать один и тот же результат при повторном выполнении. Для бизнеса это:

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

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

Источники повторов:

  • Нестабильная сеть: клиент не получил ответ и отправил запрос снова.
  • Поведение браузеров и приложений: двойной клик, авто‑повтор в SDK.
  • Гейтвеи и балансировщики: ретраи при таймаутах.
  • Платёжные провайдеры и партнёры: вебхуки отправляются до подтверждения получения и часто переотправляются.

Последствия:

  • Двойные списания и дубликаты сущностей (заказов, счетов, подписок).
  • Эскалации от клиентов, штрафы платёжных систем, потери репутации.

Базовый паттерн: ключ идемпотентности + уникальный индекс

Простая и надёжная схема:

  1. Клиент отправляет заголовок Idempotency-Key с уникальным значением на каждое «намерение» (например, оформление платежа). Ключ должен быть стабильным при повторах.
  2. На сервере мы сохраняем ключ и результат выполнения в базе, а в таблице ставим уникальный индекс на поле idempotency_key.
  3. При повторном запросе с тем же ключом возвращаем ранее сохранённый результат.

Важно:

  • Вставка + выполнение побочных эффектов (например, списание) должны быть «атомными» с точки зрения логики: выполнять эффект только если именно этот запрос «выиграл гонку» вставки.
  • При параллельных запросах с одним ключом один из них вставит запись, остальные увидят конфликт уникальности и вернут тот же результат или 202 (в обработке).

Реализация на PostgreSQL + FastAPI (готовый проект)

Ниже — минимальный рабочий проект. Он поднимает PostgreSQL и Redis через Docker, запускает FastAPI‑приложение и демонстрирует идемпотентный платежный эндпоинт.

docker-compose.yml

version: "3.9"

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: idemp_demo
      POSTGRES_USER: demo
      POSTGRES_PASSWORD: demo
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U demo -d idemp_demo"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

requirements.txt

fastapi==0.115.5
uvicorn[standard]==0.32.0
SQLAlchemy==2.0.36
psycopg[binary]==3.2.3
pydantic==2.9.2
python-dotenv==1.0.1
redis==5.1.1

main.py

from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import (
    create_engine, String, Integer, BigInteger, Text, text, event,
)
from sqlalchemy.orm import DeclarativeBase, mapped_column, Session
from sqlalchemy.exc import IntegrityError
from datetime import datetime
import time
import re
import hmac
import hashlib
import os
from redis import Redis

# Конфигурация окружения
DB_DSN = os.getenv("DB_DSN", "postgresql+psycopg://demo:demo@localhost:5432/idemp_demo")
REDIS_DSN = os.getenv("REDIS_DSN", "redis://localhost:6379/0")
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "dev_secret_change_me")

# Настройка БД
engine = create_engine(DB_DSN, pool_pre_ping=True, future=True)

class Base(DeclarativeBase):
    pass

class Payment(Base):
    __tablename__ = "payments"

    id = mapped_column(BigInteger, primary_key=True)
    idempotency_key = mapped_column(String(64), unique=True, nullable=False, index=True)
    amount_cents = mapped_column(Integer, nullable=False)
    currency = mapped_column(String(3), nullable=False)
    customer_id = mapped_column(String(64), nullable=False)
    status = mapped_column(String(16), nullable=False, default="processing")
    external_charge_id = mapped_column(String(64), nullable=True)
    error_message = mapped_column(Text, nullable=True)
    created_at = mapped_column(String(32), nullable=False, default=lambda: datetime.utcnow().isoformat())
    updated_at = mapped_column(String(32), nullable=False, default=lambda: datetime.utcnow().isoformat())

class WebhookEvent(Base):
    __tablename__ = "webhook_events"

    id = mapped_column(BigInteger, primary_key=True)
    provider = mapped_column(String(32), nullable=False)
    event_id = mapped_column(String(128), unique=True, nullable=False, index=True)
    signature = mapped_column(String(128), nullable=False)
    payload = mapped_column(Text, nullable=False)
    processed_at = mapped_column(String(32), nullable=False, default=lambda: datetime.utcnow().isoformat())

# Автообновление updated_at
@event.listens_for(Session, "before_flush")
def receive_before_flush(session, flush_context, instances):
    now = datetime.utcnow().isoformat()
    for obj in session.new.union(session.dirty):
        if hasattr(obj, "updated_at"):
            setattr(obj, "updated_at", now)

# Инициализация схемы
Base.metadata.create_all(engine)

# Настройка Redis (опционально для краткоживущей дедупликации)
redis_client = Redis.from_url(REDIS_DSN, decode_responses=True)

app = FastAPI(title="Idempotency Demo")

# Модели запросов/ответов
class PaymentRequest(BaseModel):
    amount_cents: int = Field(gt=0)
    currency: str = Field(min_length=3, max_length=3)
    customer_id: str = Field(min_length=1, max_length=64)

class PaymentResponse(BaseModel):
    id: int
    status: str
    amount_cents: int
    currency: str
    customer_id: str
    external_charge_id: str | None = None
    error_message: str | None = None

IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")


def validate_idempotency_key(key: str) -> None:
    if not key or not IDEMPOTENCY_KEY_RE.match(key):
        raise HTTPException(status_code=400, detail="Некорректный Idempotency-Key (разрешены буквы, цифры, _ и -; до 64 символов)")


def simulate_external_charge(amount_cents: int, currency: str, customer_id: str) -> tuple[str, str | None]:
    """
    Имитация внешнего списания.
    Возвращает (status, external_charge_id_or_error)
    """
    time.sleep(1.0)  # имитация сети
    # Условная логика: для ровных сумм всё хорошо, для нечетных — ошибка
    if amount_cents % 2 == 0:
        charge_id = f"CHG_{int(time.time())}_{amount_cents}"
        return "succeeded", charge_id
    else:
        return "failed", "Платёж отклонён провайдером"


@app.post("/payments", response_model=PaymentResponse)
def create_payment(payload: PaymentRequest, Idempotency_Key: str = Header(alias="Idempotency-Key")):
    validate_idempotency_key(Idempotency_Key)

    # Краткоживущая защита от шторма дублей (не обязательна, но полезна)
    # Не заменяет БД, а лишь снижает нагрузку при всплесках повторов.
    dedupe_key = f"idemp:payments:{Idempotency_Key}"
    first = redis_client.set(dedupe_key, "1", nx=True, ex=15)

    with Session(engine) as session:
        # Пытаемся вставить запись со статусом processing
        p = Payment(
            idempotency_key=Idempotency_Key,
            amount_cents=payload.amount_cents,
            currency=payload.currency.upper(),
            customer_id=payload.customer_id,
            status="processing",
        )
        try:
            session.add(p)
            session.commit()
            session.refresh(p)
            inserted = True
        except IntegrityError:
            session.rollback()
            inserted = False

        if inserted:
            # Только «победитель гонки» выполняет внешний эффект
            status, info = simulate_external_charge(payload.amount_cents, payload.currency.upper(), payload.customer_id)
            p.status = status
            if status == "succeeded":
                p.external_charge_id = info
                p.error_message = None
            else:
                p.error_message = info
                p.external_charge_id = None
            session.add(p)
            session.commit()
        else:
            # Ключ уже есть — возвращаем сохранённый результат
            p = session.query(Payment).filter_by(idempotency_key=Idempotency_Key).one()
            if p.status == "processing":
                # Идёт обработка в другом воркере/процессе — информируем клиента
                # Клиент может повторить запрос через 1–2 секунды
                return JSONResponse(
                    status_code=202,
                    content={
                        "id": p.id,
                        "status": p.status,
                        "amount_cents": p.amount_cents,
                        "currency": p.currency,
                        "customer_id": p.customer_id,
                        "external_charge_id": p.external_charge_id,
                        "error_message": p.error_message,
                    },
                )

    return PaymentResponse(
        id=p.id,
        status=p.status,
        amount_cents=p.amount_cents,
        currency=p.currency,
        customer_id=p.customer_id,
        external_charge_id=p.external_charge_id,
        error_message=p.error_message,
    )


# Пример вебхука с дедупликацией по event_id + проверка подписи
class WebhookPayload(BaseModel):
    event_id: str
    type: str
    data: dict


def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    mac = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, signature_header)


@app.post("/webhooks/provider")
def webhook_provider(payload: WebhookPayload, X_Signature: str = Header(alias="X-Signature", default="")):
    # Проверяем подпись
    body_bytes = payload.model_dump_json().encode()
    if not verify_signature(body_bytes, X_Signature, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Подпись не прошла проверку")

    # Дедупликация по event_id на уровне БД (уникальный индекс)
    with Session(engine) as session:
        evt = WebhookEvent(
            provider="provider",
            event_id=payload.event_id,
            signature=X_Signature,
            payload=payload.model_dump_json(),
        )
        try:
            session.add(evt)
            session.commit()
            # Здесь — фактическая обработка события, безопасно: мы точно первые
            # Например, подтверждение платежа или обновление статуса заказа
            # ... ваша бизнес‑логика ...
        except IntegrityError:
            session.rollback()
            # Событие уже обработано — возвращаем 200, чтобы провайдер перестал ретраить
            return {"status": "duplicate", "processed": True}

    return {"status": "ok", "processed": True}

Как запустить

# 1) Поднимите инфраструктуру
docker compose up -d

# 2) Установите зависимости и запустите приложение
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\\Scripts\\activate
pip install -r requirements.txt
uvicorn main:app --reload

# 3) Тест идемпотентного платежа
curl -X POST http://127.0.0.1:8000/payments \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: pay_12345' \
  -d '{"amount_cents": 2000, "currency": "RUB", "customer_id": "cust_1"}'

# Повтор запроса с тем же ключом — вернёт тот же результат
curl -X POST http://127.0.0.1:8000/payments \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: pay_12345' \
  -d '{"amount_cents": 2000, "currency": "RUB", "customer_id": "cust_1"}'

# 4) Тест вебхука (с правильной подписью)
BODY='{"event_id":"evt_1","type":"payment.succeeded","data":{"id":"p_1"}}'
SIG=$(python - <<'PY'
import hmac,hashlib,sys
secret=b"dev_secret_change_me"
body=b'{"event_id":"evt_1","type":"payment.succeeded","data":{"id":"p_1"}}'
print(hmac.new(secret, body, hashlib.sha256).hexdigest())
PY
)

curl -X POST http://127.0.0.1:8000/webhooks/provider \
  -H "Content-Type: application/json" \
  -H "X-Signature: $SIG" \
  -d "$BODY"

# Повтор с тем же event_id — будет помечен как duplicate
curl -X POST http://127.0.0.1:8000/webhooks/provider \
  -H "Content-Type: application/json" \
  -H "X-Signature: $SIG" \
  -d "$BODY"

Дедупликация вебхуков: безопасная обработка и валидация подписи

  • Всегда проверяйте подлинность: HMAC по «сырому» телу запроса и секрету провайдера.
  • Дедупликация по event_id с уникальным индексом в БД гарантирует «обработать один раз» даже при параллельных доставках.
  • Возвращайте 200 и при дубликате: иначе провайдер будет ретраить бесконечно.
  • Храните «сырое» тело события: поможет в разборе инцидентов.

SQL‑вариант создания таблиц (если не используете ORM):

CREATE TABLE IF NOT EXISTS payments (
  id BIGSERIAL PRIMARY KEY,
  idempotency_key TEXT NOT NULL UNIQUE,
  amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
  currency TEXT NOT NULL CHECK (char_length(currency) = 3),
  customer_id TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('processing','succeeded','failed')),
  external_charge_id TEXT,
  error_message TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS webhook_events (
  id BIGSERIAL PRIMARY KEY,
  provider TEXT NOT NULL,
  event_id TEXT NOT NULL UNIQUE,
  signature TEXT NOT NULL,
  payload TEXT NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Redis для краткоживущих ключей: когда нужен и как не ошибиться

Redis полезен как «амортизатор» при шквале дублей:

  • Ключи с NX+EX (установить если не существует + срок жизни) снижают нагрузку на БД.
  • TTL 10–30 секунд достаточно, чтобы разнести параллельные запросы.
  • Это дополнительный слой, но не замена БД: решающее «источник истины» — уникальный индекс в базе.

Пример атомарной операции:

# Возвращает True, если мы первые; False если уже есть такой ключ
is_first = redis_client.set("idemp:payments:pay_123", "1", nx=True, ex=15)

Распространённые ошибки с Redis:

  • Использовать только Redis и не фиксировать результат в БД — дубликаты вернутся после перезапуска.
  • Слишком большой TTL — блокировка легитимных повторов после долгой обработки.

Как внедрять без остановки: план по шагам

  1. Карта критичных операций: платежи, создание заказов, создание счетов, изменения лимитов.
  2. Добавьте поле idempotency_key и уникальный индекс в соответствующие таблицы. Сначала — в новые записи, без влияния на старые.
  3. На стороне клиента начните отправлять Idempotency-Key. Временное правило: если ключ не пришёл — генерируйте его на сервере по детерминированной схеме (например, хеш набора ключевых полей), но лучше настоять на отправке ключа клиентом.
  4. Реализуйте логику «insert or fetch» и статус processing для длинных операций.
  5. Для вебхуков — валидация подписи + таблица событий с уникальным event_id.
  6. Включите метрики и алерты (см. ниже), затем постепенно расширяйте покрытие на все чувствительные эндпоинты.

Метрики, алерты и операционные практики

Собирайте:

  • Количество запросов с Idempotency-Key и доля повторов (duplicate_rate). Резкий рост — симптом сетевых проблем или багов клиента.
  • Доля ответов 202 на идемпотентных эндпоинтах и среднее время «processing». Рост — признак деградации внешних интеграций.
  • Количество дубликатов вебхуков по event_id. Всплески — проблемы у провайдера.
  • Ошибки IntegrityError по уникальному индексу, если они не обработаны кодом.

Алерты:

  • duplicate_rate > 5–10% в течение 5 минут.
  • processing > 30 секунд p95.
  • Ошибки подписи вебхуков > 0.5% — возможные атаки или рассинхрон секретов.

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

  • «Сначала делаем внешний вызов, потом записываем в БД». Нельзя: при повторе вы сделаете второй внешний вызов. Сначала вставка/резервирование, потом эффект.
  • Отсутствие статуса processing. Тогда повторный запрос не понимает, ждать ему или нет — пользователь кликает ещё.
  • Слишком «умный» ключ. Ключ должен быть прост: UUID или детерминированный хеш намерения. Не включайте туда персональные данные.
  • Игнорирование подписи вебхуков. Без проверки подписи любая «левая» система может послать вам «успешный платёж».
  • Полагаться только на Redis. Перезапуск — и дубликаты вернулись.

Чек‑лист перед релизом

  • Эндпоинты с риском денежного/штучного дублирования принимают Idempotency-Key.
  • В БД стоит уникальный индекс на idempotency_key (и event_id для вебхуков).
  • Эффекты выполняются только «победителем гонки» после успешной вставки.
  • Есть статус processing и корректный ответ 202.
  • Вебхуки проверяются по подписи, дубликаты фиксируются и отвечают 200.
  • Включены метрики duplicate_rate, processing_latency, webhook_duplicate.
  • Есть алерты на всплески повторов и ошибки подписи.
  • Ключ не содержит персональных данных, длина и формат валидируются.

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


APIидемпотентностьwebhook