Идемпотентность API: что это и как реализовать

← Вернуться к статьям

Введение

Пользователь нажал «Оплатить» и ничего не произошло. Нажал ещё раз. С карты списали дважды. Знакомая ситуация? Именно для предотвращения таких проблем существует идемпотентность.

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

📋 Содержание

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

Идемпотентность (от лат. idem — «тот же» + potens — «способный») — свойство операции, при котором многократное выполнение даёт тот же результат, что и однократное.

Формально

Операция f идемпотентна, если f(f(x)) = f(x).

То есть повторный вызов не меняет результат первого.

Аналогии из реальной жизни:

В контексте API:

# Идемпотентный запрос: PUT заменяет ресурс целиком
PUT /users/42
{"name": "Иван", "email": "ivan@test.ru"}

# Вызвали 1 раз — пользователь обновлён
# Вызвали 5 раз — результат тот же, пользователь не «обновлён 5 раз»

# НЕ идемпотентный запрос: POST создаёт ресурс
POST /orders
{"product_id": 1, "quantity": 2}

# Вызвали 1 раз — создан 1 заказ
# Вызвали 5 раз — создано 5 заказов!

Идемпотентность HTTP-методов

Спецификация HTTP (RFC 7231) чётко определяет, какие методы должны быть идемпотентными:

Метод Идемпотентный Безопасный Пояснение
GET ✓ Да ✓ Да Только чтение, ничего не меняет
HEAD ✓ Да ✓ Да Как GET, но без тела ответа
OPTIONS ✓ Да ✓ Да Запрос допустимых методов
PUT ✓ Да ✗ Нет Полная замена ресурса — повтор не меняет результат
DELETE ✓ Да ✗ Нет Удаление — повторный вызов возвращает 404, ресурс не удаляется дважды
POST ✗ Нет ✗ Нет Создание ресурса — каждый вызов может создать новый
PATCH ✗ Нет* ✗ Нет Частичное обновление — зависит от реализации

⚠️ PATCH — неоднозначный метод

PATCH может быть идемпотентным, если вы устанавливаете конкретное значение: {"status": "active"}. Но он не идемпотентен при инкрементальных изменениях: {"balance": "+100"} — каждый вызов добавляет 100.

Безопасный ≠ Идемпотентный

Важно не путать эти понятия:

DELETE — идемпотентный, но не безопасный: он меняет состояние (удаляет ресурс), но повторное удаление не меняет результат.

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

В реальной сети запросы теряются, дублируются и повторяются:

Проблема

Сетевой таймаут

Клиент отправил POST-запрос на создание заказа. Сервер получил и обработал запрос, но ответ потерялся (таймаут сети). Клиент не получил ответ и повторил запрос. Результат: два заказа вместо одного.

Клиент: POST /orders
Сервер: создал заказ #1
Ответ потерялся ✗
Клиент: POST /orders (retry)
Сервер: создал заказ #2!
Проблема

Двойной клик

Пользователь нажал «Оплатить» дважды (интерфейс не заблокировал кнопку). Два POST-запроса ушли параллельно. Результат: двойное списание.

Проблема

Retry в клиентских библиотеках

Axios, Guzzle, requests — многие HTTP-клиенты автоматически повторяют запрос при ошибке сети. Без идемпотентности каждый retry создаёт дубль.

Решение

С идемпотентностью

Клиент передаёт Idempotency-Key. Сервер проверяет: «этот ключ уже был?» Если да — возвращает сохранённый ответ. Дубль невозможен.

Клиент: POST + Key:abc
Сервер: создал заказ #1
Ответ потерялся ✗
Клиент: POST + Key:abc (retry)
Сервер: ключ найден → вернул заказ #1

Idempotency-Key: стандарт де-факто

Idempotency-Key — HTTP-заголовок, в котором клиент передаёт уникальный идентификатор запроса. Сервер использует его для дедупликации.

Как работает

  1. Клиент генерирует UUID до отправки запроса
  2. Передаёт его в заголовке Idempotency-Key: <uuid>
  3. Сервер проверяет: есть ли ключ в хранилище?
  4. Если нет — выполняет операцию, сохраняет результат с ключом
  5. Если да — возвращает сохранённый результат без выполнения операции
# Первый запрос
POST /api/payments HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{"amount": 1500, "currency": "RUB", "card_token": "tok_visa"}

# Ответ (201 Created)
{
  "id": "pay_abc123",
  "amount": 1500,
  "status": "succeeded"
}

# Повторный запрос с тем же ключом
POST /api/payments HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
...

# Ответ (200 OK) — тот же результат, без повторного списания
{
  "id": "pay_abc123",
  "amount": 1500,
  "status": "succeeded"
}

Кто использует Idempotency-Key:

Реализация на сервере

Node.js (Express + Redis)

const express = require('express');
const Redis = require('ioredis');
const { v4: uuidv4 } = require('uuid');

const app = express();
const redis = new Redis();

const IDEMPOTENCY_TTL = 86400; // 24 часа

async function idempotencyMiddleware(req, res, next) {
  if (req.method !== 'POST') return next();

  const key = req.headers['idempotency-key'];
  if (!key) return next();

  const cacheKey = `idempotency:${key}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    const { statusCode, body } = JSON.parse(cached);
    res.status(statusCode).json(body);
    return;
  }

  // Перехватываем ответ для кэширования
  const originalJson = res.json.bind(res);
  res.json = async (body) => {
    await redis.setex(cacheKey, IDEMPOTENCY_TTL, JSON.stringify({
      statusCode: res.statusCode,
      body
    }));
    return originalJson(body);
  };

  next();
}

app.use(express.json());
app.use(idempotencyMiddleware);

app.post('/api/payments', async (req, res) => {
  const { amount, currency } = req.body;

  // Бизнес-логика: создание платежа
  const payment = {
    id: `pay_${uuidv4().slice(0, 8)}`,
    amount,
    currency,
    status: 'succeeded',
    created_at: new Date().toISOString()
  };

  res.status(201).json(payment);
});

Python (FastAPI + Redis)

import uuid
import json
from fastapi import FastAPI, Request, Response, HTTPException
from fastapi.responses import JSONResponse
import redis.asyncio as redis

app = FastAPI()
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)

IDEMPOTENCY_TTL = 86400  # 24 часа

@app.middleware("http")
async def idempotency_middleware(request: Request, call_next):
    if request.method != "POST":
        return await call_next(request)

    idem_key = request.headers.get("idempotency-key")
    if not idem_key:
        return await call_next(request)

    cache_key = f"idempotency:{idem_key}"

    # Проверяем, был ли запрос обработан
    cached = await redis_client.get(cache_key)
    if cached:
        data = json.loads(cached)
        return JSONResponse(
            status_code=data["status_code"],
            content=data["body"]
        )

    # Выполняем запрос
    response = await call_next(request)

    # Кэшируем результат
    body = b""
    async for chunk in response.body_iterator:
        body += chunk

    await redis_client.setex(cache_key, IDEMPOTENCY_TTL, json.dumps({
        "status_code": response.status_code,
        "body": json.loads(body)
    }))

    return Response(
        content=body,
        status_code=response.status_code,
        headers=dict(response.headers),
        media_type=response.media_type
    )


@app.post("/api/payments")
async def create_payment(request: Request):
    data = await request.json()
    payment = {
        "id": f"pay_{uuid.uuid4().hex[:8]}",
        "amount": data["amount"],
        "currency": data["currency"],
        "status": "succeeded"
    }
    return JSONResponse(status_code=201, content=payment)

PHP (Laravel middleware)

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

class IdempotencyMiddleware
{
    private const TTL = 86400; // 24 часа

    public function handle(Request $request, Closure $next)
    {
        if ($request->method() !== 'POST') {
            return $next($request);
        }

        $idempotencyKey = $request->header('Idempotency-Key');
        if (!$idempotencyKey) {
            return $next($request);
        }

        $cacheKey = "idempotency:{$idempotencyKey}";

        // Проверяем кэш
        $cached = Redis::get($cacheKey);
        if ($cached) {
            $data = json_decode($cached, true);
            return response()->json(
                $data['body'],
                $data['status_code']
            );
        }

        // Выполняем запрос
        $response = $next($request);

        // Кэшируем результат
        Redis::setex($cacheKey, self::TTL, json_encode([
            'status_code' => $response->getStatusCode(),
            'body' => json_decode($response->getContent(), true)
        ]));

        return $response;
    }
}

// Регистрация в app/Http/Kernel.php:
// 'idempotent' => \App\Http\Middleware\IdempotencyMiddleware::class,
//
// Использование в роутах:
// Route::post('/payments', [PaymentController::class, 'store'])
//     ->middleware('idempotent');

Хранилище ключей

Где хранить идемпотентные ключи и результаты? Зависит от требований:

Хранилище Скорость Надёжность Когда использовать
Redis Очень высокая Средняя (данные в RAM) Большинство случаев. TTL из коробки.
PostgreSQL / MySQL Средняя Высокая Критичные данные (платежи). Транзакции + UNIQUE.
DynamoDB / Memcached Высокая Высокая / Низкая Масштабируемые облачные проекты.
In-memory (Map) Максимальная Нулевая Только для тестирования и прототипов.

⚠️ Для платежей: Redis + БД

Redis для быстрой дедупликации, БД (с UNIQUE-индексом на idempotency_key) для гарантии. Если Redis упал — БД защитит от дубля.

Пример: PostgreSQL с UNIQUE

CREATE TABLE idempotency_keys (
    key         VARCHAR(255) PRIMARY KEY,
    endpoint    VARCHAR(255) NOT NULL,
    status_code INTEGER NOT NULL,
    response    JSONB NOT NULL,
    created_at  TIMESTAMP DEFAULT NOW()
);

-- Автоочистка старых ключей (cron или pg_cron)
DELETE FROM idempotency_keys
WHERE created_at < NOW() - INTERVAL '24 hours';
-- Атомарная вставка с проверкой
INSERT INTO idempotency_keys (key, endpoint, status_code, response)
VALUES ('550e8400-e29b-41d4-a716-446655440000', '/api/payments', 201,
        '{"id": "pay_abc123", "status": "succeeded"}')
ON CONFLICT (key) DO NOTHING
RETURNING *;

-- Если RETURNING пустой — ключ уже существует, берём сохранённый ответ
SELECT status_code, response FROM idempotency_keys
WHERE key = '550e8400-e29b-41d4-a716-446655440000';

Реальные кейсы

Кейс 1

Платёжный шлюз: двойное списание

Проблема: мобильное приложение отправляет POST /payments. Сеть нестабильна, клиент повторяет запрос 3 раза. Создаются 3 платежа.

Решение:

// Клиент генерирует ключ ДО отправки
const idempotencyKey = crypto.randomUUID();

// Все retry используют тот же ключ
async function createPayment(data, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const res = await fetch('/api/payments', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey  // один ключ для всех попыток
        },
        body: JSON.stringify(data)
      });
      return await res.json();
    } catch (err) {
      if (i === retries - 1) throw err;
      await new Promise(r => setTimeout(r, 1000 * (i + 1)));
    }
  }
}
Кейс 2

E-commerce: дубли заказов

Проблема: пользователь нажал «Заказать» и быстро обновил страницу. Браузер переотправил POST-запрос. Создались 2 идентичных заказа.

Решение:

Кейс 3

Уведомления: повторная отправка email

Проблема: сервис уведомлений обрабатывает очередь. Сообщение обработано, но ACK потерялся. Брокер повторяет доставку. Пользователь получает 2 письма.

Решение:

import hashlib

def process_notification(message):
    # Вычисляем уникальный ключ из содержимого
    idem_key = hashlib.sha256(
        f"{message['user_id']}:{message['template']}:{message['data']}".encode()
    ).hexdigest()

    # Проверяем в Redis
    if redis.exists(f"notification:{idem_key}"):
        return  # уже отправлено

    send_email(message)
    redis.setex(f"notification:{idem_key}", 86400, "sent")

Клиентская сторона

Идемпотентность требует участия и клиента, и сервера:

Правила для клиента

  1. Генерируйте ключ до запроса — не после и не на сервере
  2. Один ключ = одна логическая операция — все retry используют тот же ключ
  3. Новая операция = новый ключ — не переиспользуйте ключи для разных запросов
  4. Используйте UUID v4 — криптографически случайный, без коллизий
// Клиент-обёртка с автоматическим Idempotency-Key
class ApiClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  async post(path, data, options = {}) {
    const idempotencyKey = options.idempotencyKey || crypto.randomUUID();
    const maxRetries = options.retries || 3;

    for (let attempt = 0; attempt < maxRetries; attempt++) {
      try {
        const response = await fetch(`${this.baseURL}${path}`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey
          },
          body: JSON.stringify(data)
        });

        if (response.status === 409) {
          throw new Error('Conflict: request is being processed');
        }

        return await response.json();
      } catch (error) {
        if (attempt === maxRetries - 1) throw error;

        const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }
}

// Использование
const api = new ApiClient('https://api.example.com');
const payment = await api.post('/payments', {
  amount: 1500,
  currency: 'RUB'
});

Подводные камни

1. Один ключ — разные тела запроса

Клиент отправляет Idempotency-Key: abc с {"amount": 100}, затем тот же ключ с {"amount": 200}. Что делать серверу?

Правильно: вернуть 422 Unprocessable Entity с сообщением, что ключ уже использован с другими данными.

{
  "error": "IDEMPOTENCY_KEY_REUSE",
  "message": "This Idempotency-Key was already used with different request parameters"
}

2. Race condition: параллельные запросы с одним ключом

Два одинаковых запроса приходят одновременно. Оба проверяют Redis — ключа нет. Оба выполняют операцию.

Решение: используйте Redis SET NX (set if not exists) для атомарной блокировки:

// Атомарная блокировка: SET key IF NOT EXISTS
const acquired = await redis.set(
  `lock:${idempotencyKey}`,
  'processing',
  'EX', 30,  // истекает через 30 секунд
  'NX'       // только если не существует
);

if (!acquired) {
  // Другой запрос уже обрабатывается — подождите и верните ответ
  return res.status(409).json({ error: 'Request is being processed' });
}

3. Забыли TTL на ключах

Без TTL хранилище ключей растёт бесконечно. Через год — миллионы записей.

Решение: всегда устанавливайте TTL. Обычно 24 часа достаточно. Для платежей — до 72 часов.

4. Кэширование ошибок

Запрос завершился с 500 (временный сбой БД). Вы закэшировали ответ. Теперь клиент с тем же ключом получает 500 навсегда.

Решение: кэшируйте только успешные ответы (2xx). При 5xx — не сохраняйте ключ, позволяя retry.

// Кэшируем только успешные ответы
if (res.statusCode >= 200 && res.statusCode < 300) {
  await redis.setex(cacheKey, TTL, JSON.stringify({
    statusCode: res.statusCode,
    body
  }));
}
// Ошибки 4xx тоже можно кэшировать — клиент сам виноват
// Ошибки 5xx — НЕ кэшируем

5. Idempotency-Key на GET-запросы

GET уже идемпотентен по определению. Добавлять Idempotency-Key на GET — бессмысленно и создаёт путаницу.

Правило: Idempotency-Key нужен только для POST и иногда PATCH.

FAQ

❓ Что такое идемпотентность API простыми словами?

Ответ: Это свойство, при котором повторный вызов одного и того же запроса даёт тот же результат. Как кнопка лифта — нажмите 10 раз, приедет один лифт. В API: повторный PUT обновит ресурс один раз, повторный DELETE не удалит дважды.

❓ Какие HTTP-методы идемпотентны?

Ответ: По спецификации: GET, HEAD, PUT, DELETE, OPTIONS. Не идемпотентны: POST и PATCH (хотя PATCH можно сделать идемпотентным). Подробнее — в руководстве по HTTP-методам.

❓ Что такое Idempotency-Key и зачем он нужен?

Ответ: Уникальный ключ (UUID), который клиент передаёт в заголовке запроса. Сервер использует его для дедупликации: если ключ уже встречался — возвращает сохранённый ответ без повторного выполнения. Это стандарт платёжных API (Stripe, YooKassa, PayPal).

❓ Как защитить платёжный API от дублей транзакций?

Ответ: Комплексный подход: 1) Idempotency-Key в заголовке каждого запроса. 2) Redis для быстрой проверки + UNIQUE-индекс в БД для гарантии. 3) Кэширование только успешных ответов (не 5xx). 4) Атомарная блокировка через SET NX для защиты от параллельных запросов.

Заключение

📝 Ключевые выводы

  1. Идемпотентность — повторный вызов не меняет результат первого. Критически важно для надёжного API.
  2. GET, PUT, DELETE — идемпотентны по спецификации. POST — нет, и это главная проблема.
  3. Idempotency-Key — стандартный заголовок для дедупликации POST-запросов. Клиент генерирует UUID, сервер кэширует результат.
  4. Redis + БД — оптимальное хранилище для ключей. Redis для скорости, БД для надёжности.
  5. Не кэшируйте 5xx — только успешные ответы. Иначе временный сбой станет постоянным.
  6. Защита от race condition — используйте атомарную блокировку (SET NX) для параллельных запросов.

Идемпотентность — это не «nice to have», а обязательное свойство любого API, который обрабатывает деньги, заказы или важные данные. Реализация занимает несколько часов, а спасает от дней отладки дублей в продакшене.

🎯 Создавайте надёжные API

LightBox API помогает проектировать и тестировать API с поддержкой идемпотентности, retry-логики и корректной обработки ошибок.

Попробовать бесплатно →

Статья опубликована: 23 февраля 2026
Автор: LightBox API Team