Ошибки и лимиты
Полный справочник ошибок Prostor API: конверт ответа, все коды, что ретраить, почему прилетает 402 при живом балансе, лимиты, стриминг и идемпотентность.
Эта страница — то, что открывают в три часа ночи. Здесь перечислены все ошибки, которые может вернуть публичный Prostor API, что каждая из них значит и что с ней делать.
Конверт ошибки
Любая ошибка — и наша, и проброшенная от провайдера — приходит в одном и том же JSON-конверте:
{
"error": {
"message": "unknown model: gpt-4o-minii — call GET /v1/models for the models this key can use",
"code": 404,
"type": "not_found",
"metadata": {
"prostor_code": "model_not_found"
}
}
}Три поля, на которые можно ветвиться, и они означают разные вещи:
| Поле | Тип | Что это |
|---|---|---|
error.code | number | Числовой HTTP-статус, продублированный в теле. Не строка. Всегда совпадает с реальным статусом ответа |
error.type | string | Каноническая категория ошибки. Ровно 9 возможных значений (таблица ниже). Стабильный контракт — ветвитесь на него, если нужна грубая логика (retry / не retry / показать «пополните баланс») |
error.metadata.prostor_code | string | Наш точный код. Именно он говорит, что конкретно произошло. Ветвитесь на него, если нужна точная реакция |
error.message | string | Человекочитаемое описание. Может меняться, не парсите его |
error.code — это число (404), а не строка ("model_not_found"). Если ваш клиент читал error.code как строковый идентификатор, читайте error.metadata.prostor_code.
Ветвление на практике:
const res = await fetch(`${BASE}/chat/completions`, { /* ... */ });
if (!res.ok) {
const { error } = await res.json();
const code = error?.metadata?.prostor_code;
const type = error?.type;
if (code === "insufficient_credits") throw new NeedsTopUp(error.message);
if (code === "model_not_found") throw new BadModelId(error.message); // опечатка, не ретраить
if (type === "rate_limit_exceeded") {
const wait = Number(res.headers.get("Retry-After") || 1);
return retryAfter(wait * 1000);
}
if (type === "provider_unavailable" || error.code >= 500) return retryWithBackoff();
throw new Error(error?.message || "Prostor API error");
}Ошибки, пришедшие от routing core и провайдеров, проксируются с их HTTP-статусом, но приводятся к этому же конверту: code нормализуется в числовой статус, а внутренние поля (маршрутизация, идентификаторы провайдера, retry_after_ms, версия ядра) вырезаются. Если тело ошибки провайдера не разбирается как JSON, вы получите prostor_code: "provider_error" с текстом the upstream provider returned an error.
Канонические типы
error.type принимает ровно эти значения:
type | Смысл | Общая реакция |
|---|---|---|
authentication | ключ невалиден, истёк или отозван | не ретраить, чинить ключ |
permission_denied | ключ валиден, но операция ему не разрешена | не ретраить, менять scope / модель / ключ |
payment_required | не хватает кредитов | пополнить баланс или уменьшить max_tokens |
rate_limit_exceeded | упёрлись в лимит: rate, токены, кредиты, конкурентность | ретраить, уважая Retry-After |
provider_unavailable | ядро/провайдер недоступны или модель не тарифицируется | ретраить с backoff |
not_found | нет такой модели, генерации или эндпоинта | не ретраить |
timeout | клиент отвалился, пока запрос ждал очереди | ретраить |
invalid_request | некорректный запрос | не ретраить, чинить запрос |
server | непредвиденный сбой gateway | ретраить с backoff, сообщить нам X-Request-Id |
Полный список кодов
Все prostor_code, которые может вернуть публичный API.
400 — некорректный запрос
| HTTP | prostor_code | type | Что значит | Что делать |
|---|---|---|---|---|
400 | invalid_json | invalid_request | тело не разбирается как JSON | починить сериализацию |
400 | invalid_request_body | invalid_request | тело не удалось прочитать или оно больше 8 MiB | уменьшить запрос (обрезать историю, вынести большие изображения) |
400 | missing_model | invalid_request | нет поля model | добавить model |
400 | missing_generation_id | invalid_request | GET /v1/generation без ?id= | передать id из заголовка X-Generation-Id |
400 | unsupported_parameter | invalid_request | передан массив models (fallback-список моделей) | не поддерживается. Отправляйте один model и делайте fallback на своей стороне |
400 | model_not_allowed_on_endpoint | permission_denied | медиа-модель отправлена в /chat/completions или /embeddings, либо текстовая — в /images/generations / /videos/* | сверить category модели в GET /v1/models |
400 | streaming_not_supported_for_accounting | invalid_request | stream: true при выключенном на сервисе стриминге | в production-конфигурации стриминг включён, эта ошибка там не возникает |
Массив models (OpenRouter-style fallback) отклоняется намеренно, а не «пока не реализован». Авторизация модели и списание идут по полю model, а обслужить запрос мог бы другой элемент массива — это дало бы расхождение между тем, что выполнено, и тем, что оплачено. Пока это не сходится, параметр запрещён. Некоторые SDK, заточенные под OpenRouter, подставляют models сами — отключите это.
401 / 403 — ключ и права
| HTTP | prostor_code | type | Что значит | Что делать |
|---|---|---|---|---|
401 | invalid_api_key | authentication | ключ не найден, не тот формат или нет заголовка Authorization | проверить, что передаёте Bearer pak_pr_v1_... |
401 | internal_token_not_allowed | authentication | во внешний API передан внутренний ключ routing core | использовать внешний ключ pak_pr_v1_... |
401 | key_expired | authentication | у ключа вышел expires_at | выпустить новый ключ |
401 | key_revoked | authentication | ключ отозван | выпустить новый ключ |
403 | key_blocked | permission_denied | ключ заблокирован администратором | обратиться в поддержку |
403 | key_disabled | permission_denied | ключ в неизвестном/неактивном статусе | обратиться в поддержку |
403 | user_blocked | permission_denied | заблокирован сам пользователь-владелец | обратиться в поддержку |
403 | scope_denied | permission_denied | у ключа нет scope для этого эндпоинта | посмотреть prostor.key.scopes в GET /v1/key, перевыпустить ключ с нужным scope |
403 | model_not_allowed | permission_denied | модель не входит в allowed_models ключа | взять модель из GET /v1/models — там уже отфильтровано по ключу |
Ни одна из этих ошибок не лечится повтором. Ретрай только умножает нагрузку.
402 — кредиты
| HTTP | prostor_code | type | Что значит |
|---|---|---|---|
402 | insufficient_credits | payment_required | пустой баланс или резерв под худший случай не помещается в баланс |
Отдельный раздел ниже: Про 402 insufficient_credits. Это ошибка, которая чаще всего прилетает неожиданно.
404 — нет такого
| HTTP | prostor_code | type | Что значит | Что делать |
|---|---|---|---|---|
404 | model_not_found | not_found | такого model нет в каталоге | опечатка или снятая с публикации модель. Не ретраить. Взять id из GET /v1/models |
404 | generation_not_found | not_found | GET /v1/generation?id= — такой генерации у этого ключа нет | проверить id; генерации видны только выпустившему их ключу |
404 | generation_not_retrievable | not_found | ключ работает в режиме redact_logs, для него тела запросов и id генераций не сохраняются | использовать usage из самого ответа |
404 | not_found | not_found | неизвестный эндпоинт: unknown endpoint: POST /v1/foo | сверить путь с документацией |
405 / 408 / 409
| HTTP | prostor_code | type | Что значит | Что делать |
|---|---|---|---|---|
405 | method_not_allowed | invalid_request | неверный HTTP-метод (например GET /v1/chat/completions) | смотреть заголовок Allow в ответе |
408 | request_cancelled | timeout | клиент отвалился, пока запрос ждал места в бюджете памяти gateway | ретраить |
409 | idempotency_conflict | invalid_request | запрос с таким же Idempotency-Key прямо сейчас выполняется | подождать и повторить тот же запрос — он вернёт сохранённый ответ |
429 — лимиты и перегрузка
| HTTP | prostor_code | type | Что значит | Что делать |
|---|---|---|---|---|
429 | rate_limit_exceeded | rate_limit_exceeded | превышен минутный лимит запросов или токенов для ключа | ждать Retry-After |
429 | token_limit_exceeded | rate_limit_exceeded | исчерпан общий (за всё время) лимит токенов ключа | лимит поднимает администратор |
429 | monthly_token_limit_exceeded | rate_limit_exceeded | исчерпан месячный лимит токенов ключа | ждать следующего месяца или поднять лимит |
429 | credit_limit_exceeded | rate_limit_exceeded | исчерпан общий лимит кредитов ключа (credit_limit_total) | поднять лимит; это лимит ключа, а не баланс кошелька |
429 | concurrency_limit_exceeded | rate_limit_exceeded | слишком много одновременных запросов этого ключа | снизить параллелизм, ретраить через Retry-After |
429 | gateway_overloaded | rate_limit_exceeded | gateway временно на пределе по всем клиентам | ретраить с backoff |
Все 429 несут Retry-After (см. раздел про лимиты). Все они ретраибельны.
5xx — не ваша вина
| HTTP | prostor_code | type | Что значит | Что делать |
|---|---|---|---|---|
500 | internal_error | server | непредвиденный сбой gateway | ретраить с backoff; при повторении прислать нам X-Request-Id |
502 | invalid_core_response | provider_unavailable | ядро вернуло не-JSON | ретраить |
503 | core_unavailable | provider_unavailable | routing core недоступен, или ответ не поместился в лимит буфера | ретраить с backoff |
503 | model_pricing_not_configured | provider_unavailable | модель существует, но не тарифицируется (выключена или без цены) | выбрать другую модель из GET /v1/models |
| upstream | provider_error | по статусу | провайдер вернул ошибку, которую не удалось разобрать | статус ответа = статус провайдера; ретраить, если это 429 или 5xx |
Ошибка провайдера отдаётся с его собственным HTTP-статусом (400, 429, 500, 502…). Резерв кредитов при этом возвращается целиком — за неудавшуюся генерацию вы не платите.
Что ретраить, а что нет
| Ретраить | Не ретраить |
|---|---|
429 — все шесть кодов, с задержкой из Retry-After | 400 — запрос сломан, повтор даст ту же ошибку |
408 request_cancelled | 401 / 403 — ключ, права, scope |
500, 502, 503, а также 5xx от провайдера | 404 — включая model_not_found |
409 idempotency_conflict — с тем же Idempotency-Key | 402 — пока не изменили max_tokens или баланс |
model_not_found — это 404, а не 503. Так сделано намеренно. Раньше неизвестная модель отдавалась как 503, и SDK (OpenAI, LangChain и прочие) послушно ретраили её по три раза: одна опечатка в model превращалась в три запроса, три записи в логах и три звонка в поддержку. 404 останавливает ретрай-логику любого клиента на первом же ответе. Сообщение уже содержит подсказку: call GET /v1/models for the models this key can use.
Backoff: экспоненциальный с джиттером, старт 1 s, не больше 5 попыток. Для 429 — не быстрее, чем разрешает Retry-After.
Про 402 insufficient_credits
Самая контринтуитивная ошибка API. Она может прилететь при живом, положительном балансе.
Почему
Перед тем как отправить запрос в модель, gateway ставит на кошелёк предавторизационный резерв (hold) — сумму, которой заведомо хватит на худший исход этого запроса. Резерв считается так:
- prompt-часть — оценка по размеру тела запроса в байтах (оценка намеренно завышенная: кириллица занимает больше байт, чем латиница, и её токенов больше, чем даёт наивный расчёт);
- completion-часть — ровно ваш
max_tokens. А если вы его не передали — 8192 токенов (значение по умолчанию, см. раздел про клэмп); - обе части умножаются на розничную цену модели и округляются в большую сторону.
То есть запрос без max_tokens резервирует столько, сколько стоили бы 8192 выходных токена этой модели — даже если модель ответит одним словом. На дорогой модели это ощутимая сумма. Резервы одновременных запросов складываются: при лимите в 32 параллельных запроса на ключ в воздухе может висеть 32 таких резерва.
Итог: клиент, чей реальный расход составил бы, скажем, 200 кредитов, получает insufficient_credits с текстом insufficient credits to cover max_tokens for this request задолго до того, как баланс действительно кончился.
Что делать
Всегда передавайте max_tokens. Это единственный рычаг, который напрямую уменьшает резерв. max_tokens: 256 вместо умолчания сжимает completion-часть резерва в 32 раза.
curl -s "$PROSTOR_API_BASE_URL/chat/completions" \
-H "Authorization: Bearer $PROSTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<model-id-from-/v1/models>",
"messages": [{"role": "user", "content": "Привет"}],
"max_tokens": 256
}' | jq .Дальше по чек-листу:
- Передать
max_tokens(илиmax_completion_tokens, илиmax_output_tokensдля/responses) — реалистичный, а не «на всякий случай побольше». - Снизить параллелизм: меньше одновременных запросов — меньше одновременных резервов.
- Проверить баланс:
GET /v1/credits→prostor.balance_credits. - Проверить, не упёрлись ли в лимит ключа, а не в кошелёк:
GET /v1/key→prostor.limits.credits_total. Если исчерпан он, ошибка будет другая —429 credit_limit_exceeded.
Неизрасходованный резерв возвращается
Резерв — не списание. После того как ответ получен, gateway списывает фактическую стоимость по реальному usage, а остаток резерва немедленно возвращается на баланс. Если запрос упал (ошибка провайдера, ошибка ядра) — возвращается весь резерв. Брошенные резервы (например, процесс упал в момент расчёта) подчищаются автоматической сверкой.
Что именно списано, всегда видно в трёх местах: usage.cost (в USD), usage.cost_details.billed_credits (в кредитах) и заголовок X-Prostor-Cost-Credits.
Проверка баланса срабатывает и там, где вы её не ждёте: GET /v1/models требует положительного баланса и при нулевом вернёт 402. А вот GET /v1/credits, GET /v1/key и GET /v1/generation работают всегда — свой баланс можно прочитать даже с пустым кошельком.
Лимиты и конкурентность
Заголовки
| Заголовок | Когда | Что в нём |
|---|---|---|
Retry-After | на каждом 429 | целое число секунд, минимум 1 |
X-RateLimit-Reset | на каждом 429 | UNIX-timestamp в секундах — момент, когда можно повторить. Это не длительность |
X-Request-Id / X-Generation-Id | на генерациях | идентификатор запроса; его же передавать в GET /v1/generation?id= и цитировать в баг-репорте |
X-Prostor-Cost-Credits | на успешных нестриминговых генерациях | сколько кредитов списано |
Idempotent-Replayed | при повторе идемпотентного запроса | true |
X-RateLimit-Reset — это абсолютный UNIX-timestamp, а не «через сколько секунд». Если сложить его с текущим временем, вы получите дату в далёком будущем и вечное ожидание. Для паузы используйте Retry-After.
Все шесть заголовков объявлены в Access-Control-Expose-Headers, поэтому их видно и из браузера.
Что именно ограничено
| Ограничение | Значение по умолчанию | Код при превышении |
|---|---|---|
| Одновременных запросов на ключ | 32 | 429 concurrency_limit_exceeded |
| Одновременных запросов на весь gateway | 512 | 429 gateway_overloaded |
Запросов в минуту на ключ (request_limit_minute) | не ограничено, если администратор не задал | 429 rate_limit_exceeded |
Токенов в минуту на ключ (token_limit_minute) | не ограничено, если администратор не задал | 429 rate_limit_exceeded |
Всего токенов на ключ (token_limit_total) | не ограничено, если не задан | 429 token_limit_exceeded |
Токенов за месяц (token_limit_monthly) | не ограничено, если не задан | 429 monthly_token_limit_exceeded |
Всего кредитов на ключ (credit_limit_total) | не ограничено, если не задан | 429 credit_limit_exceeded |
| Размер тела запроса | 8 MiB | 400 invalid_request_body |
Актуальные значения именно вашего ключа — в GET /v1/key → prostor.limits, prostor.concurrency_limit, prostor.max_output_tokens.
Минутные лимиты по токенам считают зарезервированные токены, а не фактические: с запроса списывается ваш max_tokens (или 8192 по умолчанию), даже если модель ответила десятью словами. Ещё одна причина передавать max_tokens. По той же причине обычный GET /v1/models тоже «съедает» 8192 токена минутного бюджета и один запрос — не опрашивайте его в цикле, кэшируйте каталог.
В GET /v1/key поля limits.requests_per_minute и limits.tokens_per_minute всегда показывают used: 0 и remaining: null — счётчики минутного окна наружу не публикуются. Не стройте на них логику; ориентируйтесь на Retry-After в фактическом 429.
Ошибки в стриминге
Если ответ уже начал отдаваться (200 OK + text/event-stream), поменять HTTP-статус нельзя. Поэтому ошибки, случившиеся после старта стрима, приходят внутри самого стрима, отдельным SSE-событием.
Для /chat/completions:
data: {"error":{"message":"upstream generation failed","type":"stream_error"}}
data: [DONE]Для /responses — типизированное событие ошибки Responses API с полем code.
Отдельный случай — переполнение буфера: если ответ модели превысил безопасный лимит для стриминга, попытка отбрасывается целиком и клиент получает
data: {"error":{"message":"upstream response exceeded the safe streaming limit","type":"stream_error"}}
data: [DONE]Клиент, который читает только choices[].delta и игнорирует data: с полем error, увидит успешно завершившийся, но пустой стрим. Всегда проверяйте каждую SSE-строку на наличие error перед тем, как разбирать choices.
Обрыв соединения НЕ отменяет генерацию
Это важно и стоит денег.
Если вы разрываете соединение посреди стрима (таймаут HTTP-клиента, AbortController, закрытая вкладка), gateway не отменяет запрос у провайдера. Генерация дочитывается до конца, её реальный usage фиксируется, и она списывается с вашего баланса полностью. То же верно и для нестриминговых запросов.
Так сделано осознанно: только полный ответ провайдера даёт достоверный usage, а списывать «по оценке» — значит систематически ошибаться в чью-то сторону. Отменяемых генераций у API сейчас нет.
Последствия для клиента:
- ставьте HTTP-таймаут больше ожидаемого времени генерации, иначе вы будете платить за ответы, которых не увидели;
- в
GET /v1/generation?id=у такой генерации будетinterrupted: trueиfinish_reason: "client_disconnected", аcost_credits— полная стоимость; - retry по своему таймауту без идемпотентности = двойное списание. См. раздел про
Idempotency-Key.
Предельное время жизни стрима на стороне gateway — 20 минут; нестримингового запроса к ядру — 120 секунд.
Что в usage у стрима
В финальном usage-чанке стрима поля is_estimated и interrupted всегда false — на момент отправки чанка их значения ещё не известны. Достоверные значения (в том числе interrupted: true после обрыва) появляются только в GET /v1/generation?id=<X-Generation-Id>.
Если провайдер не прислал терминальный usage-чанк, токены оцениваются, причём оценка намеренно консервативна: completion считается не меньше, чем весь зарезервированный max_tokens. Такая генерация помечается is_estimated: true в GET /v1/generation. Ещё одна причина не оставлять max_tokens пустым.
Тихий клэмп max_tokens
Явный max_tokens уважается как запрошено, но выше жёсткого потолка он молча уменьшается до потолка — без ошибки, предупреждения и заголовка. Вы просто получите более короткий ответ и finish_reason: "length", неотличимый от обычной обрезки моделью.
Правила:
- явный
max_tokensрезервируется и форвардится как запрошено — реальный предел модели дальше применяет провайдер; - жёсткий потолок шлюза — 64000 (у ключа может быть свой
max_output_tokens, тогда он жёсткий именно для этого ключа); значение выше снижается до него; - дефолт 8192 — не потолок, а лишь то, что подставляется, если ни
max_tokens, ниmax_completion_tokens(chat), ниmax_output_tokens(/responses) не переданы.
Где посмотреть своё число, а не гадать:
GET /v1/models→ у каждой текстовой моделиtop_provider.max_completion_tokens;GET /v1/key→prostor.max_output_tokens.
curl -s "$PROSTOR_API_BASE_URL/key" \
-H "Authorization: Bearer $PROSTOR_API_KEY" | jq '.prostor.max_output_tokens'Если вам нужен ответ длиннее потолка — разбивайте задачу на несколько запросов; поднять потолок для ключа может администратор.
Idempotency-Key: как сделать ретраи безопасными
Ретрай по таймауту без идемпотентности — это второй счёт за ту же работу. Особенно больно с медиа: изображение и видео тарифицируются фиксированной ценой за генерацию, поэтому повторно отправленный запрос на картинку — это ровно вторая картинка и ровно второе списание.
Передайте заголовок:
curl -s "$PROSTOR_API_BASE_URL/images/generations" \
-H "Authorization: Bearer $PROSTOR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0f2b9a5e-4a1c-4c2b-9f0d-7c1e5b3a8d61" \
-d '{
"model": "<image-model-id-from-/v1/models>",
"prompt": "a cat riding a bike, watercolor",
"n": 1
}' | jq .Как это работает:
- первый запрос выполняется как обычно; при успешном (
2xx) ответе тело и статус сохраняются; - повтор с тем же ключом возвращает сохранённый ответ дословно, с заголовком
Idempotent-Replayed: true. Повторного списания нет; - повтор, пока первый запрос ещё выполняется, вернёт
409 idempotency_conflict— подождите и повторите; - если первый запрос завершился ошибкой, ключ освобождается: ретрай с тем же ключом выполнится по-настоящему (это то, что вам и нужно);
- записи живут 24 часа, потом удаляются.
Ключ привязан к пути и телу
Сохранённая запись — это ваш Idempotency-Key плюс отпечаток пути и байтов тела. Тот же Idempotency-Key с другим телом или на другом эндпоинте — другая заявка: она выполнится и спишется. Это защищает настоящие ретраи (тот же запрос) и не даёт переиспользовать ключ для другого запроса. Генерируйте UUID на каждую логическую операцию и переиспользуйте его только при ретрае байт-в-байт того же запроса.
Где идемпотентность НЕ работает
| Не работает | Почему |
|---|---|
Любой запрос со stream: true | ответ отдаётся потоком, сохранять и переигрывать нечего |
POST /v1/videos/status | это опрос статуса, он и так безопасен для повтора |
Любые GET (/models, /credits, /key, /generation) | они не меняют состояние |
Ключи с включённым redact_logs | replay требует хранить тело ответа 24 часа, что противоречит режиму минимизации данных. Для таких ключей заголовок молча игнорируется |
Работает на: POST /v1/chat/completions и POST /v1/responses (только без стриминга), POST /v1/images/generations, POST /v1/videos/generations, POST /v1/videos/submit, POST /v1/embeddings.
Если хранилище идемпотентности временно недоступно, gateway не роняет запрос, а выполняет его без защиты от дублей. Это осознанный компромисс: доступность важнее, чем гарантия дедупликации. Полагаться на идемпотентность как на строгую гарантию биллинга нельзя — она резко снижает вероятность двойного списания, но не обнуляет её.
Что приложить к обращению в поддержку
X-Request-Id(он жеX-Generation-Id) из заголовков ответа — это единственный идентификатор, по которому мы найдём запрос;- полное тело ошибки (
error.code,error.type,error.metadata.prostor_code); - время запроса с точностью до минуты и модель.
Свою сторону истории можно посмотреть самому:
curl -s "$PROSTOR_API_BASE_URL/generation?id=<X-Generation-Id>" \
-H "Authorization: Bearer $PROSTOR_API_KEY" | jq .Ответ покажет status, токены, cost_credits, finish_reason, latency, а также флаги interrupted и is_estimated.