OctoWiki

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).

ConstantStatuscodename
ValidationError400E_VALIDATION_FAILUREValidationException
ValidationExceptionError400VALIDATION_FAILUREValidationException
ThrottlingError / TooManyRequestsError429E_REQUEST_THROTTLED / TOO_MANY_REQUESTS
InvalidInputError / BadRequestError / SyntaxError400INVALID_INPUT / BAD_REQUEST / SYNTAX_ERROR
NotFoundError404NOT_FOUNDNotFoundError
WalletNotFoundError / UserNotFoundError404WALLET_NOT_FOUND / USER_NOT_FOUND
ProductNotFoundError400NOT_FOUNDProductNotFoundException
DenominationNotFound400NOT_FOUNDDenominationNotFoundException
InvalidStatusError / InvalidFeatureError400INVALID_STATUS / INVALID_FEATURE
InternalServerError / InternalDatabaseError500INTERNAL_SERVER_ERROR / INTERNAL_DATABASE_ERROR
UnauthorizedAccessError / UnauthorizedError401E_UNAUTHORIZED_ACCESS / UNAUTHORIZEDAuthenticationException / —
InvalidCredentialsError401INVALID_CREDENTIALS
PaymentRequiredError402PAYMENT_REQUIRED
ForbiddenError / ForbiddenActionError403FORBIDDEN / FORBIDDEN_ACTION
MethodNotAllowedError405METHOD_NOT_ALLOWED
OperationTimeoutError408OPERATION_TIMEOUT
ResourceConflictError / ConflictError409RESOURCE_CONFLICT / CONFLICT
GoneError410GONE
ServiceUnavailableError503SERVICE_UNAVAILABLE
ResourceNotAvailableError404RESOURCE_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 name different code: ValidationError (E_VALIDATION_FAILURE, 13 uses) vs ValidationExceptionError (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 the InternalServerError singleton is INTERNAL_SERVER_ERROR.
  • ProductNotFoundError/DenominationNotFound say "NotFound" with code NOT_FOUND but return 400, not 404.
  • Verbatim leaks: a non-AppError's raw err.Error() goes into messages.details[0].message and is returned to the client (middleware/error.go:29); plus deliberate .WithMessage(err.Error()) at topup.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.goentirely 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)

SentinelSignalsCaught → action
ErrNoVariantAvailableno variant for product+amountNotFoundError (404)
ErrWalletNotFoundno wallet for currencyNotFoundError (404)
ErrWalletCurrencyMismatchwallet currency ≠ variant currency⚠️ caught by no handler → default 500 (should be 4xx)
ErrInsufficientBalancebalance too lowValidationExceptionError (400)
ErrDuplicateClientRef(client_id, client_reference) unique-violationBadRequestError (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)

SentinelSignalsCaught → 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)
ErrOrderNotFoundGetOrderStatus can't find the order at the vendorpromoted to ErrShouldReCreate (vouchers) / Phase-2 recreate (topup/eSIM)
ErrDuplicateMerchantRefCREATE 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 retrymark items FAILED

See State Machines → item checkpoints for how these drive the checkpoint transitions.

ErrOrderNotFound is defined twicetypes/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.

CodeMeaningUser-fixableExample vendor mapping
INVALID_RECIPIENTrecipient id malformed/unknownyesDT One 30200/90200; SEAGM 20133/34; Wupex HTTP 400
RECIPIENT_BARREDoperator blocked recipientnoDT One 30201/90201; SEAGM 20116
RECIPIENT_INELIGIBLErecipient can't get this planyesDT One 30202/90202
RECIPIENT_LIMIT_EXCEEDEDper-recipient capnoDT One 30300/90300
OPERATOR_UNAVAILABLEoperator platform downnoDT One 30400/90400; SEAGM 20130/31
OPERATOR_LIMIT_EXCEEDEDoperator-wide capnoDT One 90204/90350
CLIENT_LIMIT_EXCEEDEDclient account capnoDT One 90310/90370; SEAGM 20021/20115
PRODUCT_UNAVAILABLEproduct delisted/restrictedyesDT One 1003001/1005004; SEAGM 20051/20062
PRODUCT_OUT_OF_STOCKinventory depletedyesSEAGM HTTP416/20107/20125 (DT One: n/a)
AMOUNT_OUT_OF_RANGEamount not in allowed denominationsyesDT One 1003002/03; SEAGM 20018/19
UNKNOWNretry budget exhausted / uncategorisednoDT 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

On this page