Идемпотентность — это свойство операции, при котором повтор не меняет результат: выполнили её один раз или десять, состояние системы одинаковое. Для API это значит, что повторный запрос не создаст второй заказ и не спишет деньги дважды.
Из методов HTTP идемпотентны GET, HEAD, OPTIONS, TRACE, PUT и DELETE, а POST и PATCH нет. Чтобы безопасно повторять POST, сервер принимает ключ идемпотентности: уникальную строку в заголовке, по которой он узнаёт повтор. Ниже: какие методы идемпотентны по стандарту, как устроен ключ в платёжных и облачных API, рабочий пример на Python и ошибки, из-за которых защита от дублей не срабатывает.
Что такое идемпотентность простыми словами

Слово пришло из математики. Операция идемпотентна, если применить её к результату ещё раз и ничего не изменится: модуль числа от модуля даёт то же число, объединение множества с самим собой даёт то же множество.
Бытовой пример: кнопка вызова лифта. Нажали один раз, лифт едет. Нажали ещё пять раз от нетерпения, лифт всё равно приедет один. А вот кнопка «добавить в корзину» без защиты от повторов при пяти нажатиях положит пять товаров.
То же самое в коде на Python:
x = -7
print(abs(x), abs(abs(x)))
s = {"python", "sql"}
print(s | s == s)
print(max(5, max(5, 3)) == max(5, 3))
counter = 0
def add_one():
global counter
counter += 1
return counter
add_one(); add_one()
print(counter)
7 7
True
True
2
Первые три операции идемпотентны. Последняя нет: каждый вызов add_one() меняет счётчик, и после двух вызовов в нём 2, а не 1.
Важная оговорка. Идемпотентность не означает, что ответ всегда одинаковый. Она про эффект: что изменилось на сервере после запроса. Ответ на повтор может отличаться, и это нормально.
Идемпотентные методы HTTP
Какие методы HTTP идемпотентны, определяет стандарт RFC 9110, раздел 9.2.2. Метод считается идемпотентным, если несколько одинаковых запросов дают на сервере тот же задуманный эффект, что и один. Из методов этого стандарта такими названы PUT, DELETE и все безопасные методы.
Безопасными называют методы, которые по смыслу только читают данные: GET, HEAD, OPTIONS и TRACE. Любой безопасный метод идемпотентен, но не наоборот.
| Метод | Что делает | Безопасный | Идемпотентный |
|---|---|---|---|
| GET | получить ресурс | да | да |
| HEAD | получить только заголовки | да | да |
| OPTIONS | узнать, что разрешено | да | да |
| PUT | заменить ресурс целиком | нет | да |
| DELETE | удалить ресурс | нет | да |
| POST | создать ресурс или запустить действие | нет | нет |
| PATCH | изменить часть ресурса | нет | нет |
PATCH описан в отдельном стандарте, RFC 5789. Там сказано, что PATCH не является ни безопасным, ни идемпотентным, но запрос можно составить так, чтобы он стал идемпотентным. Например, «поставь статус paid» идемпотентен, а «прибавь к балансу 500» нет.
Почему DELETE идемпотентен, если второй раз отвечает 404
Первый DELETE /orders/42 удаляет заказ и отвечает 204. Второй находит пустоту и отвечает 404. Ответы разные, но эффект один: заказа 42 нет.
Стандарт прямо допускает такое: повтор даёт тот же задуманный эффект, «even if the original request succeeded, though the response might differ». Поэтому 404 на повторный DELETE клиенту стоит считать успехом, а не ошибкой.
Канал основателя Checkroi Вани Буявца3 700 человек читают мой Телеграм-канал про нейросетиСобрал промпты для Claude Code и ChatGPT, разборы ИИ-инструментов и лайфхаки по продвижению бизнеса в одном местеПрисоединитьсяЗачем это нужно: повторы при сбоях сети
Самая неприятная ситуация в сетевом обмене выглядит так. Клиент отправил запрос, сервер его выполнил, а ответ потерялся: оборвалось соединение, сработал таймаут, мобильный интернет пропал в лифте. Клиент не знает, прошла ли операция.
Дальше у него два плохих варианта:
- не повторять и, возможно, потерять заказ или платёж;
- повторить и, возможно, создать дубль.
Идемпотентность убирает этот выбор: повтор становится безопасным. Поэтому RFC 9110 не рекомендует клиенту (SHOULD NOT) автоматически повторять неидемпотентный запрос, если нет способа понять, что повтор ничего не испортит. Прокси повторять такие запросы запрещено (MUST NOT).
В документации Yandex Cloud разобран пример с гонкой. Клиент остановил виртуальную машину, но ответа не получил. Тем временем другой пользователь её снова запустил. Клиент восстанавливает связь, повторяет запрос без ключа, и машина, нужная второму пользователю, останавливается ещё раз.
Бывает, что повтор провоцирует сам сервер. ЮKassa предупреждает, что код 500 не означает ни успеха, ни провала операции. Результат нужно сначала узнать и только потом решать, что делать с платежом.
Ключ идемпотентности: как он работает

POST по определению неидемпотентен, а создавать заказы и принимать платежи на сайте как-то надо. Для этого придумали ключ идемпотентности. Клиент генерирует уникальную строку для каждой операции и передаёт её в заголовке. Сервер запоминает ключ вместе с результатом.
Если пришёл повтор с тем же ключом, сервер не выполняет операцию заново, а возвращает сохранённый ответ. Так идемпотентным становится любой метод, включая POST. Механизм не зависит от стиля API: ключ одинаково работает и в REST, и в командах вроде запуска виртуальной машины в Yandex Cloud.
POST /v3/payments HTTP/1.1
Host: api.example.ru
Content-Type: application/json
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
{"amount": 4900, "currency": "RUB"}
Единого стандарта на этот заголовок пока нет. Его описывает черновик IETF «The Idempotency-Key HTTP Header Field»: седьмая версия вышла 15 октября 2025 года, срок черновика истёк 18 апреля 2026 года, и RFC он так и не стал. Поэтому имя заголовка, формат ключа и срок хранения у каждого API свои.
| API | Заголовок | Формат ключа | Сколько хранится |
|---|---|---|---|
| ЮKassa | Idempotence-Key |
любое уникальное значение до 64 символов, советуют UUID версии 4 | 24 часа |
| Yandex Cloud | Idempotency-Key |
UUID, рекомендуется версии 4 | в документации не указано |
| Stripe | Idempotency-Key |
до 255 символов, советуют UUID версии 4 | в API v1 ключ могут удалить автоматически, когда ему исполнится 24 часа |
| черновик IETF | Idempotency-Key |
рекомендует UUID или похожую случайную строку | политику срока задаёт сам сервис |
Обратите внимание на разницу в одну букву: у ЮKassa заголовок Idempotence-Key, а не Idempotency-Key. Перепутать легко, и ключ просто не дойдёт до проверки. Stripe попал в таблицу как образец подробной документации: в списке стран, где он принимает платежи, России нет.
Что сервер делает с ключом
- Получает запрос и ищет ключ в хранилище.
- Ключа нет: сохраняет его вместе с отпечатком тела запроса (хешем) и выполняет операцию.
- После выполнения записывает рядом код и тело ответа.
- Ключ есть и ответ готов: отдаёт сохранённый ответ, ничего не выполняя.
- Ключ есть, а тело запроса другое: отказывает с кодом
422. - Ключ есть, но первый запрос ещё выполняется: отказывает с кодом
409.
Коды 422 и 409 для этих случаев предлагает тот же документ IETF. Если операция требует ключа, а его не прислали, он рекомендует ответить 400 со ссылкой на документацию.
Эти правила стоит описать в спецификации API заранее, до первой строки кода. Обычно это работа системного аналитика: он договаривается с бэкендом и интеграторами, какой заголовок, сколько живёт ключ и что возвращать на повтор.
Как реализовать идемпотентность: пример на Python

Соберём минимальный сервер заказов на стандартной библиотеке Python, без фреймворков. Ключи и заказы храним в SQLite, ответы отдаём в JSON. Код проверен на Python 3.14.7.
import hashlib
import json
import sqlite3
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
db = sqlite3.connect("orders.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, item TEXT, amount INTEGER)")
db.execute("""CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
body_hash TEXT NOT NULL,
status INTEGER,
response TEXT
)""")
db.commit()
lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
def send(self, status, payload):
data = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(data)
def do_POST(self):
raw = self.rfile.read(int(self.headers["Content-Length"]))
key = self.headers.get("Idempotency-Key")
if not key:
self.send(400, {"error": "нужен заголовок Idempotency-Key"})
return
body_hash = hashlib.sha256(raw).hexdigest()
with lock:
row = db.execute(
"SELECT body_hash, status, response FROM idempotency_keys WHERE key = ?",
(key,),
).fetchone()
if row:
saved_hash, status, response = row
if saved_hash != body_hash:
self.send(422, {"error": "ключ уже использован с другим телом запроса"})
elif status is None:
self.send(409, {"error": "запрос с этим ключом ещё выполняется"})
else:
self.send(status, json.loads(response))
return
db.execute(
"INSERT INTO idempotency_keys (key, body_hash) VALUES (?, ?)",
(key, body_hash),
)
db.commit()
order = json.loads(raw)
cur = db.execute(
"INSERT INTO orders (item, amount) VALUES (?, ?)",
(order["item"], order["amount"]),
)
result = {"order_id": cur.lastrowid, "item": order["item"]}
db.execute(
"UPDATE idempotency_keys SET status = ?, response = ? WHERE key = ?",
(201, json.dumps(result, ensure_ascii=False), key),
)
db.commit()
self.send(201, result)
def log_message(self, *args):
pass
ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever()
Запускаем сервер и шлём запросы через curl. Сначала дважды один и тот же заказ с одним ключом, потом тот же ключ с другой суммой, потом запрос без ключа:
K=8e03978e-40d5-43e8-bc93-6894a57f9324
curl -s -X POST http://127.0.0.1:8000/orders -H "Idempotency-Key: $K" -d '{"item": "курс по SQL", "amount": 4900}'
curl -s -X POST http://127.0.0.1:8000/orders -H "Idempotency-Key: $K" -d '{"item": "курс по SQL", "amount": 4900}'
curl -s -X POST http://127.0.0.1:8000/orders -H "Idempotency-Key: $K" -d '{"item": "курс по SQL", "amount": 9800}'
curl -s -X POST http://127.0.0.1:8000/orders -d '{"item": "курс по SQL", "amount": 4900}'
{"order_id": 1, "item": "курс по SQL"} HTTP 201
{"order_id": 1, "item": "курс по SQL"} HTTP 201
{"error": "ключ уже использован с другим телом запроса"} HTTP 422
{"error": "нужен заголовок Idempotency-Key"} HTTP 400
Код ответа дописан в вывод флагом -w ' HTTP %{http_code}\n'. Повтор вернул тот же order_id: 1, и в таблице orders после четырёх запросов ровно одна строка: 1|курс по SQL|4900.
Ключ записывается в хранилище до выполнения операции, под блокировкой. Именно это закрывает гонку, когда два одинаковых запроса приходят одновременно: второй увидит запись без готового ответа и получит 409. В продакшене вместо threading.Lock обычно опираются на уникальный индекс в базе или на блокировку в Redis, потому что сервер работает в нескольких процессах.
Идемпотентность без ключа: на уровне SQL
Иногда ключ не нужен, достаточно правильно сформулировать операцию. Сравните прибавку и присвоение в SQL:
CREATE TABLE balance (user_id INTEGER PRIMARY KEY, amount INTEGER);
INSERT INTO balance VALUES (1, 1000);
-- не идемпотентно: каждый повтор добавляет ещё 500
UPDATE balance SET amount = amount + 500 WHERE user_id = 1;
UPDATE balance SET amount = amount + 500 WHERE user_id = 1;
SELECT 'прибавка', amount FROM balance;
-- идемпотентно: сколько ни повторяй, итог один
UPDATE balance SET amount = 1500 WHERE user_id = 1;
UPDATE balance SET amount = 1500 WHERE user_id = 1;
SELECT 'присвоение', amount FROM balance;
CREATE TABLE payments (external_id TEXT PRIMARY KEY, amount INTEGER);
INSERT INTO payments VALUES ('pay-2026-10-18-001', 4900) ON CONFLICT (external_id) DO NOTHING;
INSERT INTO payments VALUES ('pay-2026-10-18-001', 4900) ON CONFLICT (external_id) DO NOTHING;
SELECT 'платежей', COUNT(*) FROM payments;
прибавка|2000
присвоение|1500
платежей|1
Проверено в SQLite 3.51. Два повтора прибавки дали 2000 вместо 1500. Присвоение и вставка с ON CONFLICT DO NOTHING по внешнему идентификатору платежа выдержали повтор без последствий. Тот же синтаксис ON CONFLICT есть в PostgreSQL.
Частые ошибки
Ключ идемпотентности легко внедрить так, что он ничего не защищает. Вот что встречается чаще всего.
- Новый ключ на каждый повтор. Если клиент генерирует UUID внутри функции отправки, то каждая попытка получает свой ключ, и сервер видит три разные операции. Ключ создают один раз на операцию (например, при оформлении заказа) и переиспользуют во всех повторах.
- Ключ сохраняют после операции. Сервер сначала списал деньги, потом записал ключ, и между этими шагами процесс упал. Повтор пройдёт как новый. Надёжнее записывать ключ и выполнять операцию в одной транзакции или ставить ключ заранее со статусом «выполняется», как в примере выше.
- Не сверяют тело запроса. Без отпечатка тела повтор с тем же ключом, но другой суммой, вернёт старый ответ, и клиент решит, что новый платёж прошёл. Правильный ответ здесь — ошибка, как
422в примере выше. - PUT, который на деле прибавляет. Метод называется PUT, а внутри
amount = amount + 500. А клиент вправе повторить PUT сам, без участия человека: стандарт HTTP приводит именно этот пример. Каждый такой повтор увеличивает баланс. - Забывают про срок хранения. ЮKassa хранит ключ 24 часа, после этого повтор обработают как новый запрос. Если очередь повторов на вашей стороне живёт дольше, дубли вернутся.
- Чувствительные данные в ключе. Stripe советует не брать в качестве ключа email и другие персональные данные. Случайный UUID и надёжнее, и безопаснее.
Как проверить, что API идемпотентен
Про идемпотентность часто спрашивают на собеседовании тестировщика, и её обязательно проверяют в тестах API. Минимальный набор проверок:
- Отправить один и тот же запрос дважды и сравнить состояние данных, а не только ответы.
- Повторить запрос с тем же ключом и изменённым телом. Ожидается ошибка, старый ответ здесь означает дефект.
- Отправить два одинаковых запроса параллельно: операция должна выполниться один раз.
- Повторить DELETE уже удалённого ресурса и убедиться, что клиент считает 404 успехом.
- Повторить запрос после истечения срока ключа и сверить поведение с документацией.
Все пять сценариев легко автоматизировать: это два-три запроса и проверка таблицы в базе.
Где освоить работу с API системно
Без идемпотентности не обходится ни платёжная интеграция, ни бэкенд-разработка в целом. Рядом с ней стоят коды ответов, авторизация, повторы с паузой, очереди и транзакции.
Собрать это в одну картину помогают курсы, где API проектируют и тестируют на практике. Сравнить программы можно в каталоге курсов по работе с API, а путь в профессию целиком описан в статье «Как стать backend-разработчиком».
| Курс | Школа | Стоимость со скидкой | В рассрочку | Длительность | Обзор курса от Checkroi |
|---|---|---|---|---|---|
| Node.js. Профессиональная разработка REST API Перейти на сайт курса | 29 900 ₽ | 9960 ₽/мес. | 2 месяца | Обзор курса | |
| Профессия «Python-разработчик» Перейти на сайт курса | 157 335 ₽ | 5987 ₽/мес. | 10 месяцев | Обзор курса | |
| Веб-вёрстка Перейти на сайт курса | 145 881 ₽ | 2818 ₽/мес. | 3 месяца | Обзор курса | |
| Профессия «Аналитик данных с нуля до middle» Перейти на сайт курса | 145 600 ₽ | 6066 ₽/мес. | 12 месяцев | Обзор курса | |
| Fullstack-разработчик на Python Перейти на сайт курса | 137 011 ₽ | 7125 ₽/мес. | 21 месяц | Обзор курса |
Больше программ — в полном каталоге курсов по API




