Ошибка 422 idempotency_conflict: что значит и как чинить

Повторили запрос с тем же ключом идемпотентности, но с другим телом — и получили 422 вместо ожидаемого повтора ответа. Разбираем, что происходит.

4 мин · · Обновлено · ошибки

Ретрай-логика с идемпотентными ключами обычно работает тихо и незаметно — ровно до момента, когда сервер начинает отвечать 422 на запросы, которые выглядят как обычный повтор. Разбираемся, что это за конфликт и откуда он берётся.

Что такое Idempotency-Key вообще

Заголовок Idempotency-Key защищает от двойного списания при повторной отправке одного и того же запроса — например, если клиент не дождался ответа по таймауту и решил повторить попытку. Если в течение 24 часов приходит запрос с тем же значением ключа, сервер возвращает тот же результат, что и в первый раз, не выполняя запрос заново и не списывая деньги повторно.

bash
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

bash
{"error": {"message": "Idempotency key already used with a different request body", "code": "idempotency_conflict"}}

Конфликт возникает, если под одним и тем же значением Idempotency-Key в систему приходит запрос с другим телом. Механизм идемпотентности предполагает, что ключ уникально описывает конкретный запрос — если тело изменилось, а ключ остался прежним, сервер не может понять, какой из двух вариантов правильный, и честно отказывает вместо того, чтобы вернуть неверный кэшированный ответ или молча перезаписать историю.

Частая причина: генерация ключа без учёта содержимого

Самая типичная ошибка — брать ключ идемпотентности из чего-то, что не привязано к содержимому запроса, например из ID сессии пользователя или текущей минуты:

bash
# Плохо: один и тот же ключ на все запросы в рамках сессии
idempotency_key = f"session-{session_id}"

# Каждый новый вопрос пользователя в сессии получит один и тот же ключ,
# и второй запрос с другим содержимым провалится с 422

Тот же эффект даёт ключ, заданный в заголовках клиента по умолчанию (например, default_headers при создании клиента OpenAI SDK): он уходит с каждым запросом подряд. Задавайте Idempotency-Key в конкретном вызове (extra_headers в .create()), уникальным на каждый логический запрос.

Правильный подход — включать в основу ключа хеш от содержимого запроса, а не только внешний идентификатор:

bash
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, порядок сообщений:

bash
# Опасно: 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, вне зависимости от того, каким протоколом вы пользуетесь.

Пример с автоматическим ретраем

Идемпотентный ключ раскрывает себя именно в связке с ретраями по таймауту:

bash
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.