Ошибка 422 idempotency_conflict: что значит и как чинить
Повторили запрос с тем же ключом идемпотентности, но с другим телом — и получили 422 вместо ожидаемого повтора ответа. Разбираем, что происходит.
4 мин · · Обновлено · ошибки
Ретрай-логика с идемпотентными ключами обычно работает тихо и незаметно — ровно до момента, когда сервер начинает отвечать 422 на запросы, которые выглядят как обычный повтор. Разбираемся, что это за конфликт и откуда он берётся.
Что такое Idempotency-Key вообще
Заголовок Idempotency-Key защищает от двойного списания при повторной отправке одного и того же запроса — например, если клиент не дождался ответа по таймауту и решил повторить попытку. Если в течение 24 часов приходит запрос с тем же значением ключа, сервер возвращает тот же результат, что и в первый раз, не выполняя запрос заново и не списывая деньги повторно.
curl https://api.zukko.pro/v1/messages \
-H "Authorization: Bearer fetch_ваш_ключ" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-H "Idempotency-Key: req-2026-09-16-a1b2c3" \
-d '{"model":"claude-sonnet-5","max_tokens":256,"messages":[{"role":"user","content":"Привет"}]}'Повторите этот же запрос с тем же значением Idempotency-Key и тем же телом — получите тот же ответ мгновенно, без повторного списания.
Где возникает 422
{"error": {"message": "Idempotency key already used with a different request body", "code": "idempotency_conflict"}}Конфликт возникает, если под одним и тем же значением Idempotency-Key в систему приходит запрос с другим телом. Механизм идемпотентности предполагает, что ключ уникально описывает конкретный запрос — если тело изменилось, а ключ остался прежним, сервер не может понять, какой из двух вариантов правильный, и честно отказывает вместо того, чтобы вернуть неверный кэшированный ответ или молча перезаписать историю.
Частая причина: генерация ключа без учёта содержимого
Самая типичная ошибка — брать ключ идемпотентности из чего-то, что не привязано к содержимому запроса, например из ID сессии пользователя или текущей минуты:
# Плохо: один и тот же ключ на все запросы в рамках сессии
idempotency_key = f"session-{session_id}"
# Каждый новый вопрос пользователя в сессии получит один и тот же ключ,
# и второй запрос с другим содержимым провалится с 422Тот же эффект даёт ключ, заданный в заголовках клиента по умолчанию (например, default_headers при создании клиента OpenAI SDK): он уходит с каждым запросом подряд. Задавайте Idempotency-Key в конкретном вызове (extra_headers в .create()), уникальным на каждый логический запрос.
Правильный подход — включать в основу ключа хеш от содержимого запроса, а не только внешний идентификатор:
import hashlib
import json
def make_idempotency_key(payload):
body_hash = hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()[:16]
return f"req-{body_hash}"
payload = {
"model": "claude-sonnet-5",
"max_tokens": 256,
"messages": [{"role": "user", "content": "Привет"}],
}
key = make_idempotency_key(payload)Так ключ автоматически меняется вместе с содержимым, и настоящий повтор того же запроса совпадёт по ключу, а новый запрос — не совпадёт.
Другая частая причина: изменение тела между ретраями
Иногда ключ генерируется правильно, но между первой попыткой и ретраем в смысловую часть тела незаметно попадает что-то переменное — метка времени в тексте сообщения, случайно пересчитанный max_tokens, порядок сообщений:
# Опасно: timestamp меняется на каждый вызов, даже при том же Idempotency-Key
payload = {
"model": "claude-sonnet-5",
"messages": [
{"role": "user", "content": f"[{datetime.utcnow().isoformat()}] {text}"} # плавает
],
}Поля metadata, user и stream_options шлюз при сравнении не учитывает — их можно менять между попытками без конфликта. А всё, что входит в messages, system, temperature, max_tokens и другие параметры генерации, должно оставаться неизменным между ретраями: служебные метки времени держите вне этих полей, а ключ идемпотентности генерируйте на основе смысловой части (модель, сообщения, параметры генерации).
Как чинить на практике
Три конкретных шага:
Проверьте генератор ключей. Ключ должен зависеть от содержимого запроса, который реально может измениться, и не должен зависеть от того, что меняется между идентичными по смыслу вызовами (время, случайные ID логирования).
Логируйте пару ключ-тело перед отправкой. Если 422 воспроизводится, сравните тело первого и второго запроса построчно — обычно разница находится в одном поле, которое туда попало случайно.
Не переиспользуйте ключи вручную. Если тестируете API руками через curl и меняете -d, не забудьте сменить и Idempotency-Key — иначе первая же попытка «просто повторить с другим промптом» упрётся в этот конфликт.
Идемпотентность работает одинаково во всех режимах
Idempotency-Key — общий механизм шлюза, не привязанный к конкретному пути. Правило одинаковое для /v1/messages (Anthropic-стиль), /v1/chat/completions и /v1/responses (OpenAI-стиль); к пополнениям баланса механизм не относится: один и тот же ключ должен сопровождать одно и то же тело, иначе — 422, вне зависимости от того, каким протоколом вы пользуетесь.
Пример с автоматическим ретраем
Идемпотентный ключ раскрывает себя именно в связке с ретраями по таймауту:
import time
import requests
def send_with_retry(payload, api_key, key, max_attempts=3):
for attempt in range(max_attempts):
try:
resp = requests.post(
"https://api.zukko.pro/v1/messages",
headers={
"Authorization": f"Bearer {api_key}",
"anthropic-version": "2023-06-01",
"Idempotency-Key": key, # один и тот же на все попытки
},
json=payload,
timeout=60,
)
resp.raise_for_status()
return resp.json()
except requests.exceptions.Timeout:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)Ключ идемпотентности здесь намеренно один и тот же на все попытки одного логического запроса — именно так механизм и задуман: чтобы повтор из-за таймаута не привёл к двойному списанию. Конфликт 422 в этом сценарии возникнет, только если между попытками кто-то — например, параллельный воркер с той же генерацией ключа — отправит другое тело под тем же значением.
Что не является решением
Не стоит просто ловить 422 и генерировать новый случайный Idempotency-Key при каждой ошибке — это сводит на нет саму защиту от двойного списания, ради которой заголовок существует. Если запрос действительно новый по смыслу — у него и должен быть новый ключ, сформированный от его собственного содержимого. Если запрос действительно тот же самый — проблема в том, что где-то в пайплайне тело незаметно меняется, и это стоит найти, а не обходить.
Итого
422 idempotency_conflict — это не сбой сервера, а сигнал, что один и тот же Idempotency-Key используется для двух разных по содержимому запросов. Чинится на стороне клиента: ключ должен детерминированно зависеть от содержательной части тела запроса и не зависеть от переменных полей вроде временных меток.
zukko
Платите в 25×
меньше
за Claude Code & ChatGPT
Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.
Похожие статьи
- Ключи OpenAI API в открытом доступе: почему ими нельзя пользоватьсяОткуда берутся «бесплатные» ключи на GitHub, почему пользоваться ими опасно и для вас, и для владельца, и как защитить собственный ключ от такой же утечки.
- Ошибки GLM API: почему не работает запрос и как это исправитьНеверный id модели, переполненный контекст 128K, ключ, баланс, лимиты: типичные причины ошибок GLM API и быстрый способ найти свою.
- This organization has been disabled в Claude API: что это и как продолжитьКлюч Anthropic внезапно отвечает «organization has been disabled». Объясняем, что это за ошибка, что проверить и как перенести SDK и Claude Code на другой провайдер.
- JetBrains AI Assistant не работает: причины и обходные путиAI Assistant не активируется, молчит или недоступен в отдельных файлах. Разбираем причины по документации JetBrains и подключаем свои модели через шлюз.