Prostor Docs
Public API

Ошибки и лимиты

Полный справочник ошибок Prostor API: конверт ответа, все коды, что ретраить, почему прилетает 402 при живом балансе, лимиты, стриминг и идемпотентность.

Эта страница — то, что открывают в три часа ночи. Здесь перечислены все ошибки, которые может вернуть публичный Prostor API, что каждая из них значит и что с ней делать.

Конверт ошибки

Любая ошибка — и наша, и проброшенная от провайдера — приходит в одном и том же JSON-конверте:

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.codenumberЧисловой HTTP-статус, продублированный в теле. Не строка. Всегда совпадает с реальным статусом ответа
error.typestringКаноническая категория ошибки. Ровно 9 возможных значений (таблица ниже). Стабильный контракт — ветвитесь на него, если нужна грубая логика (retry / не retry / показать «пополните баланс»)
error.metadata.prostor_codestringНаш точный код. Именно он говорит, что конкретно произошло. Ветвитесь на него, если нужна точная реакция
error.messagestringЧеловекочитаемое описание. Может меняться, не парсите его
Важно

error.code — это число (404), а не строка ("model_not_found"). Если ваш клиент читал error.code как строковый идентификатор, читайте error.metadata.prostor_code.

Ветвление на практике:

js
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 — некорректный запрос

HTTPprostor_codetypeЧто значитЧто делать
400invalid_jsoninvalid_requestтело не разбирается как JSONпочинить сериализацию
400invalid_request_bodyinvalid_requestтело не удалось прочитать или оно больше 8 MiBуменьшить запрос (обрезать историю, вынести большие изображения)
400missing_modelinvalid_requestнет поля modelдобавить model
400missing_generation_idinvalid_requestGET /v1/generation без ?id=передать id из заголовка X-Generation-Id
400unsupported_parameterinvalid_requestпередан массив models (fallback-список моделей)не поддерживается. Отправляйте один model и делайте fallback на своей стороне
400model_not_allowed_on_endpointpermission_deniedмедиа-модель отправлена в /chat/completions или /embeddings, либо текстовая — в /images/generations / /videos/*сверить category модели в GET /v1/models
400streaming_not_supported_for_accountinginvalid_requeststream: true при выключенном на сервисе стримингев production-конфигурации стриминг включён, эта ошибка там не возникает
Заметка

Массив models (OpenRouter-style fallback) отклоняется намеренно, а не «пока не реализован». Авторизация модели и списание идут по полю model, а обслужить запрос мог бы другой элемент массива — это дало бы расхождение между тем, что выполнено, и тем, что оплачено. Пока это не сходится, параметр запрещён. Некоторые SDK, заточенные под OpenRouter, подставляют models сами — отключите это.

401 / 403 — ключ и права

HTTPprostor_codetypeЧто значитЧто делать
401invalid_api_keyauthenticationключ не найден, не тот формат или нет заголовка Authorizationпроверить, что передаёте Bearer pak_pr_v1_...
401internal_token_not_allowedauthenticationво внешний API передан внутренний ключ routing coreиспользовать внешний ключ pak_pr_v1_...
401key_expiredauthenticationу ключа вышел expires_atвыпустить новый ключ
401key_revokedauthenticationключ отозванвыпустить новый ключ
403key_blockedpermission_deniedключ заблокирован администраторомобратиться в поддержку
403key_disabledpermission_deniedключ в неизвестном/неактивном статусеобратиться в поддержку
403user_blockedpermission_deniedзаблокирован сам пользователь-владелецобратиться в поддержку
403scope_deniedpermission_deniedу ключа нет scope для этого эндпоинтапосмотреть prostor.key.scopes в GET /v1/key, перевыпустить ключ с нужным scope
403model_not_allowedpermission_deniedмодель не входит в allowed_models ключавзять модель из GET /v1/models — там уже отфильтровано по ключу

Ни одна из этих ошибок не лечится повтором. Ретрай только умножает нагрузку.

402 — кредиты

HTTPprostor_codetypeЧто значит
402insufficient_creditspayment_requiredпустой баланс или резерв под худший случай не помещается в баланс

Отдельный раздел ниже: Про 402 insufficient_credits. Это ошибка, которая чаще всего прилетает неожиданно.

404 — нет такого

HTTPprostor_codetypeЧто значитЧто делать
404model_not_foundnot_foundтакого model нет в каталогеопечатка или снятая с публикации модель. Не ретраить. Взять id из GET /v1/models
404generation_not_foundnot_foundGET /v1/generation?id= — такой генерации у этого ключа нетпроверить id; генерации видны только выпустившему их ключу
404generation_not_retrievablenot_foundключ работает в режиме redact_logs, для него тела запросов и id генераций не сохраняютсяиспользовать usage из самого ответа
404not_foundnot_foundнеизвестный эндпоинт: unknown endpoint: POST /v1/fooсверить путь с документацией

405 / 408 / 409

HTTPprostor_codetypeЧто значитЧто делать
405method_not_allowedinvalid_requestневерный HTTP-метод (например GET /v1/chat/completions)смотреть заголовок Allow в ответе
408request_cancelledtimeoutклиент отвалился, пока запрос ждал места в бюджете памяти gatewayретраить
409idempotency_conflictinvalid_requestзапрос с таким же Idempotency-Key прямо сейчас выполняетсяподождать и повторить тот же запрос — он вернёт сохранённый ответ

429 — лимиты и перегрузка

HTTPprostor_codetypeЧто значитЧто делать
429rate_limit_exceededrate_limit_exceededпревышен минутный лимит запросов или токенов для ключаждать Retry-After
429token_limit_exceededrate_limit_exceededисчерпан общий (за всё время) лимит токенов ключалимит поднимает администратор
429monthly_token_limit_exceededrate_limit_exceededисчерпан месячный лимит токенов ключаждать следующего месяца или поднять лимит
429credit_limit_exceededrate_limit_exceededисчерпан общий лимит кредитов ключа (credit_limit_total)поднять лимит; это лимит ключа, а не баланс кошелька
429concurrency_limit_exceededrate_limit_exceededслишком много одновременных запросов этого ключаснизить параллелизм, ретраить через Retry-After
429gateway_overloadedrate_limit_exceededgateway временно на пределе по всем клиентамретраить с backoff

Все 429 несут Retry-After (см. раздел про лимиты). Все они ретраибельны.

5xx — не ваша вина

HTTPprostor_codetypeЧто значитЧто делать
500internal_errorserverнепредвиденный сбой gatewayретраить с backoff; при повторении прислать нам X-Request-Id
502invalid_core_responseprovider_unavailableядро вернуло не-JSONретраить
503core_unavailableprovider_unavailablerouting core недоступен, или ответ не поместился в лимит буфераретраить с backoff
503model_pricing_not_configuredprovider_unavailableмодель существует, но не тарифицируется (выключена или без цены)выбрать другую модель из GET /v1/models
upstreamprovider_errorпо статусупровайдер вернул ошибку, которую не удалось разобратьстатус ответа = статус провайдера; ретраить, если это 429 или 5xx

Ошибка провайдера отдаётся с его собственным HTTP-статусом (400, 429, 500, 502…). Резерв кредитов при этом возвращается целиком — за неудавшуюся генерацию вы не платите.

Что ретраить, а что нет

РетраитьНе ретраить
429 — все шесть кодов, с задержкой из Retry-After400 — запрос сломан, повтор даст ту же ошибку
408 request_cancelled401 / 403 — ключ, права, scope
500, 502, 503, а также 5xx от провайдера404 — включая model_not_found
409 idempotency_conflict — с тем же Idempotency-Key402 — пока не изменили 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 раза.

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

Дальше по чек-листу:

  1. Передать max_tokens (или max_completion_tokens, или max_output_tokens для /responses) — реалистичный, а не «на всякий случай побольше».
  2. Снизить параллелизм: меньше одновременных запросов — меньше одновременных резервов.
  3. Проверить баланс: GET /v1/creditsprostor.balance_credits.
  4. Проверить, не упёрлись ли в лимит ключа, а не в кошелёк: GET /v1/keyprostor.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на каждом 429UNIX-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, поэтому их видно и из браузера.

Что именно ограничено

ОграничениеЗначение по умолчаниюКод при превышении
Одновременных запросов на ключ32429 concurrency_limit_exceeded
Одновременных запросов на весь gateway512429 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 MiB400 invalid_request_body

Актуальные значения именно вашего ключа — в GET /v1/keyprostor.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:

text
data: {"error":{"message":"upstream generation failed","type":"stream_error"}}
data: [DONE]

Для /responses — типизированное событие ошибки Responses API с полем code.

Отдельный случай — переполнение буфера: если ответ модели превысил безопасный лимит для стриминга, попытка отбрасывается целиком и клиент получает

text
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/keyprostor.max_output_tokens.
bash
curl -s "$PROSTOR_API_BASE_URL/key" \
  -H "Authorization: Bearer $PROSTOR_API_KEY" | jq '.prostor.max_output_tokens'

Если вам нужен ответ длиннее потолка — разбивайте задачу на несколько запросов; поднять потолок для ключа может администратор.

Idempotency-Key: как сделать ретраи безопасными

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

Передайте заголовок:

bash
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_logsreplay требует хранить тело ответа 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);
  • время запроса с точностью до минуты и модель.

Свою сторону истории можно посмотреть самому:

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