Перейти к содержанию

Повторы и идемпотентность

При работе с API в распределённых системах возможны сетевые задержки, разрывы соединений и таймауты. В таких ситуациях клиент часто не знает, успел ли сервис принять и обработать запрос, и сформировался ли ответ.

Для защиты от случайных дублирующих списаний и безопасного повтора запросов Nexus поддерживает механизм идемпотентности через заголовок Idempotency-Key.

Ключевые концепции: Idempotency-Key и x-request-id

Nexus использует два разных заголовка идентификации:

Заголовок Назначение Поведение при повторе
Idempotency-Key (или X-Idempotency-Key) Семантическая идемпотентность операций генерации При повторе с тем же ключом и тем же телом возвращается сохранённый результат без списания с баланса.
x-request-id Сквозная трассировка конкретной сетевой попытки Должен быть уникальным для каждого нового запроса. Повтор старого x-request-id на новом запросе отклоняется (duplicate_request_id).

Если запрос был выполнен с Idempotency-Key, повторный ответ сохраняет исходный x-request-id первой попытки и дополнительно содержит заголовок:

Idempotency-Replayed: true

При первом успешном выполнении заголовок Idempotency-Replayed в ответе отсутствует.

Формат и область действия ключа

Заголовок Idempotency-Key должен удовлетворять следующим требованиям:

  • Длина: от 1 до 256 печатных ASCII-символов (1..=256).
  • Символы: допустимы латинские буквы, цифры, дефисы, подчёркивания и стандартные разделители (например, UUID v4: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d).
  • Недопустимый формат: пустой ключ, длина более 256 символов или не-ASCII символы приводят к ошибке 400 Bad Request с кодом invalid_idempotency_key.

Область действия (Scoping)

Ключ идемпотентности изолирован в рамках:

  1. Вашего профиля Nexus;
  2. Конкретного API-ключа;
  3. Семейства маршрутов (например, Chat, Messages, Responses).

Одинаковый ключ, переданный под разными API-ключами или для разных семейств маршрутов, не вызовет коллизии.

Жизненный цикл ключа идемпотентности

Каждый ключ идемпотентности проходит несколько состояний:

[ Новый запрос ]
       │
       ▼
[ Проверка ключа ] ─── Ключ уже существует? ───► Нет ──► [ Выполнение запроса (Winner) ]
       │                                                               │
      Да                                                               ▼
       │                                                 [ Сохранение ответа на 24 часа ]
       ├─► Тело запроса не совпадает? ──► [ 409 idempotency_conflict ]
       │
       ├─► Первый запрос ещё выполняется? ──► [ Ожидание завершения / 409 idempotency_in_progress ]
       │
       ├─► Прошло более 24 часов (но менее 7 дней)? ──► [ 410 result_expired ]
       │
       └─► Точный повтор в течение 24 часов ──► [ Возврат сохранённого ответа (Replay) ]
                                                (Idempotency-Replayed: true, списание: 0)

1. Первое выполнение (Winner)

Первый запрос с данным ключом запускает стандартный цикл обработки модели. После успешного получения ответа его тело, статус и заголовки сохраняются в защищённом кэше идемпотентности на 24 часа. С баланса профиля списывается фактическая стоимость выполнения. Ответ возвращается клиенту без заголовка Idempotency-Replayed.

2. Точный повтор (Replay)

Если клиент отправляет запрос с тем же Idempotency-Key и идентичным телом в течение 24 часов, Nexus возвращает сохранённый ответ:

  • HTTP-статус и тело совпадают с исходным ответом;
  • Заголовок x-request-id совпадает с исходным;
  • Присутствует заголовок Idempotency-Replayed: true;
  • Списание с баланса не производится (ровно 0 дополнительных единиц).

3. Конкурентные запросы (In Progress)

Если параллельно приходят два одинаковых запроса с одним и тем же ключом идемпотентности:

  • Второй запрос подключается к каналу ожидания первого запроса;
  • Если первый запрос успешно завершается в течение времени ожидания, второй запрос получает результат с заголовком Idempotency-Replayed: true;
  • Если ожидание превышает лимит или соединение не удалось скоординировать, возвращается ответ 409 Conflict с кодом idempotency_in_progress и заголовком Retry-After: 1. Клиенту следует повторить запрос через указанное количество секунд.

4. Несовпадение тела запроса (Conflict)

Если с уже использованным ключом передано другое тело запроса (например, изменилась модель, prompt или параметры температуры), Nexus не выполняет запрос и возвращает 409 Conflict с кодом idempotency_conflict.

Никогда не используйте один и тот же Idempotency-Key для смыслово разных запросов. Для каждого нового запроса генерируйте новый уникальный ключ (например, UUID).

5. Истечение срока действия (Expired)

  • Сохранённый результат доступен для воспроизведения в течение 24 часов.
  • После 24 часов результат удаляется, а запись ключа переходит в статус архива (tombstone) на 7 дней.
  • При попытке повторить запрос в период действия архива возвращается 410 Gone с кодом result_expired. Для отправки запроса необходимо сгенерировать новый ключ.

Потоковая передача (SSE и WebSocket Realtime)

Для потоковых запросов действуют следующие правила:

  • Возобновление потока с произвольного фрагмента не поддерживается: если SSE-соединение прервалось на стороне клиента, дочитать оставшиеся chunk-сообщения невозможно.
  • Разрыв соединения до получения первых данных: если клиент отключился до того, как сервис успел сформировать заголовки ответа, запрос отменяется и остаётся неоплаченным.
  • Разрыв соединения во время активного стрима: если клиент разорвал соединение после начала передачи данных, обработка запроса продолжается в фоновом режиме для фиксации финального расхода токенов.
  • Если финальный расход успешно зафиксирован, списывается фактическая стоимость выполненной генерации (ровно один раз).
  • Если генерация прервалась или расход остался неопределённым, списание не производится.
  • Намеренный разрыв соединения клиентом не делает уже сгенерированный результат бесплатным.

Матрица решений: когда и как повторять запросы

Используйте эту таблицу при проектировании логики повторов:

Ситуация / Ошибка HTTP Код ошибки Можно повторять? Идентификатор Действие
Сетевой сбой / Таймаут соединения 0 / timeout — Да Тот же Idempotency-Key Повторить с экспоненциальной задержкой
Лимит запросов или параллелизма клиента 429 customer_rate_limit_exceeded, customer_concurrency_exceeded, key_* Да Тот же или новый ключ Подождать Retry-After секунд, снизить параллелизм
Высокая нагрузка сервиса / Очередь 503 provider_capacity_exceeded, global_capacity_exceeded, queue_full, queue_timeout, start_capacity_exceeded Да Тот же Idempotency-Key Подождать Retry-After секунд и повторить
Временная защита / Перезагрузка сервиса 503 protection_mode_engaged, billing_protection_engaged, server_shutting_down Да Тот же Idempotency-Key Подождать Retry-After секунд и повторить
Запрос с ключом ещё обрабатывается 409 idempotency_in_progress Да Тот же Idempotency-Key Подождать Retry-After (обычно 1 сек) и повторить
Несовпадение тела для существующего ключа 409 idempotency_conflict Нет (с тем же ключом) Новый ключ Исправить тело либо сгенерировать новый ключ
Срок хранения результата истёк 410 result_expired Нет (с тем же ключом) Новый ключ Сгенерировать новый ключ для нового выполнения
Неверный формат ключа идемпотентности 400 invalid_idempotency_key Нет Исправить ключ Использовать от 1 до 256 ASCII-символов
Дубликат идентификатора запроса 409 duplicate_request_id Нет (с тем же ID) Новый x-request-id Сгенерировать новый x-request-id или использовать Idempotency-Key
Недостаточно средств на балансе 402 insufficient_balance После пополнения Тот же или новый ключ Пополнить баланс профиля в Telegram-боте
Неверный API-ключ 401 invalid_key Нет — Проверить правильность API-ключа
Модель или маршрут недоступны 403 model_not_allowed, endpoint_not_allowed Нет — Проверить доступные модели в GET /v1/models
Ошибка формата JSON или параметров 400 invalid_request_json, invalid_request_error Нет — Исправить синтаксис или параметры запроса
Таймаут ответа модели 504 / 502 upstream_timeout, upstream_unavailable Да Тот же Idempotency-Key Повторить с задержкой

Что делать при таймауте или обрыве сети

Если ваш клиент отправил запрос и не получил HTTP-ответ (сработал read timeout или разорвалось TCP-соединение):

  1. Если вы передавали Idempotency-Key:
  2. Повторите запрос с точно таким же Idempotency-Key и точно таким же телом запроса.
  3. Если сервер успел обработать исходный запрос, вы получите готовый ответ с Idempotency-Replayed: true без повторного списания.
  4. Если сервер ещё обрабатывает запрос, вы получите ответ после ожидания или статус 409 с Retry-After.
  5. Если вы не передавали Idempotency-Key:
  6. Повторная отправка создаст независимый новый запрос, который может привести к дополнительному списанию средств.
  7. Рекомендуется всегда использовать Idempotency-Key для всех не-идемпотентных операций генерации.
  8. Не генерируйте новый Idempotency-Key при неопределённом исходе:
  9. Создание нового ключа лишает сервис возможности связать попытку с предыдущей операцией.

Примеры использования

cURL

export NEXUS_API_KEY='ваш-ключ'
export IDEMPOTENCY_KEY="req-$(uuidgen)"

# Первый запрос
curl -i https://api.nexus-hub.tech/v1/chat/completions \
  -H "Authorization: Bearer $NEXUS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{
    "model": "MODEL_FROM_V1_MODELS",
    "messages": [{"role": "user", "content": "Reply exactly OK."}],
    "max_tokens": 16
  }'

# Безопасный повтор в случае сетевой ошибки (тот же IDEMPOTENCY_KEY и то же тело):
curl -i https://api.nexus-hub.tech/v1/chat/completions \
  -H "Authorization: Bearer $NEXUS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{
    "model": "MODEL_FROM_V1_MODELS",
    "messages": [{"role": "user", "content": "Reply exactly OK."}],
    "max_tokens": 16
  }'

Во втором ответе будет присутствовать заголовок:

Idempotency-Replayed: true

Python OpenAI SDK

Официальный SDK OpenAI позволяет передавать произвольные заголовки через параметр extra_headers:

import os
import uuid
import httpx
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["NEXUS_API_KEY"],
    base_url="https://api.nexus-hub.tech/v1",
)

idempotency_key = str(uuid.uuid4())

try:
    response = client.chat.completions.create(
        model=os.environ["NEXUS_MODEL"],
        messages=[{"role": "user", "content": "Reply exactly OK."}],
        max_tokens=16,
        extra_headers={"Idempotency-Key": idempotency_key},
    )
    print("Ответ получен:", response.choices[0].message.content)
except (httpx.TimeoutException, httpx.NetworkError) as err:
    print(f"Сетевая ошибка: {err}. Выполняем безопасный повтор...")
    # Повтор с тем же ключом идемпотентности гарантирует отсутствие двойного списания
    response = client.chat.completions.create(
        model=os.environ["NEXUS_MODEL"],
        messages=[{"role": "user", "content": "Reply exactly OK."}],
        max_tokens=16,
        extra_headers={"Idempotency-Key": idempotency_key},
    )
    print("Ответ после повтора:", response.choices[0].message.content)

Python Anthropic SDK

Для Anthropic Messages заголовок также передаётся через extra_headers:

import os
import uuid
import httpx
from anthropic import Anthropic

client = Anthropic(
    api_key=os.environ["NEXUS_API_KEY"],
    base_url="https://api.nexus-hub.tech",
)

idempotency_key = str(uuid.uuid4())

try:
    message = client.messages.create(
        model=os.environ["NEXUS_MODEL"],
        max_tokens=16,
        messages=[{"role": "user", "content": "Reply exactly OK."}],
        extra_headers={"Idempotency-Key": idempotency_key},
    )
    print("Ответ:", message.content[0].text)
except (httpx.TimeoutException, httpx.NetworkError) as err:
    print(f"Сетевой сбой: {err}. Повторяем с тем же ключом...")
    message = client.messages.create(
        model=os.environ["NEXUS_MODEL"],
        max_tokens=16,
        messages=[{"role": "user", "content": "Reply exactly OK."}],
        extra_headers={"Idempotency-Key": idempotency_key},
    )
    print("Ответ после повтора:", message.content[0].text)

Совместимость

  • Использование Idempotency-Key является полностью опциональным. Стандартные клиенты, не передающие этот заголовок, работают в обычном режиме без изменений.
  • Механизм не требует перевода клиентов на асинхронную модель (202 Accepted) с опросом статуса: все ответы возвращаются синхронно как при первом выполнении, так и при повторе.