05 — Реестр кодов ошибок¶
Канонический реестр всех машино-читаемых кодов ошибок, которые Payment Manager отдаёт клиентам. Каждый код привязан к HTTP-статусу, описанию причины и точной строке в src/, где он выбрасывается. Используется Auth Center, Admin Panel и nginx-gateway для маппинга ошибок на UX-сообщения и retry-логику.
Назначение¶
Этот документ — единая точка истины для всех error.code полей, которые встречаются в HTTP-ответах PM. Зачем нужен:
- Клиенты не должны парсить
message— он на английском, нестабильный и предназначен для логов. Решения принимаются только поcode. - Семейство HTTP-статуса определяет retry-стратегию:
4xx(кроме408/409) — не ретраить;5xx— ретраить с backoff;409— особый случай (см. ниже). - При добавлении нового кода автор PR обязан внести запись в этот документ — иначе clients не смогут построить безопасный handler.
Формат ответа¶
Все ошибки PM отдаются единым обработчиком в src/server.ts:86-118. Тело ответа:
{
"error": "CODE_STRING",
"message": "Human-readable detail (English, for logs)",
"detail": { /* опционально, только для LIMIT_EXCEEDED */ }
}
Поля:
error(string, обязательно) — машинный код из реестра ниже. Только этим полем клиент принимает решения.message(string, обязательно) — текстовое описание. Может содержать имя аккаунта/сервиса/мерчанта. Не локализуется.detail(object, опционально) — структурированные данные. На сегодня заполняется только дляLIMIT_EXCEEDED(ruleName,window,limitType,limit,current,requested).
Особые ответы (исторические, не приведены к стандартному формату — поле error присутствует, но без message):
| Endpoint | Формат | Источник |
|---|---|---|
POST /intents/:id/confirm (409 ALREADY_PROCESSED) |
{ "error": "ALREADY_PROCESSED", "status": "<intent.status>" } |
src/intent/confirm-handler.ts:111 |
POST /intents/:id/confirm (409 EXPIRED) |
{ "error": "EXPIRED" } |
src/intent/confirm-handler.ts:114 |
POST /intents/:id/confirm (409 VERSION_MISMATCH) |
{ "error": "VERSION_MISMATCH", "currentVersion": <number> } |
src/intent/confirm-handler.ts:116 |
POST /intents/:id/cancel (4xx) |
{ "error": "<CODE>", "status"?: "<intent.status>" } |
src/intent/cancel-handler.ts:48-61 |
Клиенты должны быть готовы к обеим формам ({ error, message } и { error, status }/{ error, currentVersion }).
Validation (400)¶
Ошибки в теле запроса, заголовках или семантике входных данных. Не подлежат retry без правок payload-а.
| Code | HTTP | Описание | Типичная причина | Источник |
|---|---|---|---|---|
BAD_REQUEST |
400 | Generic-ошибка валидации входа на уровне обработчика (не Zod). | Отсутствует/некорректен X-User-Id, не указан fromAccountName/toAccountName для operationType, требующего его. |
src/shared/errors.ts:24-28 — класс BadRequestError. Использования: src/intent/handler.ts:113, src/intent/handler.ts:207, src/intent/handler.ts:226, src/intent/cancel-handler.ts:39, src/accounts/register-ipps.ts:50. |
NO_ROUTE |
400 | Не нашли подходящий route/PSP для пары (channel, currency) или operationType. |
OperationType не зарегистрирован, channel выключен, отсутствует mapping в rule-engine. | src/intent/errors.ts:3-7 — класс NoRouteError. |
CANCEL_NOT_SUPPORTED |
400 | Channel intent-а не поддерживает отмену через /intents/:id/cancel. |
Попытка отменить, например, INTERNAL_P2P-intent после settle (поддерживаются только pending-channel'ы). |
src/intent/cancel-handler.ts:53. |
PAYER_ACCOUNT_USER_MISMATCH |
400 | На POST /intents/:id/confirm: найденный счёт принадлежит другому userId, не равному payerUserId в body. |
Клиент передал чужой счёт; защита от подмены payer. Сейчас передаётся через BadRequestError как message — в HTTP-ответе error: "BAD_REQUEST", message PAYER_ACCOUNT_USER_MISMATCH. |
src/intent/confirm-handler.ts:81. |
Zod-валидация request body обрабатывается в общем error-handler-е и возвращает 422 VALIDATION_ERROR (см. секцию Server/Validation ниже). Это сделано, чтобы отличать «синтаксис не прошёл схему» от «семантика конфликтует с состоянием».
Auth (401 / 403)¶
Ошибки аутентификации (HMAC/Bearer) и авторизации (permissions на service-key).
| Code | HTTP | Описание | Типичная причина | Источник |
|---|---|---|---|---|
UNAUTHORIZED |
401 | HMAC-подпись отсутствует, неверна, или service-id неизвестен; Bearer-токен админа отсутствует/невалиден. | Не выставлены заголовки X-Service-Id/X-Timestamp/X-Signature, расхождение timestamp за пределами окна, неверный HMAC-секрет, неизвестный service-id в pm.service_key. |
src/shared/errors.ts:12-16 — класс UnauthorizedError. Использования: src/auth/hmacPlugin.ts:17, src/auth/hmacPlugin.ts:23, src/auth/hmacPlugin.ts:41, src/auth/adminPlugin.ts:14, src/auth/adminPlugin.ts:21. |
FORBIDDEN |
403 | Service-key аутентифицирован, но не имеет permission на запрошенную операцию. | Service-key неактивен (active=false); operationType не входит в permissions.allowedOperationTypes; нет forceResolve; запрошен override fromAccountName/toAccountName/toTbAccountId без соответствующего permission. |
src/shared/errors.ts:65-69 — класс ForbiddenError. Использования: src/intent/handler.ts:161, src/intent/handler.ts:164, src/intent/handler.ts:198, src/intent/handler.ts:214, src/intent/handler.ts:231, src/admin/resolve-intent.ts:55, src/accounts/register-ipps.ts:62, src/ledger/routes.ts:73, src/ledger/routes.ts:88. |
UNAUTHORIZED_ACTOR |
403 | На POST /intents/:id/cancel: X-User-Id не совпадает с intent.payerUserId. |
Пользователь пытается отменить чужой intent. Отдаётся напрямую через reply.status(403), без PaymentError-обёртки. |
src/intent/cancel-handler.ts:48. |
Реестр HMAC-сервисов и их разрешённых operationType-ов — в ./06-service-keys.md.
Not Found (404)¶
Ресурс не существует в БД PM.
| Code | HTTP | Описание | Типичная причина | Источник |
|---|---|---|---|---|
NOT_FOUND |
404 | Generic-not-found из обработчиков (intent, account по name, прочие). | Intent с указанным id отсутствует в pm.intent. Также используется как родовая ошибка в confirm-handler и cancel-handler (см. INTENT_NOT_FOUND ниже — тот же class, специальный message). |
src/shared/errors.ts:71-75 — класс NotFoundError. Использования: src/intent/handler.ts:472, src/intent/handler.ts:516, src/admin/resolve-intent.ts:65, src/ledger/routes.ts:156. |
INTENT_NOT_FOUND |
404 | На POST /intents/:id/confirm или /cancel: intent не найден по id. |
Клиент передал чужой/несуществующий UUID, либо intent был удалён (что не должно происходить — intent immutable после создания). Это NotFoundError с message INTENT_NOT_FOUND — общий error-handler выставит error: "NOT_FOUND" в payload, но статус будет 404. Внимание: message клиента не должен использоваться как код. |
src/intent/confirm-handler.ts:58, src/intent/confirm-handler.ts:109, src/intent/cancel-handler.ts:42. |
PAYER_ACCOUNT_NOT_FOUND |
404 | На POST /intents/:id/confirm: счёт-источник для invoice (payerUserId → wallet) не найден в pm.tb_account_map. |
Auth Center передал payerUserId, у которого нет зарегистрированного wallet в нужной валюте/channel. Сейчас передаётся через NotFoundError как message — в HTTP-ответе error: "NOT_FOUND", message PAYER_ACCOUNT_NOT_FOUND. |
src/intent/confirm-handler.ts:80. |
Важно про
INTENT_NOT_FOUND/PAYER_ACCOUNT_NOT_FOUND/PAYER_ACCOUNT_USER_MISMATCH: эти строки сейчас передаются вNotFoundError/BadRequestErrorкак message, поэтому в HTTP-ответеerrorбудет"NOT_FOUND"/"BAD_REQUEST", а сама строка — вmessage. Клиенты, которые хотят отличать «intent не найден» от «другой 404», должны парситьmessage. См. issue в backlog: «нормализоватьNotFoundErrorдля confirm/cancel — отдаватьerror: INTENT_NOT_FOUND».
Conflict (409)¶
Запрос корректен, но конфликтует с текущим состоянием ресурса. Retry без изменения состояния не имеет смысла.
| Code | HTTP | Описание | Типичная причина | Источник |
|---|---|---|---|---|
CONFLICT |
409 | Generic-конфликт (родовой ConflictError). |
Сейчас напрямую не используется в обработчиках; зарезервирован для будущих use-case'ов (см. класс). | src/shared/errors.ts:18-22 — класс ConflictError. |
TRANSACTION_IN_PROGRESS |
409 | Для пользователя уже выполняется платёж (advisory lock или активный pending intent). | Двойной клик на «Оплатить» — клиент должен дождаться завершения предыдущего intent-а. | src/shared/errors.ts:59-63 — класс TransactionInProgressError. |
ALREADY_PROCESSED |
409 | На POST /intents/:id/confirm: intent уже в терминальном состоянии (SETTLED/FAILED/CANCELED). |
Повторный confirm после успешного — нужно прочитать status из ответа и не ретраить. |
src/intent/confirm-handler.ts:111. |
EXPIRED |
409 | На POST /intents/:id/confirm: intent в статусе EXPIRED (TTL invoice истёк до confirm). |
Пользователь долго думал над QR — нужно создать новый intent. | src/intent/confirm-handler.ts:114. |
VERSION_MISMATCH |
409 | На POST /intents/:id/confirm: optimistic-lock не сработал (expectedVersion != intent.version). |
Параллельный confirm от двух клиентов — клиент должен перечитать intent и решить, продолжать ли. | src/intent/confirm-handler.ts:116. |
CANNOT_CANCEL |
409 | На POST /intents/:id/cancel: intent в нефинальном для cancel-а статусе (SETTLED, FAILED, CANCELED). |
Попытка отменить уже завершённый платёж — нужно прочитать status из ответа. |
src/intent/cancel-handler.ts:61. |
Семантика 409: ни один из этих кодов не должен ретраиться без явного действия пользователя или чтения свежего состояния.
idempotencyKeyнаPOST /intentsтоже отдаёт 200/409, но это не ошибка, а штатное поведение dedup-а.
Server / Validation Errors (422 / 500 / 503 / 408)¶
Ошибки, связанные с обработкой запроса, бизнес-инвариантами и внешними системами. Часть из них (422) семантически ближе к «4xx — клиентская проблема», часть (5xx) — серверная.
Бизнес-инварианты (422)¶
| Code | HTTP | Описание | Типичная причина | Источник |
|---|---|---|---|---|
VALIDATION_ERROR |
422 | Тело запроса не прошло Zod-схему, или fastify-валидация JSON Schema выявила issue. | Отсутствует обязательное поле, неверный формат UUID/UUID, отрицательное число там, где нужно int().positive(). Также — fee-rule expression вернула некорректную форму. |
src/shared/errors.ts:30-34 — класс ValidationError. Также выбрасывается в общем error-handler-е для Zod-issues: src/server.ts:102-111. Использования: src/admin/fee-rules.ts:51, src/ledger/routes.ts:64, src/ledger/routes.ts:68. |
ACCOUNT_NOT_FOUND |
422 | Счёт, указанный в fromAccountName/toAccountName/toTbAccountId/fee-split, отсутствует в pm.tb_account_map. |
Имя счёта набрано с опечаткой; пользователь не зарегистрировал IPPS-кошелёк; transit-счёт для (channel, currency) не создан admin-tool-ом. | src/intent/errors.ts:9-13 — класс AccountNotFoundError. Использования: src/intent/handler.ts:201, src/intent/handler.ts:217, src/intent/handler.ts:244, src/intent/handler.ts:245, src/intent/handler.ts:246, src/intent/handler.ts:344. |
INSUFFICIENT_FUNDS |
422 | На balance-check перед TB pending transfer: debits_pending + amount > credits_posted (с учётом fee-splits). |
Недостаточно средств на счёте-источнике (в TB-flags.debits_must_not_exceed_credits). |
src/shared/errors.ts:36-40 — класс InsufficientFundsError. |
LIMIT_EXCEEDED |
422 | Лимит политики (auth_policies или operationType limits) превышен. | Пользователь превысил daily/monthly cap (amount или count). detail содержит ruleName, window, limit, current, requested. |
src/shared/errors.ts:51-57 — класс LimitExceededError. Использования: src/limits/check-limits.ts:46, src/limits/check-limits.ts:78, src/limits/check-limits.ts:85. |
IPPS_NOT_REGISTERED |
422 | Пользователь пытается выполнить IPPS-операцию, но у него нет зарегистрированного IPPS-wallet. | pm.tb_account_map не содержит записи userId=<...>, channel=IPPS. Нужно сначала POST /accounts/register-ipps. |
src/intent/errors.ts:28-36 — класс IppsNotRegisteredError. |
IPPS_METADATA_INVALID |
422 | На creation IPPS-intent: payload metadata невалиден (например, отсутствует bankCode или proxyType для on-us). |
Клиент не передал обязательные поля для конкретного IPPS-suboperation. | src/intent/errors.ts:38-42 — класс IppsMetadataInvalidError. |
EVAL_ERROR |
422 | На POST /admin/fee-rules/dry-run: fee expression бросила исключение или вернула не-массив. |
Опечатка в JavaScript-выражении, обращение к несуществующему context-полю. | src/admin/fee-rules.ts:65. |
WRONG_STATUS |
422 | На POST /admin/intents/:id/resolve: intent не в MANUAL_REVIEW статусе. |
Админ пытается force-resolve intent, который уже settled/failed (idempotent ветка 200) или ещё в pending. | src/admin/resolve-intent.ts:80. |
TigerBeetle / TimeOut (408 / 500)¶
| Code | HTTP | Описание | Типичная причина | Источник |
|---|---|---|---|---|
PENDING_EXPIRED |
408 | TB pending transfer истёк до того, как pm-saga успела вызвать post_pending_transfer. |
Adapter не вернул результат за timeout pending-trasfer-а (по умолчанию ~30 мин); либо outbox-worker отстал. |
src/intent/errors.ts:22-26 — класс PendingExpiredError. |
TB_TRANSFER_ERROR |
500 | TB вернул не-ok статус при createTransfers. |
Нарушение TB-инвариантов: debits_must_not_exceed_credits, linked_event_chain_open, дубликат transfer-id. message содержит список TB-статусов. |
src/intent/errors.ts:15-20 — класс TBTransferError. |
INTERNAL_ERROR |
500 | Unhandled exception, не пойманный ни одним конкретным error-классом. | Баг в коде; PostgreSQL connection потерян; Redis недоступен. В dev-режиме message содержит детали, в production — статичную строку «Internal server error». |
src/server.ts:112-117 — общий fallback. |
Health (503)¶
| Code | HTTP | Описание | Источник |
|---|---|---|---|
(none — формат { status: 'ko', message }) |
503 | Health-probe вернула неуспех: либо app.isUnderPressure() (event-loop latency > 1s), либо TigerBeetle недоступен. |
src/admin/health.ts:23, src/admin/health.ts:30. |
Health-endpoint исторически использует другой формат ответа ({ status, message }, без error), так как читается liveness/readiness-проверками nginx/k8s, а не клиентами.
Сводная таблица: код → endpoint¶
Краткий cross-reference: в каком endpoint-е какой код может быть возвращён. Полный список — в ../../api/ per-endpoint.
| Endpoint | Возможные error.code |
|---|---|
POST /intents |
BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NO_ROUTE, ACCOUNT_NOT_FOUND, INSUFFICIENT_FUNDS, LIMIT_EXCEEDED, IPPS_NOT_REGISTERED, IPPS_METADATA_INVALID, VALIDATION_ERROR, TB_TRANSFER_ERROR, INTERNAL_ERROR |
POST /intents/quote |
BAD_REQUEST, UNAUTHORIZED, NO_ROUTE, VALIDATION_ERROR, INTERNAL_ERROR |
POST /intents/:id/confirm |
UNAUTHORIZED, NOT_FOUND (с message=INTENT_NOT_FOUND/PAYER_ACCOUNT_NOT_FOUND), BAD_REQUEST (PAYER_ACCOUNT_USER_MISMATCH), ALREADY_PROCESSED, EXPIRED, VERSION_MISMATCH, TB_TRANSFER_ERROR, INTERNAL_ERROR |
POST /intents/:id/cancel |
BAD_REQUEST, UNAUTHORIZED, UNAUTHORIZED_ACTOR, NOT_FOUND (INTENT_NOT_FOUND), CANCEL_NOT_SUPPORTED, CANNOT_CANCEL, INTERNAL_ERROR |
GET /intents/:id |
UNAUTHORIZED, NOT_FOUND, INTERNAL_ERROR |
POST /policies/evaluate |
UNAUTHORIZED, VALIDATION_ERROR, INTERNAL_ERROR |
POST /admin/intents/:id/resolve |
UNAUTHORIZED, FORBIDDEN, NOT_FOUND, WRONG_STATUS, INTERNAL_ERROR |
POST /admin/fee-rules/dry-run |
UNAUTHORIZED, VALIDATION_ERROR, EVAL_ERROR, INTERNAL_ERROR |
POST /accounts/register-ipps |
BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND (USER_NOT_FOUND), INTERNAL_ERROR |
GET /healthz, GET /readyz |
(не в формате PM-error; { status: 'ok'|'ko' }) |
Примеры ответов¶
Конкретные тела HTTP-ответов, чтобы клиенты могли построить точные парсеры. Все примеры — фактические, по коду из src/server.ts и handler-ов.
1. LIMIT_EXCEEDED — с detail¶
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": "LIMIT_EXCEEDED",
"message": "Payment limit exceeded",
"detail": {
"ruleName": "daily_p2p_cap",
"window": "1d",
"limitType": "amount",
"limit": "5000000",
"current": "4800000",
"requested": "300000"
}
}
Все числовые значения в detail — строки, так как исходно bigint. Сериализатор в src/server.ts:96-98 принудительно конвертирует bigint в string, чтобы payload оставался валидным JSON.
2. ACCOUNT_NOT_FOUND — стандартный 422¶
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": "ACCOUNT_NOT_FOUND",
"message": "Account not found: user.1234.IPPS.THB"
}
3. ALREADY_PROCESSED — confirm-handler, нестандартное поле status¶
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "ALREADY_PROCESSED",
"status": "SETTLED"
}
Клиент Auth Center должен отличать эту форму от стандартной ({ error, message }) — поле status отсутствует в общем формате.
4. VERSION_MISMATCH — confirm-handler, поле currentVersion¶
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "VERSION_MISMATCH",
"currentVersion": 3
}
5. INTERNAL_ERROR в production¶
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"error": "INTERNAL_ERROR",
"message": "Internal server error"
}
В dev-режиме (NODE_ENV !== 'production') поле message будет содержать оригинальное error.message — это полезно для локальной отладки, но не должно ехать в production.
Рекомендации по обработке на клиенте¶
Минимальный алгоритм маппинга для клиентов (Auth Center, Admin Panel, Flutter):
- HTTP 2xx — успех, читать payload по контракту endpoint-а.
- HTTP 401/403 — не ретраить. Перепроверить HMAC-конфигурацию (401) или permissions service-key (403). Сообщение
UNAUTHORIZED_ACTOR(cancel) — отдельный UX (показать пользователю «это не ваш платёж»). - HTTP 404 — не ретраить. Сообщить пользователю «не найдено». Особый случай:
INTENT_NOT_FOUNDв confirm/cancel — intent больше не существует, перечитать список. - HTTP 408
PENDING_EXPIRED— intent уже отвалится в FAILED через outbox-worker; не ретраить confirm, перечитатьGET /intents/:id. - HTTP 409 — не ретраить тот же запрос. Прочитать поле
status/currentVersionиз ответа и: ALREADY_PROCESSED/CANNOT_CANCEL— перечитать intent, не предлагать пользователю повторное действие.EXPIRED— предложить создать новый intent.VERSION_MISMATCH— перечитать intent, показать актуальный state.TRANSACTION_IN_PROGRESS— дождаться завершения предыдущей операции (через SSEintent.{id}).- HTTP 422 — не ретраить без правки payload-а. Маппить
errorна конкретный UX: INSUFFICIENT_FUNDS→ «недостаточно средств».LIMIT_EXCEEDED→ читатьdetail.ruleName/limitдля конкретного сообщения.IPPS_NOT_REGISTERED→ запустить flow регистрации IPPS-wallet.VALIDATION_ERROR→ баг клиента; показать generic ошибку и залогировать.- HTTP 500
INTERNAL_ERROR/TB_TRANSFER_ERROR— единственная категория, где можно ретраить с экспоненциальным backoff. Но: дляPOST /intentsобязательно с тем жеidempotencyKey, иначе получим двойное списание. - HTTP 503 — health-down. Клиент не должен показывать «попробуйте позже» — это задача nginx-gateway / k8s probe; обычные клиенты этот код получать не должны.
Правила расширения реестра¶
- Любой новый
throw new PaymentError(...)(или подкласс) обязан попасть в этот документ — code, HTTP-status, причина, ссылка на строку. - Не использовать произвольные строки в
messageкак коды: клиенты парсят толькоerror. Если нужен новый машинный код — заведи новый подклассPaymentErrorсcodeв конструкторе. - Не пере-использовать существующий код для нового семантического случая. Лучше завести новый код, даже если HTTP-статус совпадает.
- После добавления — обновить таблицу «код → endpoint» в этом файле и соответствующий endpoint-документ в
../../api/. - Если ответ требует структурированных деталей (как у
LIMIT_EXCEEDED), добавь полеdetailвPaymentErrorи обработчикJSON.stringifyуже умеет сериализовать bigint — см.src/server.ts:95-99.