Повторы и идемпотентность¶
При работе с 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)¶
Ключ идемпотентности изолирован в рамках:
- Вашего профиля Nexus;
- Конкретного API-ключа;
- Семейства маршрутов (например, 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-соединение):
- Если вы передавали
Idempotency-Key: - Повторите запрос с точно таким же
Idempotency-Keyи точно таким же телом запроса. - Если сервер успел обработать исходный запрос, вы получите готовый ответ с
Idempotency-Replayed: trueбез повторного списания. - Если сервер ещё обрабатывает запрос, вы получите ответ после ожидания или статус
409сRetry-After. - Если вы не передавали
Idempotency-Key: - Повторная отправка создаст независимый новый запрос, который может привести к дополнительному списанию средств.
- Рекомендуется всегда использовать
Idempotency-Keyдля всех не-идемпотентных операций генерации. - Не генерируйте новый
Idempotency-Keyпри неопределённом исходе: - Создание нового ключа лишает сервис возможности связать попытку с предыдущей операцией.
Примеры использования¶
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) с опросом статуса: все ответы возвращаются синхронно как при первом выполнении, так и при повторе.