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 unauthorized | 404 not_found/route_not_found |
| Неизвестный маршрут с учётными данными | 404, тело Fastify с URL | 404 конверт без URL |
| Известный путь, неверный метод | 404 | 405 invalid_input/method_not_allowed + Allow |
| Тело больше 64 KiB | 400 invalid_input | 413 invalid_input/body_too_large |
| Неподдерживаемый Content-Type | 400 invalid_input | 415 invalid_input/unsupported_media_type |
| Неудовлетворимый Range | 416 без тела | 416 с JSON-конвертом |
CLI: capacity_exceeded, 422 | exit 4 | exit 8 и 5 |
| SDK: ответ прокси без тела, X-Request-Id | requestId пустой | requestId из заголовка |
Домен: ThrottledError.code | busy (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.