diff --git a/apps/web/src/app/api/readings/route.ts b/apps/web/src/app/api/readings/route.ts index 620730c..cc83e07 100644 --- a/apps/web/src/app/api/readings/route.ts +++ b/apps/web/src/app/api/readings/route.ts @@ -10,6 +10,7 @@ import { getIdempotentResponse, storeIdempotentResponse } from '@/lib/idempotenc import { logger } from '@/lib/logger' import { requireAuth, isAuthError } from '@/lib/auth' import { enqueue } from '@/lib/queue' +import { AppError, ErrorCategory, errorBody } from '@/lib/errors' const NONCE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours @@ -226,12 +227,13 @@ export async function POST(req: NextRequest) { .single() return NextResponse.json( - { error: 'Reading already anchored', reading_id: existing?.id }, + { error: 'Reading already anchored', reading_id: existing?.id, code: ErrorCategory.DB_CONSTRAINT, retriable: false }, { status: 409 } ) } - log.error('readings.post.db_insert_failed', { meter_id, error: readingErr?.message }) - return NextResponse.json({ error: 'Failed to save reading' }, { status: 500 }) + const appErr = new AppError(ErrorCategory.DB_QUERY, 'Failed to save reading', { meter_id, dbError: readingErr?.message }) + log.error('readings.post.db_insert_failed', { ...appErr.meta }) + return NextResponse.json(errorBody(appErr), { status: appErr.httpStatus }) } const { data: coop } = await db diff --git a/apps/web/src/lib/errors.ts b/apps/web/src/lib/errors.ts new file mode 100644 index 0000000..dc6fedc --- /dev/null +++ b/apps/web/src/lib/errors.ts @@ -0,0 +1,94 @@ +/** + * Structured error categories for external service failures. + * + * Provides consistent error codes, HTTP status mapping, and retriable/fatal + * distinction so API routes return actionable responses and logs carry context. + * + * Usage: + * import { AppError, ErrorCategory } from '@/lib/errors' + * throw new AppError(ErrorCategory.STELLAR_TIMEOUT, 'RPC timed out', { correlationId }) + * + * // In a route handler: + * catch (err) { + * return errorResponse(err) + * } + */ + +export const ErrorCategory = { + // Stellar / RPC + STELLAR_TIMEOUT: 'STELLAR_TIMEOUT', + STELLAR_CIRCUIT: 'STELLAR_CIRCUIT', + STELLAR_RPC: 'STELLAR_RPC', + // Supabase / database + DB_QUERY: 'DB_QUERY', + DB_CONSTRAINT: 'DB_CONSTRAINT', + // Input validation + VALIDATION: 'VALIDATION', + // Auth + UNAUTHORIZED: 'UNAUTHORIZED', + // Generic + INTERNAL: 'INTERNAL', +} as const + +export type ErrorCategory = (typeof ErrorCategory)[keyof typeof ErrorCategory] + +/** Whether clients should retry requests that fail with this category. */ +export const RETRIABLE: ReadonlySet = new Set([ + ErrorCategory.STELLAR_TIMEOUT, + ErrorCategory.STELLAR_CIRCUIT, + ErrorCategory.DB_QUERY, +]) + +/** Default HTTP status per category. */ +const HTTP_STATUS: Record = { + STELLAR_TIMEOUT: 503, + STELLAR_CIRCUIT: 503, + STELLAR_RPC: 502, + DB_QUERY: 500, + DB_CONSTRAINT: 409, + VALIDATION: 400, + UNAUTHORIZED: 401, + INTERNAL: 500, +} + +export class AppError extends Error { + readonly category: ErrorCategory + readonly meta: Record + readonly retriable: boolean + readonly httpStatus: number + + constructor( + category: ErrorCategory, + message: string, + meta: Record = {} + ) { + super(message) + this.name = 'AppError' + this.category = category + this.meta = meta + this.retriable = RETRIABLE.has(category) + this.httpStatus = HTTP_STATUS[category] + } +} + +/** + * Convert any caught value to an AppError. + * Preserves AppError instances; wraps everything else as INTERNAL. + */ +export function toAppError(err: unknown): AppError { + if (err instanceof AppError) return err + const message = err instanceof Error ? err.message : String(err) + return new AppError(ErrorCategory.INTERNAL, message) +} + +/** + * Build a Next.js-compatible JSON response body for an error. + * Returns `{ error, code, retriable }` so clients can branch on `code`. + */ +export function errorBody(err: AppError): { + error: string + code: ErrorCategory + retriable: boolean +} { + return { error: err.message, code: err.category, retriable: err.retriable } +} diff --git a/apps/web/src/lib/queue.ts b/apps/web/src/lib/queue.ts index 3f37ca3..942cc45 100644 --- a/apps/web/src/lib/queue.ts +++ b/apps/web/src/lib/queue.ts @@ -14,6 +14,7 @@ import { Queue, Worker, type Job } from 'bullmq' import { createServiceClient } from '@/lib/supabase' import { getRedisConnection } from '@/lib/redis' import { logger } from '@/lib/logger' +import { AppError } from '@/lib/errors' export type JobType = 'anchor_and_mint' export type JobStatus = 'pending' | 'running' | 'done' | 'failed' @@ -111,7 +112,9 @@ async function processJob(job: Job): Promise { await db.from('jobs').update({ status: 'done', result: result as import('@/lib/database.types').Json }).eq('id', dbJobId) } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err) + const category = err instanceof AppError ? err.category : 'INTERNAL' const isFinal = job.attemptsMade + 1 >= (job.opts.attempts ?? 3) + logger.error('job.failed', { dbJobId, category, error: errorMsg, attempt: job.attemptsMade + 1, final: isFinal }) await db .from('jobs') .update({ status: isFinal ? 'failed' : 'pending', error: errorMsg }) @@ -157,7 +160,8 @@ async function runAnchorAndMint(payload: AnchorAndMintPayload): Promise= CB.openUntil) { CB.failures = 0 @@ -52,21 +66,21 @@ function recordSuccess() { function recordFailure(correlationId: string) { CB.failures++ - console.error(`[stellar] timeout correlationId=${correlationId} failures=${CB.failures}`) + logger.warn('stellar.rpc.timeout', { correlationId, failures: CB.failures }) if (CB.failures >= CB.THRESHOLD) { CB.openUntil = Date.now() + CB.RESET_MS - console.error(`[stellar] circuit opened until ${new Date(CB.openUntil).toISOString()}`) + logger.error('stellar.circuit.opened', { openUntil: new Date(CB.openUntil).toISOString() }) } } -/** - * Wrap a promise with a timeout. - * - * @param promise - The promise to race against the timeout. - * @param correlationId - Identifier used in the thrown error for tracing. - * @returns Resolves with the promise value if it settles before the timeout. - * @throws {StellarTimeoutError} If the promise does not settle within `RPC_TIMEOUT_MS`. - */ +/** Returns true for transient errors that should be retried. */ +function isTransient(err: unknown): boolean { + return ( + err instanceof StellarTimeoutError || + (err instanceof Error && /timeout|ECONNRESET|ENOTFOUND|503|429/i.test(err.message)) + ) +} + async function withTimeout(promise: Promise, correlationId: string): Promise { let timer: ReturnType const timeout = new Promise((_, reject) => { @@ -83,25 +97,48 @@ async function withTimeout(promise: Promise, correlationId: string): Promi } /** - * Execute an RPC call with timeout and circuit-breaker protection. + * Execute an RPC call with timeout, exponential backoff retry, and + * circuit-breaker protection. + * + * Transient errors (timeout, network reset, 503/429) are retried up to + * RETRY_MAX times with exponential backoff capped at RETRY_MAX_MS. + * Permanent failures are surfaced immediately without retrying. * * @param fn - Factory that returns the RPC promise to execute. * @param correlationId - Identifier propagated to timeout errors and logs. * @returns The resolved value of `fn()`. - * @throws {CircuitOpenError} When the circuit breaker is open. - * @throws {StellarTimeoutError} When the call exceeds `RPC_TIMEOUT_MS`. + * @throws {AppError} STELLAR_CIRCUIT when the circuit breaker is open. + * @throws {AppError} STELLAR_TIMEOUT / STELLAR_RPC on unrecoverable failure. */ async function rpcCall(fn: () => Promise, correlationId: string): Promise { - checkCircuit() - try { - const result = await withTimeout(fn(), correlationId) - recordSuccess() - return result - } catch (err) { - if (err instanceof StellarTimeoutError) { - recordFailure(correlationId) + checkCircuit(correlationId) + + let attempt = 0 + while (true) { + try { + const result = await withTimeout(fn(), correlationId) + recordSuccess() + return result + } catch (err) { + if (err instanceof StellarTimeoutError) { + recordFailure(correlationId) + } + + const canRetry = isTransient(err) && attempt < RETRY_MAX + if (!canRetry) { + // Wrap in AppError with appropriate category + if (err instanceof AppError) throw err + if (err instanceof StellarTimeoutError) { + throw new AppError(ErrorCategory.STELLAR_TIMEOUT, err.message, { correlationId }) + } + throw new AppError(ErrorCategory.STELLAR_RPC, err instanceof Error ? err.message : String(err), { correlationId }) + } + + const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS) + logger.warn('stellar.rpc.retry', { correlationId, attempt: attempt + 1, delayMs: delay }) + await new Promise((resolve) => setTimeout(resolve, delay)) + attempt++ } - throw err } }