Введение
Пользователь нажал «Оплатить» и ничего не произошло. Нажал ещё раз. С карты списали дважды. Знакомая ситуация? Именно для предотвращения таких проблем существует идемпотентность.
Идемпотентность — одно из самых важных свойств надёжного API. Без неё каждый сетевой сбой, каждый повторный запрос — потенциальный дубль данных, двойной платёж или повреждённое состояние. В этой статье разберём:
- Что такое идемпотентность простыми словами
- Какие HTTP-методы идемпотентны по спецификации
- Что такое
Idempotency-Keyи как он работает - Как реализовать идемпотентность на сервере (Node.js, Python, PHP)
- Реальные кейсы: платежи, заказы, отправка email
📋 Содержание
Что такое идемпотентность
Идемпотентность (от лат. idem — «тот же» + potens — «способный») — свойство операции, при котором многократное выполнение даёт тот же результат, что и однократное.
Формально
Операция f идемпотентна, если f(f(x)) = f(x).
То есть повторный вызов не меняет результат первого.
Аналогии из реальной жизни:
- Выключатель света «Выкл» — нажимайте сколько угодно, свет останется выключенным. Идемпотентно.
- Лифт: нажатие кнопки этажа — 10 нажатий не вызовут 10 лифтов. Идемпотентно.
- Снятие денег с банкомата — каждое действие списывает деньги. НЕ идемпотентно.
В контексте 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, но без тела ответа |
| ✓ Да | ✓ Да | Запрос допустимых методов | |
| PUT | ✓ Да | ✗ Нет | Полная замена ресурса — повтор не меняет результат |
| DELETE | ✓ Да | ✗ Нет | Удаление — повторный вызов возвращает 404, ресурс не удаляется дважды |
| POST | ✗ Нет | ✗ Нет | Создание ресурса — каждый вызов может создать новый |
| PATCH | ✗ Нет* | ✗ Нет | Частичное обновление — зависит от реализации |
⚠️ PATCH — неоднозначный метод
PATCH может быть идемпотентным, если вы устанавливаете конкретное значение: {"status": "active"}. Но он не идемпотентен при инкрементальных изменениях: {"balance": "+100"} — каждый вызов добавляет 100.
Безопасный ≠ Идемпотентный
Важно не путать эти понятия:
- Безопасный (safe) — не меняет состояние сервера (GET, HEAD, OPTIONS)
- Идемпотентный — повторный вызов не меняет результат (GET, PUT, DELETE...)
DELETE — идемпотентный, но не безопасный: он меняет состояние (удаляет ресурс), но повторное удаление не меняет результат.
Зачем нужна идемпотентность
В реальной сети запросы теряются, дублируются и повторяются:
Сетевой таймаут
Клиент отправил POST-запрос на создание заказа. Сервер получил и обработал запрос, но ответ потерялся (таймаут сети). Клиент не получил ответ и повторил запрос. Результат: два заказа вместо одного.
Двойной клик
Пользователь нажал «Оплатить» дважды (интерфейс не заблокировал кнопку). Два POST-запроса ушли параллельно. Результат: двойное списание.
Retry в клиентских библиотеках
Axios, Guzzle, requests — многие HTTP-клиенты автоматически повторяют запрос при ошибке сети. Без идемпотентности каждый retry создаёт дубль.
С идемпотентностью
Клиент передаёт Idempotency-Key. Сервер проверяет: «этот ключ уже был?» Если да — возвращает сохранённый ответ. Дубль невозможен.
Idempotency-Key: стандарт де-факто
Idempotency-Key — HTTP-заголовок, в котором клиент передаёт уникальный идентификатор запроса. Сервер использует его для дедупликации.
Как работает
- Клиент генерирует UUID до отправки запроса
- Передаёт его в заголовке
Idempotency-Key: <uuid> - Сервер проверяет: есть ли ключ в хранилище?
- Если нет — выполняет операцию, сохраняет результат с ключом
- Если да — возвращает сохранённый результат без выполнения операции
# Первый запрос
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:
- Stripe — заголовок
Idempotency-Keyдля всех POST-запросов - YooKassa (Яндекс) — заголовок
Idempotence-Key - PayPal — заголовок
PayPal-Request-Id - AWS — параметр
ClientToken/client-request-token - Google Cloud — параметр
requestId
Реализация на сервере
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';
Реальные кейсы
Платёжный шлюз: двойное списание
Проблема: мобильное приложение отправляет 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)));
}
}
}
E-commerce: дубли заказов
Проблема: пользователь нажал «Заказать» и быстро обновил страницу. Браузер переотправил POST-запрос. Создались 2 идентичных заказа.
Решение:
- Фронтенд: блокировка кнопки после первого нажатия
- Бэкенд:
Idempotency-Keyпривязанный к корзине пользователя - БД: UNIQUE-индекс на
(user_id, cart_hash, created_date)
Уведомления: повторная отправка 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")
Клиентская сторона
Идемпотентность требует участия и клиента, и сервера:
Правила для клиента
- Генерируйте ключ до запроса — не после и не на сервере
- Один ключ = одна логическая операция — все retry используют тот же ключ
- Новая операция = новый ключ — не переиспользуйте ключи для разных запросов
- Используйте 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, . Не идемпотентны: POST и PATCH (хотя PATCH можно сделать идемпотентным). Подробнее — в руководстве по HTTP-методам.
❓ Что такое Idempotency-Key и зачем он нужен?
Ответ: Уникальный ключ (UUID), который клиент передаёт в заголовке запроса. Сервер использует его для дедупликации: если ключ уже встречался — возвращает сохранённый ответ без повторного выполнения. Это стандарт платёжных API (Stripe, YooKassa, PayPal).
❓ Как защитить платёжный API от дублей транзакций?
Ответ: Комплексный подход: 1) Idempotency-Key в заголовке каждого запроса. 2) Redis для быстрой проверки + UNIQUE-индекс в БД для гарантии. 3) Кэширование только успешных ответов (не 5xx). 4) Атомарная блокировка через SET NX для защиты от параллельных запросов.
Заключение
📝 Ключевые выводы
- Идемпотентность — повторный вызов не меняет результат первого. Критически важно для надёжного API.
- GET, PUT, DELETE — идемпотентны по спецификации. POST — нет, и это главная проблема.
- Idempotency-Key — стандартный заголовок для дедупликации POST-запросов. Клиент генерирует UUID, сервер кэширует результат.
- Redis + БД — оптимальное хранилище для ключей. Redis для скорости, БД для надёжности.
- Не кэшируйте 5xx — только успешные ответы. Иначе временный сбой станет постоянным.
- Защита от race condition — используйте атомарную блокировку (
SET NX) для параллельных запросов.
Идемпотентность — это не «nice to have», а обязательное свойство любого API, который обрабатывает деньги, заказы или важные данные. Реализация занимает несколько часов, а спасает от дней отладки дублей в продакшене.
🎯 Создавайте надёжные API
LightBox API помогает проектировать и тестировать API с поддержкой идемпотентности, retry-логики и корректной обработки ошибок.
- ✓ Mock API с настраиваемыми ответами
- ✓ Тестирование retry и таймаутов
- ✓ Логирование всех входящих запросов
- ✓ Swagger/OpenAPI документация
- ✓ Бесплатный план для старта
Статья опубликована: 23 февраля 2026
Автор: LightBox API Team