Error & Failure Codes
The three error systems — the HTTP AppError catalog (29 singletons), the Err* control-flow sentinels grouped by subsystem, and the client-facing FailureCode enum with vendor mappings — plus the duplicate frameworks, verbatim leaks, and dead gRPC block.
Three independent error systems: the HTTP API errors clients see, the Err* sentinels used for internal control flow, and the FailureCode enum surfaced to customers on failed top-up/eSIM orders. See API Reference → errors for the envelope in context.
1. HTTP API errors (AppError)
Envelope rendered by middleware.CustomErrorHandler:
{ "error": { "name": "...", "code": "...", "message": "...",
"messages": { "field": [{ "rule": "...", "field": "...", "message": "..." }] } } }Two parallel copies of the error framework
The web package (web/error.go) and the entity package (http/entity/error.go) both define near-identical ErrorResponse / AppError / ParseValidationErrors / NewAppError. The catalog singletons are built with entity.NewAppError, but the HTTP handler renders via web.* types. One of these should be deleted.
The catalog (errors/errors.go)
29 singletons. Builder methods (WithMessage/WithDetails/WithError) are copy-on-write — they clone() then override one field, because the catalog values are package-level singletons shared across every request (mutating the receiver would leak per-request state across goroutines — a real fuzz-caught bug).
| Constant | Status | code | name |
|---|---|---|---|
ValidationError | 400 | E_VALIDATION_FAILURE | ValidationException |
ValidationExceptionError | 400 | VALIDATION_FAILURE | ValidationException |
ThrottlingError / TooManyRequestsError | 429 | E_REQUEST_THROTTLED / TOO_MANY_REQUESTS | — |
InvalidInputError / BadRequestError / SyntaxError | 400 | INVALID_INPUT / BAD_REQUEST / SYNTAX_ERROR | — |
NotFoundError | 404 | NOT_FOUND | NotFoundError |
WalletNotFoundError / UserNotFoundError | 404 | WALLET_NOT_FOUND / USER_NOT_FOUND | — |
ProductNotFoundError | 400 | NOT_FOUND | ProductNotFoundException |
DenominationNotFound | 400 | NOT_FOUND | DenominationNotFoundException |
InvalidStatusError / InvalidFeatureError | 400 | INVALID_STATUS / INVALID_FEATURE | — |
InternalServerError / InternalDatabaseError | 500 | INTERNAL_SERVER_ERROR / INTERNAL_DATABASE_ERROR | — |
UnauthorizedAccessError / UnauthorizedError | 401 | E_UNAUTHORIZED_ACCESS / UNAUTHORIZED | AuthenticationException / — |
InvalidCredentialsError | 401 | INVALID_CREDENTIALS | — |
PaymentRequiredError | 402 | PAYMENT_REQUIRED | — |
ForbiddenError / ForbiddenActionError | 403 | FORBIDDEN / FORBIDDEN_ACTION | — |
MethodNotAllowedError | 405 | METHOD_NOT_ALLOWED | — |
OperationTimeoutError | 408 | OPERATION_TIMEOUT | — |
ResourceConflictError / ConflictError | 409 | RESOURCE_CONFLICT / CONFLICT | — |
GoneError | 410 | GONE | — |
ServiceUnavailableError | 503 | SERVICE_UNAVAILABLE | — |
ResourceNotAvailableError | 404 | RESOURCE_NOT_AVAILABLE | — |
Validation errors expand via ParseValidationErrors (go-playground validator → per-field {rule, field, message} with hand-written copy per tag). 404 + Accept: text/html renders an HTML page instead of JSON.
Inconsistencies & leaks
- Two validation errors, same
namedifferentcode:ValidationError(E_VALIDATION_FAILURE, 13 uses) vsValidationExceptionError(VALIDATION_FAILURE, 117 uses) — clients see inconsistent codes for the same class. - Two 500 codes: the non-AppError default path emits
INTERNAL_ERROR(middleware/error.go:23), but theInternalServerErrorsingleton isINTERNAL_SERVER_ERROR. ProductNotFoundError/DenominationNotFoundsay "NotFound" with codeNOT_FOUNDbut return 400, not 404.- Verbatim leaks: a non-AppError's raw
err.Error()goes intomessages.details[0].messageand is returned to the client (middleware/error.go:29); plus deliberate.WithMessage(err.Error())attopup.go:455/484,esim.go:89,payout.go:61/201,client_ip_whitelist.go:159/254. - Error-string matching for control flow (fragile):
strings.Contains(err.Error(), "key not found")across ~25 cache sites;err.Error() == "payout not found";"not found"/"duplicate"/"unique"/"access denied"substring checks in several admin handlers.
There is a separate gRPC error layer (GRPCError, WithValidationError) plus a set of "PLUTUS" sentinels (ErrNotFound, ErrInsufficientPoints, …) in errors/errors.go — entirely dead (zero callers outside the file). Ignore for the live HTTP surface.
2. Sentinel errors (Err*)
Internal control-flow signals, compared with errors.Is. Grouped by subsystem.
Order/topup/eSIM create (services/topup_order.go:442)
| Sentinel | Signals | Caught → action |
|---|---|---|
ErrNoVariantAvailable | no variant for product+amount | → NotFoundError (404) |
ErrWalletNotFound | no wallet for currency | → NotFoundError (404) |
ErrWalletCurrencyMismatch | wallet currency ≠ variant currency | ⚠️ caught by no handler → default 500 (should be 4xx) |
ErrInsufficientBalance | balance too low | → ValidationExceptionError (400) |
ErrDuplicateClientRef | (client_id, client_reference) unique-violation | → BadRequestError (400) |
Voucher redemption (services/voucher_redemption.go:23)
ErrVoucherCodeNotFound / ErrVoucherNotRedeemable / ErrVoucherAlreadyRedeemed → all 400. ErrOrderItemAlreadyRedeemed (repo/order_item.go:1546) guards the atomic single-winner redemption claim — see Wallet → prepaid.
Vendor orchestration (the retry engine)
| Sentinel | Signals | Caught → action |
|---|---|---|
ErrShouldReCreate (external_vendors/types/common.go:27) | vendor's accepted order will never deliver → clear vendor_order_id, re-CREATE next retry (order stays PENDING) | checkpoints route to CP2 CREATE (budget 5) |
ErrOrderNotFound | GetOrderStatus can't find the order at the vendor | promoted to ErrShouldReCreate (vouchers) / Phase-2 recreate (topup/eSIM) |
ErrDuplicateMerchantRef | CREATE rejected — merchant ref already on file (DT One 1007001, SEAGM 20135) | poll existing order & adopt status |
ErrVendorOrderTerminal (create_voucher_order.go:40) | vendor order terminal FAILED/CANCELLED; do NOT retry | mark items FAILED |
See State Machines → item checkpoints for how these drive the checkpoint transitions.
ErrOrderNotFound is defined twice — types/common.go:11 (vouchers) and direct_topup/types/types.go:12 (topup/eSIM) — identical strings, distinct values, so a voucher errors.Is won't match a topup one (deliberate, relies on promotion glue). Also: vendor adapters transport the sentinel both as a typed error and as a .Error string on the response — the string form is the wire contract for that field.
Wallet / forex / notifier
ErrForexNotAvailable (order_charges.go:14) → BadRequestError (400). Notifier guards ErrEmailTemplateNotImplemented / ErrNotTerminalState / ErrNoEmailAddress → skip/soft-ignore (Notifications).
3. Client-facing FailureCode (top-up/eSIM)
type FailureCode string (external_vendors/types/failure_codes.go:41). Set only when an order terminates FAILED (transient failures stay PENDING and are retried). Surfaces on the recharge/esim row (failure_code, failure_reason, failure_is_user_fixable), on webhook payloads (topup.failed/esim.failed only), and on API responses.
IsUserFixable() is the single source of truth (failure_codes.go:180); defaultReason gives customer-safe copy surfaced verbatim, overridable per vendor.
| Code | Meaning | User-fixable | Example vendor mapping |
|---|---|---|---|
INVALID_RECIPIENT | recipient id malformed/unknown | yes | DT One 30200/90200; SEAGM 20133/34; Wupex HTTP 400 |
RECIPIENT_BARRED | operator blocked recipient | no | DT One 30201/90201; SEAGM 20116 |
RECIPIENT_INELIGIBLE | recipient can't get this plan | yes | DT One 30202/90202 |
RECIPIENT_LIMIT_EXCEEDED | per-recipient cap | no | DT One 30300/90300 |
OPERATOR_UNAVAILABLE | operator platform down | no | DT One 30400/90400; SEAGM 20130/31 |
OPERATOR_LIMIT_EXCEEDED | operator-wide cap | no | DT One 90204/90350 |
CLIENT_LIMIT_EXCEEDED | client account cap | no | DT One 90310/90370; SEAGM 20021/20115 |
PRODUCT_UNAVAILABLE | product delisted/restricted | yes | DT One 1003001/1005004; SEAGM 20051/20062 |
PRODUCT_OUT_OF_STOCK | inventory depleted | yes | SEAGM HTTP416/20107/20125 (DT One: n/a) |
AMOUNT_OUT_OF_RANGE | amount not in allowed denominations | yes | DT One 1003002/03; SEAGM 20018/19 |
UNKNOWN | retry budget exhausted / uncategorised | no | DT One 80000/90000; SEAGM 20060/61; adapter fallback |
Normalization: each vendor maps its raw codes to a FailureCode via an error_messages.go table (SEAGM map[int], DT One sub-status table, Wupex). Unmapped → UNKNOWN. The retry engine short-circuits: a PENDING recharge already carrying FailureIsUserFixable && FailureCode != "" is funneled straight to FAILED + auto-refund instead of polling again.
The DT One eSIM adapter re-declares its own FailureInfo struct with a standalone IsUserFixable bool field instead of deriving from FailureCode.IsUserFixable() — bypassing the single-source-of-truth design and risking drift. See DT One eSIM.
Key files
- HTTP:
errors/errors.go,web/error.go,http/entity/error.go,middleware/error.go - Sentinels:
services/topup_order.go:442,services/voucher_redemption.go:23,services/external_vendors/types/common.go,direct_topup/types/types.go,http/handler/create_voucher_order.go:40 - FailureCode:
services/external_vendors/types/{failure_codes,failure_info}.go; per-vendor.../{seagm,dtone,wupex}/error_messages.go
Client API Reference (/api/v1)
The machine-facing REST API clients integrate against — every /api/v1 endpoint, request/response contracts, the two pagination conventions, the error envelope, idempotency semantics, and the async order model.
Auth & Access Control
Every way Octopus authenticates and authorizes — JWT (API), cookie sessions (admin), cookie-JWT (client portal), WebAuthn passkeys, TOTP 2FA, IP whitelisting, RBAC, and multi-tenant scoping — plus the security gaps to fix.