diff --git a/.changeset/api-cursor-pagination.md b/.changeset/api-cursor-pagination.md new file mode 100644 index 00000000..5e07cfa5 --- /dev/null +++ b/.changeset/api-cursor-pagination.md @@ -0,0 +1,22 @@ +--- +'lens': minor +--- + +Add cursor-and-limit pagination to every list route — `GET /pools`, +`GET /pairs`, `GET /candles/:assetA/:assetB` and `GET /prices/history` — matching +the convention `GET /screener` already used. Each accepts `?cursor=` and +`?limit=` (absent limit → the route default, out-of-range values clamped, a +non-numeric limit is a 400) and now returns `nextCursor` + `hasMore` beside its +own key (`pools`, `pairs`, `candles`, `points`), so an unpaged caller still gets +a sensible first page. `GET /prices/history?format=csv` carries the same +envelope in the `X-Next-Cursor` / `X-Has-More` response headers, keeping the CSV +body the byte-stable `ts,price,volume` contract. + +Pagination is keyset-based, not offset-based: the cursor is an opaque base64 +`[sortValue, id]` and the predicate is `(sort, id) > (value, id)` (or `<` for +descending), so a row inserted while a client is paging can never shift a page +boundary and cause a duplicate or a skipped row. The shared helpers live in +`src/pagination.ts`; the per-route default/maximum page sizes are 50/200 +(`/pools`, `/pairs`), 500/5000 (`/candles`), 1000/10000 (`/prices/history`) and +20/100 (`/screener`), documented in `openapi.yaml` and exercised by a +mid-page-insert regression test. diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index d167db35..cfdbc158 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -948,6 +948,16 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * @description Stable machine-readable error code. Branch on this, never on `error`, which is human-readable. + * @enum {string} + */ + ErrorCode: "missing_parameter" | "invalid_parameter" | "bad_network" | "bad_interval" | "bad_window" | "unsupported" | "unknown_pair" | "unwatched_pair" | "pair_already_watched" | "unauthorized" | "not_found" | "upstream_unavailable" | "internal_error" | "rate_limited" | "payment_required" | "payment_invalid" | "payment_verification_failed" | "quota_exceeded" | "admin_not_configured" | "admin_unauthorized" | "missing" | "malformed" | "unknown" | "revoked" | "expired" | "insufficient_scope" | "discovery_database_unavailable" | "VALIDATION_ERROR" | "UNSUPPORTED_PARAMETER" | "INVALID_NETWORK" | "STALE_DATA" | "INVALID_PRICE" | "INTERNAL_ERROR" | "DB_POOL_EXHAUSTED"; + Error: { + /** @description Human-readable error message. Do not branch on it. */ + error: string; + code: components["schemas"]["ErrorCode"]; + }; LendingRateEntry: { id: string; /** @example blend */ diff --git a/openapi.json b/openapi.json index 99ef01d8..57def8ee 100644 --- a/openapi.json +++ b/openapi.json @@ -4287,6 +4287,62 @@ } }, "schemas": { + "ErrorCode": { + "type": "string", + "description": "Stable machine-readable error code. Branch on this, never on `error`, which is human-readable.", + "enum": [ + "missing_parameter", + "invalid_parameter", + "bad_network", + "bad_interval", + "bad_window", + "unsupported", + "unknown_pair", + "unwatched_pair", + "pair_already_watched", + "unauthorized", + "not_found", + "upstream_unavailable", + "internal_error", + "rate_limited", + "payment_required", + "payment_invalid", + "payment_verification_failed", + "quota_exceeded", + "admin_not_configured", + "admin_unauthorized", + "missing", + "malformed", + "unknown", + "revoked", + "expired", + "insufficient_scope", + "discovery_database_unavailable", + "VALIDATION_ERROR", + "UNSUPPORTED_PARAMETER", + "INVALID_NETWORK", + "STALE_DATA", + "INVALID_PRICE", + "INTERNAL_ERROR", + "DB_POOL_EXHAUSTED" + ] + }, + "Error": { + "type": "object", + "required": [ + "error", + "code" + ], + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message. Do not branch on it." + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + } + } + }, "LendingRateEntry": { "type": "object", "required": [ diff --git a/openapi.yaml b/openapi.yaml index ab16755b..3cbd81ac 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3009,6 +3009,53 @@ components: example: 'public, max-age=300' schemas: + ErrorCode: + type: string + description: Stable machine-readable error code. Branch on this, never on `error`, which is human-readable. + enum: + - missing_parameter + - invalid_parameter + - bad_network + - bad_interval + - bad_window + - unsupported + - unknown_pair + - unwatched_pair + - pair_already_watched + - unauthorized + - not_found + - upstream_unavailable + - internal_error + - rate_limited + - payment_required + - payment_invalid + - payment_verification_failed + - quota_exceeded + - admin_not_configured + - admin_unauthorized + - missing + - malformed + - unknown + - revoked + - expired + - insufficient_scope + - discovery_database_unavailable + - VALIDATION_ERROR + - UNSUPPORTED_PARAMETER + - INVALID_NETWORK + - STALE_DATA + - INVALID_PRICE + - INTERNAL_ERROR + - DB_POOL_EXHAUSTED + Error: + type: object + required: [error, code] + properties: + error: + type: string + description: Human-readable error message. Do not branch on it. + code: + $ref: '#/components/schemas/ErrorCode' LendingRateEntry: type: object required: [id, venue, source, network, poolId, asset, supplyApy, borrowApy, timestamp] diff --git a/src/__tests__/benchmark.test.ts b/src/__tests__/benchmark.test.ts index 575099e8..defa1486 100644 --- a/src/__tests__/benchmark.test.ts +++ b/src/__tests__/benchmark.test.ts @@ -37,7 +37,7 @@ describe('GET /benchmark/:asset', () => { }) expect(res.statusCode).toBe(404) - expect(res.json()).toEqual({ error: 'Pair USDC/USD not watched' }) + expect(res.json()).toEqual({ error: 'Pair USDC/USD not watched', code: 'unwatched_pair' }) }) it('calculates benchmark deviation correctly when asset is assetB in the pair (e.g. USD/USDC)', async () => { diff --git a/src/__tests__/errorCodes.test.ts b/src/__tests__/errorCodes.test.ts new file mode 100644 index 00000000..4b4e5db4 --- /dev/null +++ b/src/__tests__/errorCodes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { readdirSync } from 'node:fs' +import YAML from 'js-yaml' +import { ERROR_CODES, ERROR_CODE_VALUES } from '../errors' + +function readSpec(): any { + const specPath = fileURLToPath(new URL('../../openapi.yaml', import.meta.url)) + const spec = YAML.load(readFileSync(specPath, 'utf8')) + return spec +} + +function collectCodesFromFile(filePath: string): string[] { + const content = readFileSync(filePath, 'utf8') + const codes: string[] = [] + const re1 = /apiError\(\s*['"]([a-z0-9_]+)['"]/g + let m: RegExpExecArray | null + while ((m = re1.exec(content))) { + codes.push(m[1]) + } + const re2 = /\bcode:\s*['"]([a-z][a-z0-9_]*)['"]/g + while ((m = re2.exec(content))) { + codes.push(m[1]) + } + return codes +} + +function getModuleFiles(): string[] { + const modules: string[] = [] + const routesDir = fileURLToPath(new URL('../routes', import.meta.url)) + for (const f of readdirSync(routesDir)) { + if (f.endsWith('.ts')) modules.push(fileURLToPath(new URL(`../routes/${f}`, import.meta.url))) + } + const apiFiles = ['rest.ts', 'history.ts', 'admin.ts', 'auth.ts', 'usage.ts'].map(f => + fileURLToPath(new URL(`../api/${f}`, import.meta.url)), + ) + modules.push(...apiFiles) + modules.push(fileURLToPath(new URL('../middleware/network.ts', import.meta.url))) + modules.push(fileURLToPath(new URL('../middleware/x402.ts', import.meta.url))) + modules.push(fileURLToPath(new URL('../app.ts', import.meta.url))) + return modules +} + +describe('error codes', () => { + it('documents the error code enum in openapi and keeps it in sync with ERROR_CODES', () => { + const spec = readSpec() + const enumVals = spec.components?.schemas?.ErrorCode?.enum as string[] | undefined + expect(enumVals).toBeDefined() + const expected = Object.keys(ERROR_CODES).sort() + const actual = [...(enumVals || [])].sort() + expect(actual).toEqual(expected) + }) + + it('no route/error-emitting module emits a code outside the enumerated set', () => { + const allowed = new Set(Object.keys(ERROR_CODES)) + const modules = getModuleFiles() + const all = modules.flatMap(collectCodesFromFile) + const bad = all.filter(c => !allowed.has(c)) + expect(bad, `Codes outside set: ${JSON.stringify(bad)}`).toEqual([]) + }) + + it('error code set is non-empty and matches values', () => { + expect(ERROR_CODE_VALUES.length).toBeGreaterThan(0) + expect(Object.keys(ERROR_CODES).length).toBe(ERROR_CODE_VALUES.length) + }) +}) diff --git a/src/__tests__/movers.test.ts b/src/__tests__/movers.test.ts index 9f17d2c0..402432a5 100644 --- a/src/__tests__/movers.test.ts +++ b/src/__tests__/movers.test.ts @@ -279,7 +279,7 @@ describe('GET /movers', () => { const res = await app.inject({ method: 'GET', url: '/movers' }) expect(res.statusCode).toBe(500) - expect(res.json()).toEqual({ error: 'Internal server error' }) + expect(res.json()).toEqual({ error: 'Internal server error', code: 'internal_error' }) expect(res.body).not.toMatch(/secret/) await app.close() diff --git a/src/api/admin.ts b/src/api/admin.ts index 77a3b4a4..387c895b 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -7,6 +7,7 @@ import { formatValidationError } from '../validation' import { hashApiKey, isAdminAuthorized, checkAdminAuth, invalidateApiKey } from './auth' import { copyUsageHistory } from '../x402/metering' +import { apiError } from '../errors' /** Generates a new opaque API key with a recognizable `lens_` prefix. */ export function generateApiKey(): string { return `lens_${randomBytes(24).toString('hex')}` @@ -136,7 +137,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { } const requestedLimit = Number(req.query.limit ?? 25) if (!Number.isInteger(requestedLimit) || requestedLimit < 1) { - return reply.status(400).send({ error: 'limit must be a positive integer' }) + return reply.status(400).send(apiError('invalid_parameter', 'limit must be a positive integer')) } const failures = await prisma.webhookDeliveryFailure.findMany({ orderBy: { createdAt: 'desc' }, @@ -224,7 +225,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { const revoked = req.query.revoked ?? 'false' if (limit === null || limit < 1 || offset === null || !['true', 'false'].includes(revoked)) { return reply.status(400).send({ - error: 'Invalid pagination or revoked filter. Use limit (1-100), offset (0 or greater), and revoked=true|false.', + ...apiError('invalid_parameter', 'Invalid pagination or revoked filter. Use limit (1-100), offset (0 or greater), and revoked=true|false.'), }) } @@ -299,7 +300,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { const { id } = req.params const existing = await prisma.apiKey.findUnique({ where: { id } }) if (!existing) { - return reply.status(404).send({ error: 'API key not found' }) + return reply.status(404).send(apiError('not_found', 'API key not found')) } if (existing.revokedAt) { return reply.status(200).send({ id, revoked: true, alreadyRevoked: true }) @@ -334,10 +335,10 @@ export async function registerAdminRoutes(app: FastifyInstance) { const { id } = req.params const oldKey = await prisma.apiKey.findUnique({ where: { id } }) if (!oldKey) { - return reply.status(404).send({ error: 'API key not found' }) + return reply.status(404).send(apiError('not_found', 'API key not found')) } if (oldKey.revokedAt) { - return reply.status(400).send({ error: 'Cannot rotate a revoked API key' }) + return reply.status(400).send(apiError('invalid_parameter', 'Cannot rotate a revoked API key')) } const defaultOverlap = parseInt(process.env.KEY_ROTATION_OVERLAP_HOURS ?? '24', 10) || 24 diff --git a/src/api/auth.ts b/src/api/auth.ts index f95b412e..6b82e3ca 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -7,6 +7,7 @@ import { prisma } from '../db' import { config } from '../config' import { api_key_auth_failures_total, admin_auth_failures_total, api_key_cache_lookups_total } from '../metrics' +import { apiError } from '../errors' const log = createLogger('auth') type ApiKeyAuthFailureReason = 'missing' | 'malformed' | 'unknown' | 'revoked' | 'expired' | 'insufficient_scope' @@ -518,7 +519,7 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { reply.header('Retry-After', retryAfterSec) return reply.status(429).send({ statusCode: 429, - error: 'Too Many Requests', + ...apiError('rate_limited', 'Too Many Requests'), message: `Daily rate limit of ${keyContext.ratePerDay} requests exceeded for this API key. Quota resets at UTC midnight.`, retryAfter: retryAfterSec, }) diff --git a/src/api/history.ts b/src/api/history.ts index 8b1b1652..2ff20158 100644 --- a/src/api/history.ts +++ b/src/api/history.ts @@ -12,6 +12,7 @@ import '../middleware/network' // declares req.network on the FastifyRequest typ import { findOverlappingGaps } from '../backfill/gaps' import { formatPrice, formatVolume } from '../pricing/precision' +import { apiError } from '../errors' /** Supported aggregation intervals → bucket width in seconds. */ export const HISTORY_INTERVAL_SECONDS: Record = { '1m': 60, @@ -158,26 +159,26 @@ export async function registerHistoryRoutes(app: FastifyInstance) { }>('/prices/history', { config: { scope: 'read:history' } }, async (req, reply) => { const { pair } = req.query if (!pair) { - return reply.status(400).send({ error: 'pair query parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'pair query parameter is required')) } // Validated before the DB round-trip so an unknown format never queries. const format = req.query.format ?? 'json' if (format !== 'json' && format !== 'csv') { - return reply.status(400).send({ error: 'format must be one of: json, csv' }) + return reply.status(400).send(apiError('invalid_parameter', 'format must be one of: json, csv')) } const interval = req.query.interval ?? '1m' const intervalSecs = HISTORY_INTERVAL_SECONDS[interval] if (!intervalSecs) { return reply.status(400).send({ - error: `interval must be one of: ${Object.keys(HISTORY_INTERVAL_SECONDS).join(', ')}`, + ...apiError('bad_interval', `interval must be one of: ${Object.keys(HISTORY_INTERVAL_SECONDS).join(', ')}`), }) } const parsedLimit = parseLimit(req.query.limit, HISTORY_DEFAULT_LIMIT, MAX_HISTORY_POINTS) if ('error' in parsedLimit) { - return reply.status(400).send({ error: parsedLimit.error }) + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) } const { limit } = parsedLimit @@ -185,12 +186,12 @@ export async function registerHistoryRoutes(app: FastifyInstance) { if (req.query.cursor) { const decoded = decodeCursor(req.query.cursor) if (decoded === null) { - return reply.status(400).send({ error: 'Invalid cursor format' }) + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) } if (decoded !== 'skip') { afterTs = new Date(String(decoded.value)) if (isNaN(afterTs.getTime())) { - return reply.status(400).send({ error: 'Invalid cursor format' }) + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) } } } @@ -199,20 +200,20 @@ export async function registerHistoryRoutes(app: FastifyInstance) { const to = req.query.to ? new Date(req.query.to) : new Date() if (isNaN(from.getTime())) { - return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be a valid ISO 8601 date')) } if (isNaN(to.getTime())) { - return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'to must be a valid ISO 8601 date')) } if (from.getTime() > to.getTime()) { - return reply.status(400).send({ error: 'from must be before to' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be before to')) } // Guard against unbounded scans: cap the number of buckets the window can produce. const spanSecs = (to.getTime() - from.getTime()) / 1000 if (spanSecs / intervalSecs > MAX_HISTORY_POINTS) { return reply.status(400).send({ - error: `requested window is too large for interval=${interval} (max ${MAX_HISTORY_POINTS} points); narrow the range or use a coarser interval`, + ...apiError('invalid_parameter', `requested window is too large for interval=${interval} (max ${MAX_HISTORY_POINTS} points); narrow the range or use a coarser interval`), }) } diff --git a/src/api/rest.ts b/src/api/rest.ts index 39496185..8b70d059 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -25,6 +25,7 @@ import { findOverlappingGaps } from '../backfill/gaps' import { getDepth, getImpact } from '../pricing/depth' import { getRetentionPath } from '../jobs/retentionStatus' +import { apiError } from '../errors' function makePairKey(a: string, b: string): string { return [a, b].sort().join('/') } @@ -219,14 +220,14 @@ export async function registerRESTRoutes(app: FastifyInstance) { const { assetA, assetB } = req.params const network = req.network ?? activeNetwork const pair = findPair(assetA, assetB, network) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + if (!pair) return reply.status(404).send(apiError('unwatched_pair', `Pair ${assetA}/${assetB} not watched on ${network}`)) const rawThreshold = req.query?.staleThreshold ?? req.query?.staleThresholdSeconds let staleThreshold: number | undefined if (rawThreshold !== undefined) { staleThreshold = parseInt(rawThreshold, 10) if (isNaN(staleThreshold) || staleThreshold <= 0) { - return reply.status(400).send({ error: 'staleThreshold must be a positive integer' }) + return reply.status(400).send(apiError('invalid_parameter', 'staleThreshold must be a positive integer')) } } @@ -280,10 +281,10 @@ export async function registerRESTRoutes(app: FastifyInstance) { const amount = parseFloat(req.query.amount ?? '1000') // Validate amount before the pair check: a malformed request parameter // is a 400 regardless of whether the pair is watched. - if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + if (isNaN(amount) || amount <= 0) return reply.status(400).send(apiError('invalid_parameter', 'amount must be a positive number')) const network = req.network ?? activeNetwork const pair = findPair(assetA, assetB, network) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + if (!pair) return reply.status(404).send(apiError('unwatched_pair', `Pair ${assetA}/${assetB} not watched on ${network}`)) const decimalFormat = resolveDecimalFormat(req) const routeInfo = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount, network) @@ -320,7 +321,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { } if (!['1m', '5m', '1h', '24h'].includes(window)) { - return reply.status(400).send({ error: 'window must be one of: 1m, 5m, 1h, 24h' }) + return reply.status(400).send(apiError('bad_window', 'window must be one of: 1m, 5m, 1h, 24h')) } const result = await pgPool.query( @@ -397,8 +398,8 @@ export async function registerRESTRoutes(app: FastifyInstance) { const network = req.network ?? activeNetwork const pair = findPair(assetA, assetB, network) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) - if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + if (!pair) return reply.status(404).send(apiError('unwatched_pair', `Pair ${assetA}/${assetB} not watched on ${network}`)) + if (isNaN(amount) || amount <= 0) return reply.status(400).send(apiError('invalid_parameter', 'amount must be a positive number')) // Depth reads are network-scoped on both legs (pool_snapshots and the // price_points subquery that names this pair's pools), so a mainnet pair @@ -435,7 +436,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { const { assetA, assetB } = req.params const network = req.network ?? activeNetwork const pair = findPair(assetA, assetB, network) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + if (!pair) return reply.status(404).send(apiError('unwatched_pair', `Pair ${assetA}/${assetB} not watched on ${network}`)) const thresholds = req.query.thresholds ? req.query.thresholds.split(',').map(Number) @@ -448,11 +449,11 @@ export async function registerRESTRoutes(app: FastifyInstance) { thresholds.some(value => !Number.isFinite(value) || value <= 0) || !Number.isFinite(liquidityFraction) || liquidityFraction <= 0 || liquidityFraction > 1 ) { - return reply.status(400).send({ error: 'thresholds must be positive numbers and liquidityFraction must be between 0 and 1' }) + return reply.status(400).send(apiError('invalid_parameter', 'thresholds must be positive numbers and liquidityFraction must be between 0 and 1')) } const impact = await getImpact(pair.pairKey, { thresholds, liquidityFraction }, network) - if (!impact) return reply.status(404).send({ error: `No AMM liquidity available for ${assetA}/${assetB}` }) + if (!impact) return reply.status(404).send(apiError('unknown_pair', `No AMM liquidity available for ${assetA}/${assetB}`)) return { assetA: pair.assetA.code, assetB: pair.assetB.code, pairKey: pair.pairKey, ...impact } } ) diff --git a/src/errors.ts b/src/errors.ts index 25a22b99..2fc1b1c6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -43,3 +43,120 @@ export function sendInternalError( return reply.status(500).send(createInternalErrorBody(correlationId)) } + +/** + * The single, enumerated set of stable machine-readable error codes. + * + * Every JSON error response the API emits carries a `code` beside its existing + * `error` string. `error` stays human-readable and may be reworded at any time; + * `code` is a contract a client branches on. Codes are declared here (and + * mirrored in `openapi.yaml`) rather than invented at each call site, so the set + * can be audited in one place and a typo fails to compile instead of shipping. + * + * Lowercase snake_case is deliberate: it matches the codes the Bazaar and x402 + * surfaces already expose (`rate_limited`, `unexpected_settle_error`, ...). + */ +export const ERROR_CODES = { + /** A required query/body/path parameter was not supplied. */ + missing_parameter: 'missing_parameter', + /** A supplied parameter was present but malformed, out of range, or unknown. */ + invalid_parameter: 'invalid_parameter', + /** `network` was not one of the supported networks (testnet, mainnet). */ + bad_network: 'bad_network', + /** An interval was not one of the supported values for the endpoint. */ + bad_interval: 'bad_interval', + /** A window was not one of the supported values for the endpoint. */ + bad_window: 'bad_window', + /** The request named something the API recognises but deliberately does not serve. */ + unsupported: 'unsupported', + /** No price/data exists for the requested pair or asset. */ + unknown_pair: 'unknown_pair', + /** The requested pair is not in the set this instance watches. */ + unwatched_pair: 'unwatched_pair', + /** The pair is already being watched (conflict). */ + pair_already_watched: 'pair_already_watched', + /** Credentials were missing or invalid. */ + unauthorized: 'unauthorized', + /** A referenced resource (e.g. an API key) does not exist. */ + not_found: 'not_found', + /** A dependency the endpoint relies on (database, venue, oracle) was unreachable. */ + upstream_unavailable: 'upstream_unavailable', + /** An unexpected server-side fault. */ + internal_error: 'internal_error', + /** The request was throttled by a quota or rate limit. */ + rate_limited: 'rate_limited', + /** Payment is required before the resource can be served. */ + payment_required: 'payment_required', + /** The supplied payment was rejected. */ + payment_invalid: 'payment_invalid', + /** The payment could not be verified because verification itself failed. */ + payment_verification_failed: 'payment_verification_failed', + /** The caller's quota is exhausted and further payment is required. */ + quota_exceeded: 'quota_exceeded', + + // Codes routes already emitted before this set existed. Clients may branch on + // them, so they are enumerated as-is rather than renamed. + /** Admin surface is disabled because ADMIN_TOKEN is not configured. */ + admin_not_configured: 'admin_not_configured', + /** The X-Admin-Token header was missing or wrong. */ + admin_unauthorized: 'admin_unauthorized', + /** API-key auth: no Authorization header. */ + missing: 'missing', + /** API-key auth: the Authorization header was not a Bearer token. */ + malformed: 'malformed', + /** API-key auth: the key does not exist. */ + unknown: 'unknown', + /** API-key auth: the key has been revoked. */ + revoked: 'revoked', + /** API-key auth: the key has expired. */ + expired: 'expired', + /** API-key auth: the key lacks the scope the route requires. */ + insufficient_scope: 'insufficient_scope', + /** The discovery search database query failed. */ + discovery_database_unavailable: 'discovery_database_unavailable', + /** Zod validation failure from `formatValidationError`. */ + VALIDATION_ERROR: 'VALIDATION_ERROR', + /** A parameter the endpoint recognises but does not support (e.g. screener market cap). */ + UNSUPPORTED_PARAMETER: 'UNSUPPORTED_PARAMETER', + /** /premium: `network` was not testnet or mainnet. */ + INVALID_NETWORK: 'INVALID_NETWORK', + /** /premium: a price input was too old to use. */ + STALE_DATA: 'STALE_DATA', + /** /premium: the on-chain price was not positive. */ + INVALID_PRICE: 'INVALID_PRICE', + /** /premium: an unexpected server-side fault. */ + INTERNAL_ERROR: 'INTERNAL_ERROR', + /** The database connection pool was exhausted. */ + DB_POOL_EXHAUSTED: 'DB_POOL_EXHAUSTED', +} as const + +/** A member of {@link ERROR_CODES}. */ +export type ErrorCode = keyof typeof ERROR_CODES + +/** Every code as a plain string array, for validation and spec generation. */ +export const ERROR_CODE_VALUES: readonly ErrorCode[] = Object.values(ERROR_CODES) + +const ERROR_CODE_SET: ReadonlySet = new Set(ERROR_CODE_VALUES) + +/** Narrows an unknown value to an {@link ErrorCode}. */ +export function isErrorCode(value: unknown): value is ErrorCode { + return typeof value === 'string' && ERROR_CODE_SET.has(value) +} + +/** The JSON body of every non-2xx error response. */ +export interface ApiError { + /** Human-readable message. Do not branch on this — branch on `code`. */ + error: string + /** Stable code from {@link ERROR_CODES}. */ + code: ErrorCode +} + +/** + * Builds an error body from the enumerated set. + * + * Typing `code` as {@link ErrorCode} is what stops a route inventing its own + * code: an unknown value is a compile error, not a runtime surprise. + */ +export function apiError(code: ErrorCode, message: string): ApiError { + return { error: message, code } +} diff --git a/src/middleware/network.ts b/src/middleware/network.ts index 8519559f..0a9dbe5d 100644 --- a/src/middleware/network.ts +++ b/src/middleware/network.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import fp from 'fastify-plugin' import { activeNetwork, type NetworkName } from '../config' +import { apiError } from '../errors' const VALID_NETWORKS: readonly NetworkName[] = ['testnet', 'mainnet'] declare module 'fastify' { @@ -60,7 +61,7 @@ async function networkSelectorPlugin(app: FastifyInstance) { app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { const resolved = resolveNetworkName(rawNetworkFromRequest(req)) if (!resolved.ok) { - reply.status(400).send({ error: resolved.error }) + reply.status(400).send(apiError('bad_network', resolved.error)) return } req.network = resolved.network diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index a2651f0a..ce35422b 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -3,6 +3,7 @@ import { x402_payments_received_total } from '../metrics' import { checkQuota, recordUsage, parseCents, getQuotaConfig } from '../x402/metering' import { X402_NETWORK_LABEL, paymentAddressFor, isX402Configured, getX402ResourceServer } from '../x402/network' import fp from 'fastify-plugin' +import { apiError } from '../errors' import './network' // declares req.network on the FastifyRequest type export interface GatedRoute { @@ -108,7 +109,7 @@ async function x402Plugin(app: FastifyInstance) { const network = req.network ?? 'testnet' const paymentAddress = paymentAddressFor(network) if (!paymentAddress) { - reply.status(402).send({ error: `x402 payments are not configured for network "${network}"` }) + reply.status(402).send(apiError('unsupported', `x402 payments are not configured for network "${network}"`)) return } @@ -127,7 +128,7 @@ async function x402Plugin(app: FastifyInstance) { reply.status(402).send({ x402Version: 1, accepts: [requirements], - error: 'Payment required', + ...apiError('payment_required', 'Payment required'), description, }) return @@ -145,7 +146,7 @@ async function x402Plugin(app: FastifyInstance) { const resourceServer = await getX402ResourceServer(network, FACILITATOR_URL) const result = await resourceServer.verify(payload, requirements) if (!result.isValid) { - reply.status(402).send({ error: 'Payment invalid', reason: result.invalidReason }) + reply.status(402).send({ ...apiError('payment_invalid', 'Payment invalid'), reason: result.invalidReason }) return } @@ -163,14 +164,14 @@ async function x402Plugin(app: FastifyInstance) { await recordUsage(apiKeyId, parseCents(price)) } else if (config.overagePolicy === 'charge_402') { reply.status(402).send({ - error: 'Quota exceeded — additional payment required', + ...apiError('quota_exceeded', 'Quota exceeded — additional payment required'), policy: 'charge_402', }) return } else { // default: block reply.status(402).send({ - error: 'Quota exceeded', + ...apiError('quota_exceeded', 'Quota exceeded'), policy: 'block', }) return @@ -185,7 +186,7 @@ async function x402Plugin(app: FastifyInstance) { app.log.error({ err }, '[oracle] x402 settle error') }) } catch (err) { - reply.status(402).send({ error: 'Payment verification failed', reason: (err as Error).message }) + reply.status(402).send({ ...apiError('payment_verification_failed', 'Payment verification failed'), reason: (err as Error).message }) } }) } diff --git a/src/routes/assets.ts b/src/routes/assets.ts index 8f10ad69..29e38877 100644 --- a/src/routes/assets.ts +++ b/src/routes/assets.ts @@ -12,6 +12,7 @@ import { assetDetailResponseSchema, } from '../api/schemas' +import { apiError } from '../errors' export async function registerAssetRoutes(app: FastifyInstance): Promise { // GET /assets — list tracked assets with metadata and verification status app.get<{ @@ -69,7 +70,7 @@ export async function registerAssetRoutes(app: FastifyInstance): Promise { const network = (req.network ?? activeNetwork) as NetworkName const rawCode = req.params.code if (!rawCode || rawCode.trim().length === 0) { - return reply.status(400).send({ error: 'Asset code is required' }) + return reply.status(400).send(apiError('missing_parameter', 'Asset code is required')) } let parsedCode = rawCode.trim() @@ -111,7 +112,7 @@ export async function registerAssetRoutes(app: FastifyInstance): Promise { if (!matched) { return reply.status(404).send({ - error: `Asset "${rawCode}" not found on ${network}`, + ...apiError('not_found', `Asset "${rawCode}" not found on ${network}`), }) } diff --git a/src/routes/benchmark.ts b/src/routes/benchmark.ts index 20fa639d..bf66d781 100644 --- a/src/routes/benchmark.ts +++ b/src/routes/benchmark.ts @@ -4,7 +4,7 @@ import { getActivePairs } from '../pairsRegistry' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type import { benchmarkResponseSchema } from '../api/schemas' -import { sendInternalError } from '../errors' +import { sendInternalError, apiError } from '../errors' function findPair(assetCode: string, targetCode: string) { const normalize = (c: string) => c.toLowerCase() === 'native' ? 'XLM' : c.split(':')[0].toUpperCase() @@ -34,7 +34,7 @@ export async function registerBenchmarkRoutes(app: FastifyInstance) { const pair = findPair(asset, target) if (!pair) { - return reply.status(404).send({ error: `Pair ${asset}/${target} not watched` }) + return reply.status(404).send(apiError('unwatched_pair', `Pair ${asset}/${target} not watched`)) } const isAssetA = pair.assetA.code.toUpperCase() === normAsset diff --git a/src/routes/book.ts b/src/routes/book.ts index 8ad06f1a..96834e54 100644 --- a/src/routes/book.ts +++ b/src/routes/book.ts @@ -8,6 +8,7 @@ import { getHorizonServer } from '../network/clients' import { aggregateBookLevels, generateAmmBookLevels, type AmmBookPool, type RawBookLevel } from '../pricing/book' import { bookResponseSchema } from '../api/schemas' +import { apiError } from '../errors' const BookQuerySchema = z.object({ depth: z.coerce.number().min(0).max(0.5).default(0.1), step: z.coerce.number().min(0.001).max(0.1).default(0.01), @@ -138,7 +139,7 @@ export async function registerBookRoutes(app: FastifyInstance) { }>('/book/:assetA/:assetB', { config: { scope: 'read:prices' }, schema: { response: { 200: bookResponseSchema, 503: bookResponseSchema } } }, async (req, reply) => { const parsedQuery = BookQuerySchema.safeParse(req.query) if (!parsedQuery.success) { - return reply.status(400).send({ error: parsedQuery.error.issues[0]?.message ?? 'Invalid book parameters' }) + return reply.status(400).send(apiError('invalid_parameter', parsedQuery.error.issues[0]?.message ?? 'Invalid book parameters')) } let assetA: ParsedAsset @@ -148,7 +149,7 @@ export async function registerBookRoutes(app: FastifyInstance) { assetB = parseAsset(req.params.assetB) if (assetA.canonical === assetB.canonical) throw new Error('Assets must be different') } catch (err) { - return reply.status(400).send({ error: (err as Error).message }) + return reply.status(400).send(apiError('invalid_parameter', (err as Error).message)) } const network: NetworkName = req.network ?? activeNetwork diff --git a/src/routes/candles.ts b/src/routes/candles.ts index df79a2a3..e3cff51f 100644 --- a/src/routes/candles.ts +++ b/src/routes/candles.ts @@ -14,6 +14,7 @@ import '../middleware/network' // declares req.network on the FastifyRequest typ import { formatPrice, formatVolume } from '../pricing/precision' +import { apiError } from '../errors' const INTERVAL_SECONDS: Record = { '1m': 60, '5m': 300, @@ -52,13 +53,13 @@ export async function registerCandleRoutes(app: FastifyInstance) { if (!intervalSecs) { return reply.status(400).send({ - error: `interval must be one of: ${Object.keys(INTERVAL_SECONDS).join(', ')}`, + ...apiError('bad_interval', `interval must be one of: ${Object.keys(INTERVAL_SECONDS).join(', ')}`), }) } const parsedLimit = parseLimit(req.query.limit, CANDLES_DEFAULT_LIMIT, CANDLES_MAX_LIMIT) if ('error' in parsedLimit) { - return reply.status(400).send({ error: parsedLimit.error }) + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) } const { limit } = parsedLimit @@ -76,10 +77,10 @@ export async function registerCandleRoutes(app: FastifyInstance) { const to = toRaw ? new Date(toRaw) : new Date() if (isNaN(from.getTime())) { - return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be a valid ISO 8601 date')) } if (isNaN(to.getTime())) { - return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'to must be a valid ISO 8601 date')) } // $5 is the network predicate in the WHERE clause below. It has to be @@ -90,7 +91,7 @@ export async function registerCandleRoutes(app: FastifyInstance) { if (req.query.cursor) { const decoded = decodeCursor(req.query.cursor) if (decoded === null) { - return reply.status(400).send({ error: 'Invalid cursor format' }) + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) } if (decoded !== 'skip') { params.push(Number(decoded.value)) diff --git a/src/routes/correlation.ts b/src/routes/correlation.ts index d7c9c205..e1d14b27 100644 --- a/src/routes/correlation.ts +++ b/src/routes/correlation.ts @@ -4,6 +4,7 @@ import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' +import { apiError } from '../errors' const log = createLogger('correlation') /** @@ -113,22 +114,22 @@ export async function registerCorrelationRoutes(app: FastifyInstance) { assetList = Array.from(new Set(assetList.filter(Boolean).map(a => a.toUpperCase()))) if (assetList.length === 0) { - return reply.status(400).send({ error: 'assets parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'assets parameter is required')) } if (assetList.length < 2) { - return reply.status(400).send({ error: 'At least 2 assets are required to compute correlation' }) + return reply.status(400).send(apiError('invalid_parameter', 'At least 2 assets are required to compute correlation')) } const rawWindow = req.query.window ?? '24h' if (!(rawWindow in CORRELATION_WINDOWS)) { return reply.status(400).send({ - error: `window must be one of: ${SUPPORTED_WINDOWS.join(', ')}`, + ...apiError('bad_window', `window must be one of: ${SUPPORTED_WINDOWS.join(', ')}`), }) } const requestedNetwork = req.query.network if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = req.network ?? (requestedNetwork as NetworkName) ?? activeNetwork @@ -391,7 +392,7 @@ export async function registerCorrelationRoutes(app: FastifyInstance) { } } catch (err) { log.error({ err }, 'correlation computation failed') - return reply.status(500).send({ error: 'Correlation computation failed' }) + return reply.status(500).send(apiError('internal_error', 'Correlation computation failed')) } }) } diff --git a/src/routes/coverage.ts b/src/routes/coverage.ts index e99fce6d..209f569d 100644 --- a/src/routes/coverage.ts +++ b/src/routes/coverage.ts @@ -9,6 +9,7 @@ import { } from '../services/coverage' import '../middleware/network' +import { apiError } from '../errors' /** * Register the market coverage reporting routes. * @@ -36,12 +37,12 @@ export async function registerCoverageRoutes(app: FastifyInstance) { if (!(window in WINDOW_HOURS)) { return reply .status(400) - .send({ error: `window must be one of: ${COVERAGE_WINDOWS.join(', ')}` }) + .send(apiError('bad_window', `window must be one of: ${COVERAGE_WINDOWS.join(', ')}`)) } const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requested as NetworkName) ?? (req.network ?? activeNetwork) @@ -52,7 +53,7 @@ export async function registerCoverageRoutes(app: FastifyInstance) { req.log.error({ err }, 'Failed to compute overall coverage') return reply .status(500) - .send({ error: `Coverage computation failed: ${(err as Error).message}` }) + .send(apiError('internal_error', `Coverage computation failed: ${(err as Error).message}`)) } }) @@ -63,19 +64,19 @@ export async function registerCoverageRoutes(app: FastifyInstance) { }>('/coverage/:asset', { config: { public: true } }, async (req, reply) => { const { asset } = req.params if (!asset || asset.trim() === '') { - return reply.status(400).send({ error: 'Asset parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'Asset parameter is required')) } const window = (req.query.window ?? '24h') as CoverageWindow if (!(window in WINDOW_HOURS)) { return reply .status(400) - .send({ error: `window must be one of: ${COVERAGE_WINDOWS.join(', ')}` }) + .send(apiError('bad_window', `window must be one of: ${COVERAGE_WINDOWS.join(', ')}`)) } const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requested as NetworkName) ?? (req.network ?? activeNetwork) @@ -85,12 +86,12 @@ export async function registerCoverageRoutes(app: FastifyInstance) { } catch (err) { const msg = (err as Error).message || '' if (msg.includes('Invalid asset') || msg.includes('must include its issuer')) { - return reply.status(400).send({ error: msg }) + return reply.status(400).send(apiError('invalid_parameter', msg)) } req.log.error({ asset, err }, 'Failed to compute asset coverage') return reply .status(500) - .send({ error: `Asset coverage computation failed: ${msg}` }) + .send(apiError('internal_error', `Asset coverage computation failed: ${msg}`)) } }) } diff --git a/src/routes/dispersion.ts b/src/routes/dispersion.ts index d7c18da5..6e8cbafd 100644 --- a/src/routes/dispersion.ts +++ b/src/routes/dispersion.ts @@ -3,6 +3,7 @@ import { activeNetwork, type NetworkName } from '../config' import { queryDispersionHistory } from '../reconciliation/dispersion' import '../middleware/network' +import { apiError } from '../errors' export async function registerDispersionRoutes(app: FastifyInstance) { // GET /dispersion/:assetA/:assetB — query dispersion history and outlier attribution for a pair app.get<{ @@ -12,12 +13,12 @@ export async function registerDispersionRoutes(app: FastifyInstance) { const { assetA, assetB } = req.params if (!assetA || !assetB) { - return reply.status(400).send({ error: 'assetA and assetB are required' }) + return reply.status(400).send(apiError('missing_parameter', 'assetA and assetB are required')) } const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requested as NetworkName) ?? req.network ?? activeNetwork @@ -29,20 +30,20 @@ export async function registerDispersionRoutes(app: FastifyInstance) { if (req.query.from) { from = new Date(req.query.from) if (isNaN(from.getTime())) { - return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be a valid ISO 8601 date')) } } if (req.query.to) { to = new Date(req.query.to) if (isNaN(to.getTime())) { - return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'to must be a valid ISO 8601 date')) } } const limit = req.query.limit ? parseInt(req.query.limit, 10) : 50 if (isNaN(limit) || limit <= 0) { - return reply.status(400).send({ error: 'limit must be a positive integer' }) + return reply.status(400).send(apiError('invalid_parameter', 'limit must be a positive integer')) } try { @@ -63,7 +64,7 @@ export async function registerDispersionRoutes(app: FastifyInstance) { } } catch (err) { req.log.error({ err }, 'dispersion query failed') - return reply.status(500).send({ error: 'Dispersion query failed' }) + return reply.status(500).send(apiError('internal_error', 'Dispersion query failed')) } }) @@ -73,7 +74,7 @@ export async function registerDispersionRoutes(app: FastifyInstance) { }>('/dispersion', async (req, reply) => { const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requested as NetworkName) ?? req.network ?? activeNetwork @@ -83,20 +84,20 @@ export async function registerDispersionRoutes(app: FastifyInstance) { if (req.query.from) { from = new Date(req.query.from) if (isNaN(from.getTime())) { - return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be a valid ISO 8601 date')) } } if (req.query.to) { to = new Date(req.query.to) if (isNaN(to.getTime())) { - return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'to must be a valid ISO 8601 date')) } } const limit = req.query.limit ? parseInt(req.query.limit, 10) : 50 if (isNaN(limit) || limit <= 0) { - return reply.status(400).send({ error: 'limit must be a positive integer' }) + return reply.status(400).send(apiError('invalid_parameter', 'limit must be a positive integer')) } try { @@ -116,7 +117,7 @@ export async function registerDispersionRoutes(app: FastifyInstance) { } } catch (err) { req.log.error({ err }, 'dispersion query failed') - return reply.status(500).send({ error: 'Dispersion query failed' }) + return reply.status(500).send(apiError('internal_error', 'Dispersion query failed')) } }) } diff --git a/src/routes/fiat.ts b/src/routes/fiat.ts index 30dd0d5a..f92670c8 100644 --- a/src/routes/fiat.ts +++ b/src/routes/fiat.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify' import { activeNetwork, type NetworkName } from '../config' import { pgPool } from '../db' +import { apiError } from '../errors' export const SUPPORTED_FIAT_CURRENCIES = ['USD', 'NGN', 'EUR', 'GBP', 'KES', 'ZAR', 'CAD', 'BRL'] as const export type SupportedFiatCurrency = typeof SUPPORTED_FIAT_CURRENCIES[number] @@ -39,7 +40,7 @@ export async function registerFiatRoutes(app: FastifyInstance) { if (!SUPPORTED_FIAT_CURRENCIES.includes(currencyStr as SupportedFiatCurrency)) { return reply.status(400).send({ - error: `Unsupported currency: ${currencyStr}. Supported currencies: ${SUPPORTED_FIAT_CURRENCIES.join(', ')}`, + ...apiError('invalid_parameter', `Unsupported currency: ${currencyStr}. Supported currencies: ${SUPPORTED_FIAT_CURRENCIES.join(', ')}`), supportedCurrencies: SUPPORTED_FIAT_CURRENCIES, }) } diff --git a/src/routes/movers.ts b/src/routes/movers.ts index c6cb1fab..babbbf32 100644 --- a/src/routes/movers.ts +++ b/src/routes/movers.ts @@ -3,6 +3,7 @@ import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' +import { apiError } from '../errors' // Supported windows -> lookback in hours. const WINDOW_HOURS = { '1h': 1, @@ -55,13 +56,13 @@ export async function registerMoversRoutes(app: FastifyInstance) { if (!(rawWindow in WINDOW_HOURS)) { return reply .status(400) - .send({ error: `window must be one of: ${WINDOWS.join(', ')}` }) + .send(apiError('bad_window', `window must be one of: ${WINDOWS.join(', ')}`)) } const window = rawWindow as MoversWindow const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = req.network ?? (requested as NetworkName) ?? activeNetwork @@ -186,7 +187,7 @@ export async function registerMoversRoutes(app: FastifyInstance) { return response } catch (err) { req.log.error(err, '[movers] Movers aggregation failed') - return reply.status(500).send({ error: 'Internal server error' }) + return reply.status(500).send(apiError('internal_error', 'Internal server error')) } }) } diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index b3a6fee5..d189b8ad 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -10,6 +10,7 @@ import { type OracleHistoryWindow, } from '../services/oracleComparison' +import { apiError } from '../errors' const oracleAdapters: OracleAdapter[] = [reflectorOracleAdapter] interface HistoryQuery { @@ -28,14 +29,14 @@ function parseHistoryQueryParams( ) { const { asset } = req.params if (!asset) { - reply.status(400).send({ error: 'Asset parameter is required' }) + reply.status(400).send(apiError('missing_parameter', 'Asset parameter is required')) return null } const assetCode = asset.toUpperCase() const requestedNetwork = req.query.network if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { - reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) return null } const network: NetworkName = (requestedNetwork as NetworkName) ?? req.network ?? activeNetwork @@ -43,7 +44,7 @@ function parseHistoryQueryParams( const windowParam = req.query.window if (windowParam !== undefined && !(windowParam in ORACLE_HISTORY_WINDOWS)) { reply.status(400).send({ - error: `window must be one of: ${Object.keys(ORACLE_HISTORY_WINDOWS).join(', ')}`, + ...apiError('bad_window', `window must be one of: ${Object.keys(ORACLE_HISTORY_WINDOWS).join(', ')}`), }) return null } @@ -54,14 +55,14 @@ function parseHistoryQueryParams( if (req.query.from !== undefined) { fromDate = new Date(req.query.from) if (isNaN(fromDate.getTime())) { - reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + reply.status(400).send(apiError('invalid_parameter', 'from must be a valid ISO 8601 date')) return null } } if (req.query.to !== undefined) { toDate = new Date(req.query.to) if (isNaN(toDate.getTime())) { - reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + reply.status(400).send(apiError('invalid_parameter', 'to must be a valid ISO 8601 date')) return null } } @@ -72,7 +73,7 @@ function parseHistoryQueryParams( if (fromDate !== undefined && toDate !== undefined) { if (fromDate.getTime() > toDate.getTime()) { - reply.status(400).send({ error: 'from must be earlier than to' }) + reply.status(400).send(apiError('invalid_parameter', 'from must be earlier than to')) return null } from = fromDate @@ -81,7 +82,7 @@ function parseHistoryQueryParams( } else if (fromDate !== undefined && toDate === undefined) { to = new Date() if (fromDate.getTime() > to.getTime()) { - reply.status(400).send({ error: 'from must be earlier than to' }) + reply.status(400).send(apiError('invalid_parameter', 'from must be earlier than to')) return null } from = fromDate @@ -104,7 +105,7 @@ function parseHistoryQueryParams( if (req.query.band !== undefined) { const parsedBand = parseFloat(req.query.band) if (isNaN(parsedBand) || parsedBand < 0) { - reply.status(400).send({ error: 'band must be a non-negative number' }) + reply.status(400).send(apiError('invalid_parameter', 'band must be a non-negative number')) return null } bandPct = parsedBand @@ -114,7 +115,7 @@ function parseHistoryQueryParams( if (req.query.limit !== undefined) { const parsedLimit = parseInt(req.query.limit, 10) if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 5000) { - reply.status(400).send({ error: 'limit must be an integer between 1 and 5000' }) + reply.status(400).send(apiError('invalid_parameter', 'limit must be an integer between 1 and 5000')) return null } limit = parsedLimit @@ -142,12 +143,12 @@ export async function registerOracleRoutes( async (req, reply) => { const { asset } = req.params if (!asset) { - return reply.status(400).send({ error: 'Asset parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'Asset parameter is required')) } const assetCode = asset.toUpperCase() const requestedNetwork = req.query.network if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requestedNetwork as NetworkName) ?? req.network ?? activeNetwork diff --git a/src/routes/pairs.ts b/src/routes/pairs.ts index 7214f047..62a570c8 100644 --- a/src/routes/pairs.ts +++ b/src/routes/pairs.ts @@ -14,6 +14,7 @@ import { import '../middleware/network' // declares req.network on the FastifyRequest type import { admin_auth_failures_total } from '../metrics' +import { apiError } from '../errors' export interface PairWithLatest { pairKey: string assetA: AssetId @@ -136,14 +137,14 @@ export async function registerPairsRoutes(app: FastifyInstance) { async (req, reply) => { const parsedLimit = parseLimit(req.query.limit, PAIRS_DEFAULT_LIMIT, PAIRS_MAX_LIMIT) if ('error' in parsedLimit) { - return reply.status(400).send({ error: parsedLimit.error }) + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) } const outcome = await listPairs(req.network ?? activeNetwork, { limit: parsedLimit.limit, cursor: req.query.cursor, }) - if (!outcome.ok) return reply.status(400).send({ error: outcome.error }) + if (!outcome.ok) return reply.status(400).send(apiError('invalid_parameter', outcome.error)) return { pairs: outcome.pairs, nextCursor: outcome.nextCursor, hasMore: outcome.hasMore } } ) @@ -168,27 +169,27 @@ export async function registerPairsRoutes(app: FastifyInstance) { const { assetA: assetAStr, assetB: assetBStr } = req.body ?? {} if (!assetAStr || !assetBStr) { - return reply.status(400).send({ error: 'assetA and assetB are required' }) + return reply.status(400).send(apiError('missing_parameter', 'assetA and assetB are required')) } const assetA = parseAssetStr(assetAStr) if (!assetA) { return reply.status(400).send({ - error: `Invalid assetA format "${assetAStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`, + ...apiError('invalid_parameter', `Invalid assetA format "${assetAStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`), }) } const assetB = parseAssetStr(assetBStr) if (!assetB) { return reply.status(400).send({ - error: `Invalid assetB format "${assetBStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`, + ...apiError('invalid_parameter', `Invalid assetB format "${assetBStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`), }) } const pairKey = makePairKey(assetA, assetB) if (hasPair(pairKey)) { - return reply.status(409).send({ error: `Pair ${pairKey} is already being watched` }) + return reply.status(409).send(apiError('pair_already_watched', `Pair ${pairKey} is already being watched`)) } const pair = { assetA, assetB, pairKey } diff --git a/src/routes/pegs.ts b/src/routes/pegs.ts index 7d128ed8..c5f70a06 100644 --- a/src/routes/pegs.ts +++ b/src/routes/pegs.ts @@ -11,6 +11,7 @@ import { pegsResponseSchema } from '../api/schemas' import type { WatchedPair } from '../types' import '../middleware/network' +import { apiError } from '../errors' /** * Default deviation band in basis points (100 bps = 1.0%), aligned with * GET /snapshot (PEG_TOLERANCE_BPS = 100) and the peg-deviation alerting @@ -83,7 +84,7 @@ export async function registerPegsRoutes(app: FastifyInstance) { async (req, reply) => { const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = req.network ?? (requested as NetworkName) ?? activeNetwork diff --git a/src/routes/pools.ts b/src/routes/pools.ts index 73529487..e7a56c76 100644 --- a/src/routes/pools.ts +++ b/src/routes/pools.ts @@ -5,6 +5,7 @@ import { poolsResponseSchema } from '../api/schemas' import { POOLS_DEFAULT_LIMIT, POOLS_MAX_LIMIT, decodeCursor, encodeCursor, parseLimit, slicePage } from '../pagination' import { formatDecimal, resolveDecimalFormat } from '../utils/decimals' import { estimatedLpReturns } from '../pricing/lpReturns' +import { apiError } from '../errors' import '../middleware/network' // declares req.network on the FastifyRequest type /** @@ -129,7 +130,7 @@ export async function registerPoolsRoutes(app: FastifyInstance) { schema: { response: { 200: poolsResponseSchema, - 400: { type: 'object', required: ['error'], properties: { error: { type: 'string' } } }, + 400: { type: 'object', required: ['error'], properties: { error: { type: 'string' }, code: { type: 'string' } } }, }, }, }, @@ -142,11 +143,11 @@ export async function registerPoolsRoutes(app: FastifyInstance) { const parsedLimit = parseLimit(req.query.limit, POOLS_DEFAULT_LIMIT, POOLS_MAX_LIMIT) if ('error' in parsedLimit) { - return reply.status(400).send({ error: parsedLimit.error }) + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) } const outcome = await listPools(network, { limit: parsedLimit.limit, cursor: req.query.cursor }) - if (!outcome.ok) return reply.status(400).send({ error: outcome.error }) + if (!outcome.ok) return reply.status(400).send(apiError('invalid_parameter', outcome.error)) return { pools: outcome.pools.map(r => ({ diff --git a/src/routes/price.ts b/src/routes/price.ts index 56a8d874..b74c2291 100644 --- a/src/routes/price.ts +++ b/src/routes/price.ts @@ -7,6 +7,7 @@ import { activeNetwork, type NetworkName } from '../config' import { findOverlappingGaps } from '../backfill/gaps' import { resolveDecimalFormat } from '../utils/decimals' +import { apiError } from '../errors' export const TWAP_WINDOW_MIN_MINUTES = 1 export const TWAP_WINDOW_MAX_MINUTES = 1440 export const TWAP_WINDOW_DEFAULT_MINUTES = 60 @@ -117,7 +118,7 @@ export async function registerPriceRoutes(app: FastifyInstance) { // A driver error message can carry the connection string, and so the // Postgres credentials. Log it; never put it in the response body. log.error({ err }, 'TWAP computation failed') - return reply.status(500).send({ error: 'TWAP computation failed' }) + return reply.status(500).send(apiError('internal_error', 'TWAP computation failed')) } } ) @@ -195,7 +196,7 @@ export async function registerPriceRoutes(app: FastifyInstance) { } catch (err) { // Same as TWAP above: the driver message can contain credentials. log.error({ err }, 'VWAP computation failed') - return reply.status(500).send({ error: 'VWAP computation failed' }) + return reply.status(500).send(apiError('internal_error', 'VWAP computation failed')) } } ) diff --git a/src/routes/rates.ts b/src/routes/rates.ts index 6215a90a..9f1d94ff 100644 --- a/src/routes/rates.ts +++ b/src/routes/rates.ts @@ -4,6 +4,7 @@ import { pgPool, getLatestLendingRates } from '../db' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' +import { apiError } from '../errors' const RatesQuerySchema = z.object({ asset: z.string().optional(), poolId: z.string().optional(), @@ -77,7 +78,7 @@ export async function registerRateRoutes(app: FastifyInstance) { }>('/rates', async (req, reply) => { const parsed = RatesQuerySchema.safeParse(req.query) if (!parsed.success) { - return reply.status(400).send({ error: parsed.error.issues[0].message || 'Invalid parameters' }) + return reply.status(400).send(apiError('invalid_parameter', parsed.error.issues[0].message || 'Invalid parameters')) } const network: NetworkName = req.network ?? parsed.data.network ?? activeNetwork const rows = await getLatestLendingRates(network, { @@ -117,7 +118,7 @@ export async function registerRateRoutes(app: FastifyInstance) { }>('/rates/:asset', async (req, reply) => { const { asset } = req.params if (!asset) { - return reply.status(400).send({ error: 'Asset parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'Asset parameter is required')) } const assetCode = asset.toUpperCase() @@ -126,7 +127,7 @@ export async function registerRateRoutes(app: FastifyInstance) { // rather than silently falling back. const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requested as NetworkName) ?? activeNetwork @@ -200,7 +201,7 @@ export async function registerRateRoutes(app: FastifyInstance) { } catch (err) { return reply .status(500) - .send({ error: `Rate lookup failed: ${(err as Error).message}` }) + .send(apiError('internal_error', `Rate lookup failed: ${(err as Error).message}`)) } }) } diff --git a/src/routes/screener.ts b/src/routes/screener.ts index 813be4c2..cb531082 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -10,6 +10,7 @@ import { ScreenerCursorError, SCREENER_DEFAULT_LIMIT, } from '../services/screener' +import { apiError } from '../errors' import '../middleware/network' // declares req.network on the FastifyRequest type // The query execution (filters, sorting, cursor pagination, the liquidity @@ -130,7 +131,7 @@ export async function registerScreenerRoutes(app: FastifyInstance) { return reply.status(400).send({ error: error.message, code: error.code }) } log.error({ err: error }, 'screener query failed') - return reply.status(500).send({ error: 'Internal server error' }) + return reply.status(500).send(apiError('internal_error', 'Internal server error')) } }) } diff --git a/src/routes/snapshot.ts b/src/routes/snapshot.ts index d385f9af..e0f535f0 100644 --- a/src/routes/snapshot.ts +++ b/src/routes/snapshot.ts @@ -3,7 +3,7 @@ import { pgPool } from '../db' import { getCachedSnapshot, setCachedSnapshot } from '../redis' import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' import { snapshotResponseSchema } from '../api/schemas' -import { sendInternalError } from '../errors' +import { sendInternalError, apiError } from '../errors' import type { AssetId, WatchedPair } from '../types' /** @@ -232,7 +232,7 @@ export async function registerSnapshotRoutes(app: FastifyInstance) { async (req, reply) => { const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requested as NetworkName) ?? activeNetwork const ttl = config.cache.snapshotTtl diff --git a/src/routes/spreads.ts b/src/routes/spreads.ts index ed327cbc..fedaaee8 100644 --- a/src/routes/spreads.ts +++ b/src/routes/spreads.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' -import { sendInternalError } from '../errors' +import { sendInternalError, apiError } from '../errors' // Supported windows → lookback in minutes. A spread is a statement about *now*, // so these are deliberately short: widen the window far enough and you stop @@ -74,14 +74,14 @@ export async function registerSpreadsRoutes(app: FastifyInstance) { const { asset } = req.params if (!asset) { - return reply.status(400).send({ error: 'Asset parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'Asset parameter is required')) } try { return await computeSpreads(asset, req.query.window, req.query.network) } catch (err) { if (err instanceof SpreadsInputError) { - return reply.status(400).send({ error: err.message }) + return reply.status(400).send(apiError('invalid_parameter', err.message)) } return sendInternalError(reply, req, err, 'Spread aggregation failed') } diff --git a/src/routes/tvl.ts b/src/routes/tvl.ts index 74b97003..7bc154c0 100644 --- a/src/routes/tvl.ts +++ b/src/routes/tvl.ts @@ -3,6 +3,7 @@ import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' +import { apiError } from '../errors' export interface ValuationPriceSource { price: number source: string @@ -419,7 +420,7 @@ export async function registerTvlRoutes(app: FastifyInstance): Promise { }>('/tvl', { config: { public: true } }, async (req, reply) => { const requested = req.query.network if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requested as NetworkName) ?? req.network ?? activeNetwork diff --git a/src/routes/venues.ts b/src/routes/venues.ts index f6ea1058..7b2588b5 100644 --- a/src/routes/venues.ts +++ b/src/routes/venues.ts @@ -5,6 +5,7 @@ import { venueAdapters } from '../ingest/venues' import { calculateVenueVolumeShare } from '../services/venueVolume' import '../middleware/network' +import { apiError } from '../errors' export const LIVE_WINDOW_MS = 5 * 60 * 1000 export interface RegisteredVenue { @@ -66,7 +67,7 @@ export async function registerVenueRoutes(app: FastifyInstance) { const network = (req.network ?? activeNetwork) as NetworkName const window = (req.query.window ?? '24h') as string if (!['24h', '7d', '30d'].includes(window)) { - return reply.status(400).send({ error: 'window must be one of: 24h, 7d, 30d' }) + return reply.status(400).send(apiError('bad_window', 'window must be one of: 24h, 7d, 30d')) } const response = await calculateVenueVolumeShare(network, window as '24h' | '7d' | '30d') return response diff --git a/src/routes/volumes.ts b/src/routes/volumes.ts index 5cfe5877..8c3de7b8 100644 --- a/src/routes/volumes.ts +++ b/src/routes/volumes.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' -import { sendInternalError } from '../errors' +import { sendInternalError, apiError } from '../errors' import { isAggregatorVenue } from '../ingest/venues' // Supported windows → lookback in hours. @@ -46,7 +46,7 @@ export async function registerVolumeRoutes(app: FastifyInstance) { return await computeVolumes(asset, req.query.window, req.query.network, req.network) } catch (err) { if (err instanceof VolumesInputError) { - return reply.status(400).send({ error: err.message }) + return reply.status(400).send(apiError('invalid_parameter', err.message)) } return sendInternalError(reply, req, err, 'Volume aggregation failed') } diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 2366ac2f..1651c7b6 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -4,6 +4,7 @@ import { prisma } from '../db' import { activeNetwork, type NetworkName } from '../config' import { resolveNetworkName } from '../middleware/network' +import { apiError } from '../errors' function isValidHttpsUrl(raw: string): boolean { try { const u = new URL(raw) @@ -21,26 +22,26 @@ export async function registerWebhookRoutes(app: FastifyInstance) { const { url, assetA, assetB, threshold, direction, network: bodyNetwork } = req.body ?? {} if (!url || !isValidHttpsUrl(url)) { - return reply.status(400).send({ error: 'url must be a valid HTTPS URL' }) + return reply.status(400).send(apiError('invalid_parameter', 'url must be a valid HTTPS URL')) } if (!assetA || typeof assetA !== 'string') { - return reply.status(400).send({ error: 'assetA is required' }) + return reply.status(400).send(apiError('missing_parameter', 'assetA is required')) } if (!assetB || typeof assetB !== 'string') { - return reply.status(400).send({ error: 'assetB is required' }) + return reply.status(400).send(apiError('missing_parameter', 'assetB is required')) } if (typeof threshold !== 'number' || isNaN(threshold)) { - return reply.status(400).send({ error: 'threshold must be a number' }) + return reply.status(400).send(apiError('invalid_parameter', 'threshold must be a number')) } if (direction !== 'above' && direction !== 'below') { - return reply.status(400).send({ error: 'direction must be "above" or "below"' }) + return reply.status(400).send(apiError('invalid_parameter', 'direction must be "above" or "below"')) } let targetNetwork: NetworkName = req.network ?? activeNetwork if (bodyNetwork !== undefined) { const resolved = resolveNetworkName(bodyNetwork) if (!resolved.ok) { - return reply.status(400).send({ error: resolved.error }) + return reply.status(400).send(apiError('bad_network', resolved.error)) } targetNetwork = resolved.network }