Перейти к содержанию

05 — Реестр кодов ошибок

Канонический реестр всех машино-читаемых кодов ошибок, которые Payment Manager отдаёт клиентам. Каждый код привязан к HTTP-статусу, описанию причины и точной строке в src/, где он выбрасывается. Используется Auth Center, Admin Panel и nginx-gateway для маппинга ошибок на UX-сообщения и retry-логику.

Назначение

Этот документ — единая точка истины для всех error.code полей, которые встречаются в HTTP-ответах PM. Зачем нужен:

  1. Клиенты не должны парсить message — он на английском, нестабильный и предназначен для логов. Решения принимаются только по code.
  2. Семейство HTTP-статуса определяет retry-стратегию: 4xx (кроме 408/409) — не ретраить; 5xx — ретраить с backoff; 409 — особый случай (см. ниже).
  3. При добавлении нового кода автор 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_FOUNDmessage=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):

  1. HTTP 2xx — успех, читать payload по контракту endpoint-а.
  2. HTTP 401/403 — не ретраить. Перепроверить HMAC-конфигурацию (401) или permissions service-key (403). Сообщение UNAUTHORIZED_ACTOR (cancel) — отдельный UX (показать пользователю «это не ваш платёж»).
  3. HTTP 404 — не ретраить. Сообщить пользователю «не найдено». Особый случай: INTENT_NOT_FOUND в confirm/cancel — intent больше не существует, перечитать список.
  4. HTTP 408 PENDING_EXPIRED — intent уже отвалится в FAILED через outbox-worker; не ретраить confirm, перечитать GET /intents/:id.
  5. HTTP 409 — не ретраить тот же запрос. Прочитать поле status/currentVersion из ответа и:
  6. ALREADY_PROCESSED/CANNOT_CANCEL — перечитать intent, не предлагать пользователю повторное действие.
  7. EXPIRED — предложить создать новый intent.
  8. VERSION_MISMATCH — перечитать intent, показать актуальный state.
  9. TRANSACTION_IN_PROGRESS — дождаться завершения предыдущей операции (через SSE intent.{id}).
  10. HTTP 422 — не ретраить без правки payload-а. Маппить error на конкретный UX:
  11. INSUFFICIENT_FUNDS → «недостаточно средств».
  12. LIMIT_EXCEEDED → читать detail.ruleName/limit для конкретного сообщения.
  13. IPPS_NOT_REGISTERED → запустить flow регистрации IPPS-wallet.
  14. VALIDATION_ERROR → баг клиента; показать generic ошибку и залогировать.
  15. HTTP 500 INTERNAL_ERROR / TB_TRANSFER_ERROR — единственная категория, где можно ретраить с экспоненциальным backoff. Но: для POST /intents обязательно с тем же idempotencyKey, иначе получим двойное списание.
  16. HTTP 503 — health-down. Клиент не должен показывать «попробуйте позже» — это задача nginx-gateway / k8s probe; обычные клиенты этот код получать не должны.

Правила расширения реестра

  1. Любой новый throw new PaymentError(...) (или подкласс) обязан попасть в этот документ — code, HTTP-status, причина, ссылка на строку.
  2. Не использовать произвольные строки в message как коды: клиенты парсят только error. Если нужен новый машинный код — заведи новый подкласс PaymentError с code в конструкторе.
  3. Не пере-использовать существующий код для нового семантического случая. Лучше завести новый код, даже если HTTP-статус совпадает.
  4. После добавления — обновить таблицу «код → endpoint» в этом файле и соответствующий endpoint-документ в ../../api/.
  5. Если ответ требует структурированных деталей (как у LIMIT_EXCEEDED), добавь поле detail в PaymentError и обработчик JSON.stringify уже умеет сериализовать bigint — см. src/server.ts:95-99.