Skip to content

0051. Контракт ошибок: причины, поля и обратная связь от сервера до CLI ​

Статус: accepted. Дата: 2026-10-01.

Контекст ​

Ответ об ошибке содержал только {code, message, requestId}, а code был свободной строкой. Проверка кода на f27f7c8 показала:

  • разные причины одного кода были неразличимы: 401 «неверный пароль», «истёк сеанс», «отозван ключ» и «нет заголовка» выглядели одинаково, консоль угадывала смысл по маршруту и на форме смены пароля показывала «неверный текущий пароль» при истёкшем сеансе; все 507 читались как «закончилось место», хотя исчерпаны могли быть учётные записи, группы, ключи или очередь;
  • любые клиентские ошибки Fastify (слишком большое тело, неподдерживаемый тип, битый JSON, schema validation) превращались в 400 Invalid request, поля не назывались; fields() сообщал «Unknown field» без имени;
  • неизвестный маршрут без учётных данных отвечал 401, с учётными данными — телом Fastify, повторяющим URL, без code и requestId; неверный метод — 404;
  • read_only и rate_limited уходили на wire мимо доменного набора кодов; 416 не имел тела; 429 и 401 входа не были описаны в OpenAPI; схема ошибки трижды копировалась вручную;
  • SDK терял message сервера и игнорировал X-Request-Id для ответов прокси; локальные сбои SDK были Error с английским текстом, который консоль сравнивала строкой;
  • консоль выбирала текст по HTTP-статусу, показывала request ID только в общей строке состояния, не показывала Retry-After и не подсвечивала поля; CLI выдавал только статус, без кода сервера и ID запроса.

Решение ​

Код и причина. Набор кодов домена закрыт и включает read_only и rate_limited. Необязательная reason уточняет код из закрытого набора этого кода; каждая причина принадлежит ровно одному коду. Конструктор ArkvoryError(code, message, {reason, details, retryAfterSeconds}) проверяет пару код/причина при компиляции. ThrottledError теперь имеет код rate_limited (раньше busy и отдельная отправка 429 в маршрутах). Полный перечень причин и их значений — в API_CONTRACTS.

Wire. Единственная схема — packages/contracts/src/errors.ts (nativeErrorSchema, readNativeError); копии в native.ts и service-api.ts удалены. code — enum OpenAPI, reason — строка с перечисленными значениями и шаблоном машинного имени. Добавлены необязательные поля:

  • reason;
  • details — до 16 элементов {field, problem}: field — JSON Pointer в тело (/currentPassword, /policy/quotaBytes) либо имя параметра пути/запроса без / (stage, limit); problem — required | unknown_field | type | format | length | range | invalid. Значения полей никогда не возвращаются;
  • retryAfterSeconds — только вместе с Retry-After, то же число.

{code, message, requestId} остаются обязательными и прежними. Домен и contracts держат одинаковые наборы раздельно (contracts не зависит от domain), равенство проверяет unit-тест. Читатели обязаны терпеть неизвестные будущие коды (по классу статуса) и причины (как отсутствующие); SDK не валидирует enum.

Статусы. Статус выводится из пары код/причина: invalid_input — 400, кроме body_too_large 413, unsupported_media_type 415, method_not_allowed 405, range_not_satisfiable 416; read_only — 405 с Allow: GET, HEAD; rate_limited — 429. Retry-After и retryAfterSeconds отправляются для 429 и 503 (busy, unavailable), по умолчанию 2 с. Ошибки Fastify классифицируются по стабильным кодам FST_ERR_*; ошибки schema validation и fields() перечисляют поля в details. Без новой причины invalid_input получает validation.

Маршруты. setNotFoundHandler отвечает not_found/route_not_found без отражения URL; хук безопасности пропускает несовпавшие запросы (request.is404) до аутентификации, поэтому ответ не зависит от учётных данных. Для пути, который существует с другим методом, тот же обработчик отвечает 405 invalid_input/method_not_allowed и Allow: шаблоны маршрутов собираются хуком onRoute и проверяются только на промахе, маршрутизация совпавших запросов не меняется. На шлюзе чтения такой запрос с изменяющим методом получает прежний ответ шлюза read_only с Allow: GET, HEAD, теперь также до аутентификации. 416 получает JSON-конверт invalid_input/range_not_satisfiable с Content-Range: bytes */size.

Аутентификация. Вход с неверным паролем, неизвестным именем или выключенной учётной записью — одинаково invalid_credentials, существование имени не раскрывается. Неверный текущий пароль — current_password_invalid и details: [{field: "/currentPassword"}]. Если resolve отказал, rejection(token) ищет тот же дайджест без фильтра срока одним индексным запросом: найден истёкший — session_expired (сессия) или token_expired (PAT, управляемый ключ), иначе credential_invalid; нет токена — credential_missing. Для управляемого ключа token_expired возвращается только при совпадении секрета (timing-safe), поэтому угаданный id ключа ничего не сообщает. Запрос выполняется для любого отклонённого корректного по форме токена; новой разницы во времени ответа, кроме уже существующей для неверной формы, нет. Срок сообщается лишь владельцу точного секрета.

SDK. ArkvoryHttpError сохраняет позиционный конструктор и поля, получает name, serverMessage (не Error.message, чтобы текст сервера не попал в UI случайно), reason, details, retryAfterSeconds; при отсутствии requestId в теле берётся X-Request-Id; retryAfterMs — из заголовка, иначе из тела. Локальные сбои — ArkvoryClientError с кодами invalid_argument | insecure_url | invalid_response | response_too_large | size_mismatch | file_changed | upload_cancelled | completion_failed; прежний английский текст сохранён в message. ClientOptions.onRequest получает {method, path, status?, durationMs, requestId?} для диагностики — без заголовков, query, тела и ключей; ошибка наблюдателя не влияет на запрос.

Консоль. Текст выбирается по коду и причине, затем по коду, затем по статусу (для ответов прокси), никогда по тексту ошибки. Под сообщением одна строка .error-ref: «Повторите через N с» и выделяемый одним щелчком ID запроса — одинаково для строки состояния, панелей, ManagementTask, строк загрузок и состояния передачи. Поля из details (и локальной проверки) получают aria-invalid, связанное сообщение через aria-describedby, фокус; правка поля снимает отметку. Подробности — CONSOLE_UX.

CLI. Текст: код CLI, HTTP-статус, code/reason и сообщение сервера (одна строка без управляющих символов, до 200 знаков), объяснение, Retry-After и ID запроса. JSON сохраняет code, status, exitCode, stage, artifactId и добавляет serverCode, reason, message, requestId, details, retryAfterSeconds. Коды выхода 2/3/4/6/7/130 сохранены; integrity_mismatch/422 — 5, capacity_exceeded/507 — новый 8. --verbose пишет в stderr метод, путь, статус, длительность и ID запроса. CLI.

Изменения статусов и совместимость ​

СитуацияБылоСтало
Неизвестный маршрут без учётных данных401 unauthorized404 not_found/route_not_found
Неизвестный маршрут с учётными данными404, тело Fastify с URL404 конверт без URL
Известный путь, неверный метод404405 invalid_input/method_not_allowed + Allow
Тело больше 64 KiB400 invalid_input413 invalid_input/body_too_large
Неподдерживаемый Content-Type400 invalid_input415 invalid_input/unsupported_media_type
Неудовлетворимый Range416 без тела416 с JSON-конвертом
CLI: capacity_exceeded, 422exit 4exit 8 и 5
SDK: ответ прокси без тела, X-Request-IdrequestId пустойrequestId из заголовка
Домен: ThrottledError.codebusy (wire уже 429)rate_limited

Остальные ответы меняются аддитивно: новые поля необязательны, прежние значения code для прежних ситуаций не изменились. Клиенты, обрабатывающие 400 как общий отказ ввода, должны также трактовать 413/415 как отказ ввода; SDK по-прежнему повторяет только 408/429/502/503/504.

Альтернативы ​

  • Новые коды method_not_allowed, range_not_satisfiable, payload_too_large: расширяли бы enum ради форм запроса; причина при invalid_input сохраняет смысл «ошибка запроса» и отдельный HTTP-статус.
  • RFC 9457 application/problem+json: смена media type и обязательных полей ломает существующих клиентов; поля details/reason дают ту же машинную информацию в прежнем конверте.
  • Различать истёкшую сессию по времени создания в токене: потребовало бы смены формата токенов и миграции.
  • Отражать в details значение поля: облегчает отладку, но рискует вернуть секреты (пароли, ключи) в журналы клиентов.

Последствия ​

Изменение схемы БД не требуется. Новые причины добавляются аддитивно в оба набора и в документацию; изменение смысла кода или удаление причины требует нового ADR. Истёкшие сессии удаляются при следующем входе пользователя, после этого их токены читаются как credential_invalid. Ошибки неверного URL (FST_ERR_BAD_URL) по-прежнему формирует Fastify: их обработка относится к созданию HTTP-сервера и не входит в это изменение.

Проверка ​

Unit: tests/error-contract.test.mjs (наборы, HTTP-маппинг, 404/405, SDK), tests/web-errors.test.mjs (errorKey, consoleRunner), tests/cli-errors.test.mjs (коды выхода, JSON, текст, --verbose), tests/sdk-compatibility.test.mjs с tests/fixtures/sdk-consumer.ts. PostgreSQL/HTTP: tests/integration/error-contract.test.mjs. Браузер: tests/browser/errors.mjs, дополненные session.mjs и build-details.mjs.

Arkvory · ProAnimaStudio