diff --git a/docs/architecture/route-error-mapping.md b/docs/architecture/route-error-mapping.md new file mode 100644 index 0000000..f7b9715 --- /dev/null +++ b/docs/architecture/route-error-mapping.md @@ -0,0 +1,41 @@ +# Route-safe unknown error mapping + +The global error middleware (`src/middlewares/error.middleware.ts`) routes +known errors (Zod validation, JWT, Prisma, `ApiError`, payload-too-large, +malformed JSON) through dedicated branches that already produce the right +status code, error code and message. + +For everything else — an unhandled `throw`, a third-party library exception, +a programming bug — the fallback path delegates to +`mapUnknownRouteError()` in `src/utils/route-error.utils.ts`. + +## Why a shared helper + +- One place to evolve the unknown-error envelope shape. +- Guaranteed inclusion of the route context id (`req.requestId`) so an + operator can correlate a 500 response with server-side logs. +- Production responses never leak `error.message`, `stack`, or the raw + error object. Development responses include them for local debugging. + +## Envelope shape + +Production: + +```json +{ + "success": false, + "code": "INTERNAL_ERROR", + "message": "Internal server error", + "requestId": "8c4d…" +} +``` + +Development additionally includes `stack` and `error` for fast iteration. + +## What the helper does NOT do + +It is the **fallback only**. Validation errors, auth errors, Prisma errors +and explicit `ApiError` instances continue to be mapped by their dedicated +branches in the global middleware. Do not reach for this helper to wrap a +known-shape error — throw an `ApiError` (or one of the helpers in +`error.middleware.ts`) instead. diff --git a/docs/health-endpoints.md b/docs/health-endpoints.md new file mode 100644 index 0000000..638cee1 --- /dev/null +++ b/docs/health-endpoints.md @@ -0,0 +1,47 @@ +# Health and Readiness Endpoints + +The health module exposes three endpoints, each with a different contract. + +## `GET /api/v1/health` — liveness + +A minimal "the process is up" check. Always `200` while the event loop is +healthy. Safe for load balancers and uptime monitors that should not fan out +to dependencies. + +## `GET /api/v1/health/ready` — readiness + +Probes critical dependencies (database, cache config). Returns `200` when +every probe passes and `503` otherwise. + +### Response shape + +```json +{ + "ready": true, + "timestamp": "2026-04-28T16:00:00.000Z", + "latencyMs": 7, + "checks": [ + { "name": "database", "status": "ok", "latencyMs": 6 }, + { "name": "cache", "status": "ok" } + ] +} +``` + +### Fields + +| Field | Type | Notes | +| ----------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `ready` | boolean | `true` only when every check is `ok`. Maps directly to the HTTP status (`200` vs `503`). | +| `timestamp` | string (ISO-8601) | When the response was built. | +| `latencyMs` | number | **Total** wall-clock duration of the readiness probe — sum of every check plus orchestration overhead. Useful for dashboards and SLO tracking. | +| `checks` | array | Per-dependency results, each with its own `name`, `status`, optional `latencyMs`, and optional `error`. | + +The payload is intentionally public-safe: no internal hostnames, +connection strings, or stack traces are included even when a check fails. + +## `GET /api/v1/health/detailed` — diagnostics + +Full system snapshot including memory, uptime, system info, database +response time, chain-sync lag and per-service health flags. Intended for +operators rather than load balancers — cheaper liveness/readiness paths +should be preferred for automated probing. diff --git a/docs/indexer/FEATURE_FLAGS.md b/docs/indexer/FEATURE_FLAGS.md new file mode 100644 index 0000000..5e21903 --- /dev/null +++ b/docs/indexer/FEATURE_FLAGS.md @@ -0,0 +1,45 @@ +# Indexer Feature Flags + +The indexer subsystem is gated by a small set of boolean env vars plus the +threshold values they reference. Every flag is validated at boot by +`runIndexerFeatureFlagsStartupCheck()` in +`src/utils/indexer-flags-startup-check.utils.ts`. Misconfigurations are +collected into a single error so an operator can fix every issue in one +pass — the server refuses to start until all listed issues are resolved. + +## Flags + +| Env var | Type | Default | Purpose | +| ----------------------------------------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------- | +| `ENABLE_INDEXER_DEDUPE` | boolean | `true` | Drop duplicate chain events by `(transactionHash, eventIndex)` before processing. | +| `ENABLE_INDEXER_DLQ` | boolean | `true` | Move events that fail after retries to the dead-letter queue. Requires dedupe to be enabled. | +| `ENABLE_INDEXER_CURSOR_STALENESS_WARNING` | boolean | `true` | Emit a structured `warn` log when the indexer cursor falls behind by more than the threshold. | +| `INDEXER_JITTER_FACTOR` | number `[0, 1]` | `0.1` | Random jitter applied to indexer poll intervals. | +| `INDEXER_CURSOR_STALE_AGE_WARNING_MS` | integer ms | `300000` (5 min) | How old the cursor must be before the staleness warning fires. | + +## Cross-field invariants enforced at startup + +- `INDEXER_JITTER_FACTOR` must be a finite number in `[0, 1]`. +- `INDEXER_CURSOR_STALE_AGE_WARNING_MS` must be a positive integer. +- If `ENABLE_INDEXER_CURSOR_STALENESS_WARNING=true`, the threshold must be + at least `1000ms`. Either disable the flag or raise the threshold. +- `ENABLE_INDEXER_DLQ=true` requires `ENABLE_INDEXER_DEDUPE=true`. The DLQ + uses the dedupe key to identify repeated failures; running DLQ without + dedupe causes duplicate DLQ entries. + +## Failure mode + +If any invariant fails, the server logs a structured `error` entry with +the full list of issues and exits with code `1`: + +``` +Refusing to start: indexer feature flags are misconfigured + issues: + - INDEXER_JITTER_FACTOR must be a number between 0 and 1 (got 5) + - ENABLE_INDEXER_DLQ is on but ENABLE_INDEXER_DEDUPE is off — … +``` + +## Validating locally + +Set the env vars you want to verify and run `pnpm dev`. The startup log +emits one info line confirming the validated values. diff --git a/src/config.ts b/src/config.ts index c1755cc..ef1ff9c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,6 +58,13 @@ export const envSchema = z.object({ BACKGROUND_JOB_LOCK_TTL_MS: z.coerce.number().int().positive().default(300000), CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), INDEXER_CURSOR_STALE_AGE_WARNING_MS: z.coerce.number().int().positive().default(300000), + + // Indexer feature flags — toggle individual indexer behaviors. The startup + // check in src/utils/indexer-flags-startup-check.utils.ts enforces the + // cross-field invariants between these flags and their threshold values. + ENABLE_INDEXER_DEDUPE: z.coerce.boolean().default(true), + ENABLE_INDEXER_DLQ: z.coerce.boolean().default(true), + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: z.coerce.boolean().default(true), }); export const envConfig = envSchema.parse(process.env); diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts index 326158f..3b436be 100644 --- a/src/middlewares/error.middleware.ts +++ b/src/middlewares/error.middleware.ts @@ -6,6 +6,7 @@ import chalk from 'chalk'; import { z } from 'zod'; import { ErrorCode, ErrorCodeType } from '../constants/error.constants'; import { logger } from '../utils/logger.utils'; +import { mapUnknownRouteError } from '../utils/route-error.utils'; export class ApiError extends Error { statusCode: number; @@ -165,22 +166,14 @@ export const errorHandler: ErrorRequestHandler = ( `${method === 'GET' ? chalkColor.getReq(method) : chalkColor.postReq(method)} ${protocol}://${hostname}:${envConfig.PORT || 3000}${originalUrl}` ); - // Default error response - const statusCode = err.statusCode || err.status || 500; - const message = - envConfig.MODE === 'production' - ? 'Internal server error' - : err.message || 'Something went wrong!'; - - res.status(statusCode).json({ - success: false, - code: err.errorCode || ErrorCode.INTERNAL_ERROR, - message, - ...(envConfig.MODE === 'development' && { - stack: err.stack, - error: err, - }), + // Default fallback for unknown errors — delegated to a shared helper so + // route-safe envelopes stay consistent and include the request id for + // correlation. Known-error branches above handle their own mappings. + const { statusCode, body } = mapUnknownRouteError(err, { + requestId: req.requestId, + includeDebug: envConfig.MODE === 'development', }); + res.status(statusCode).json(body); }; // Helper functions for common errors diff --git a/src/modules/health/health.controllers.test.ts b/src/modules/health/health.controllers.test.ts new file mode 100644 index 0000000..1f2aa37 --- /dev/null +++ b/src/modules/health/health.controllers.test.ts @@ -0,0 +1,74 @@ +import { Request, Response } from 'express'; + +jest.mock('../../config', () => ({ + envConfig: { MODE: 'test', PORT: 3000 }, + appConfig: { allowedOrigins: [] }, +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + $queryRaw: jest.fn(), + }, +})); + +import { readinessCheck } from './health.controllers'; +import { prisma } from '../../utils/prisma.utils'; + +const queryRawMock = prisma.$queryRaw as unknown as jest.Mock; + +function buildRes() { + const res = { + statusCode: 0, + body: undefined as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + }; + return res as unknown as Response & { statusCode: number; body: any }; +} + +describe('readinessCheck()', () => { + beforeEach(() => { + queryRawMock.mockReset(); + }); + + it('includes a top-level latencyMs field in the response metadata', async () => { + queryRawMock.mockResolvedValue([{ '?column?': 1 }]); + const res = buildRes(); + + await readinessCheck({} as Request, res); + + expect(res.statusCode).toBe(200); + expect(res.body.ready).toBe(true); + expect(typeof res.body.latencyMs).toBe('number'); + expect(res.body.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('still reports latencyMs when a check fails (returns 503)', async () => { + queryRawMock.mockRejectedValue(new Error('connection refused')); + const res = buildRes(); + + await readinessCheck({} as Request, res); + + expect(res.statusCode).toBe(503); + expect(res.body.ready).toBe(false); + expect(typeof res.body.latencyMs).toBe('number'); + }); + + it('keeps the existing per-check latencyMs alongside the new top-level field', async () => { + queryRawMock.mockResolvedValue([{ '?column?': 1 }]); + const res = buildRes(); + + await readinessCheck({} as Request, res); + + const dbCheck = res.body.checks.find((c: any) => c.name === 'database'); + expect(dbCheck.status).toBe('ok'); + expect(typeof dbCheck.latencyMs).toBe('number'); + expect(typeof res.body.latencyMs).toBe('number'); + }); +}); diff --git a/src/modules/health/health.controllers.ts b/src/modules/health/health.controllers.ts index 2322c29..43c15d5 100644 --- a/src/modules/health/health.controllers.ts +++ b/src/modules/health/health.controllers.ts @@ -158,6 +158,7 @@ export const simpleHealthCheck = (_: Request, res: Response): void => { export const readinessCheck = async (_: Request, res: Response): Promise => { const checks: ReadinessCheck[] = []; + const overallStart = Date.now(); // DB check const dbStart = Date.now(); @@ -189,6 +190,7 @@ export const readinessCheck = async (_: Request, res: Response): Promise = res.status(ready ? 200 : 503).json({ ready, timestamp: new Date().toISOString(), + latencyMs: Date.now() - overallStart, checks, }); }; diff --git a/src/server.ts b/src/server.ts index d4ecd77..872ddc8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,10 +5,30 @@ import { envConfig } from './config'; import { logger } from './utils/logger.utils'; import { prisma } from './utils/prisma.utils'; import { verifyMigrationChecksums } from './utils/migration-checksum.utils'; +import { + IndexerFlagsConfigError, + runIndexerFeatureFlagsStartupCheck, +} from './utils/indexer-flags-startup-check.utils'; async function startServer() { try { + // Validate indexer feature flags before any code paths read them. We + // fail fast here so operators see every misconfiguration at once + // instead of cryptic runtime errors later in the boot sequence. + try { + runIndexerFeatureFlagsStartupCheck(); + } catch (err) { + if (err instanceof IndexerFlagsConfigError) { + logger.error( + { issues: err.issues }, + 'Refusing to start: indexer feature flags are misconfigured' + ); + process.exit(1); + } + throw err; + } + await prisma.$connect(); logger.info('Connected to database'); diff --git a/src/utils/indexer-flags-startup-check.utils.test.ts b/src/utils/indexer-flags-startup-check.utils.test.ts new file mode 100644 index 0000000..238bec3 --- /dev/null +++ b/src/utils/indexer-flags-startup-check.utils.test.ts @@ -0,0 +1,139 @@ +jest.mock('../config', () => ({ + envConfig: { + ENABLE_INDEXER_DEDUPE: true, + ENABLE_INDEXER_DLQ: true, + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true, + INDEXER_JITTER_FACTOR: 0.1, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: 300_000, + }, +})); + +import { + IndexerFlagsConfig, + IndexerFlagsConfigError, + validateIndexerFeatureFlags, +} from './indexer-flags-startup-check.utils'; + +function makeConfig(overrides: Partial = {}): IndexerFlagsConfig { + return { + ENABLE_INDEXER_DEDUPE: true, + ENABLE_INDEXER_DLQ: true, + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true, + INDEXER_JITTER_FACTOR: 0.1, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: 300_000, + ...overrides, + }; +} + +describe('validateIndexerFeatureFlags()', () => { + it('passes for the default configuration', () => { + expect(() => validateIndexerFeatureFlags(makeConfig())).not.toThrow(); + }); + + it('rejects a jitter factor below zero', () => { + try { + validateIndexerFeatureFlags(makeConfig({ INDEXER_JITTER_FACTOR: -0.5 })); + fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(IndexerFlagsConfigError); + expect((err as IndexerFlagsConfigError).issues[0]).toMatch( + /INDEXER_JITTER_FACTOR/ + ); + } + }); + + it('rejects a jitter factor above one', () => { + expect(() => + validateIndexerFeatureFlags(makeConfig({ INDEXER_JITTER_FACTOR: 1.5 })) + ).toThrow(IndexerFlagsConfigError); + }); + + it('rejects a non-positive cursor stale-age threshold', () => { + expect(() => + validateIndexerFeatureFlags( + makeConfig({ INDEXER_CURSOR_STALE_AGE_WARNING_MS: 0 }) + ) + ).toThrow(IndexerFlagsConfigError); + + expect(() => + validateIndexerFeatureFlags( + makeConfig({ INDEXER_CURSOR_STALE_AGE_WARNING_MS: -1 }) + ) + ).toThrow(IndexerFlagsConfigError); + }); + + it('rejects staleness warning enabled with too-small threshold', () => { + try { + validateIndexerFeatureFlags( + makeConfig({ + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: 500, + }) + ); + fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(IndexerFlagsConfigError); + const issue = (err as IndexerFlagsConfigError).issues.find(i => + i.includes('ENABLE_INDEXER_CURSOR_STALENESS_WARNING') + ); + expect(issue).toBeDefined(); + } + }); + + it('rejects DLQ enabled while dedupe is disabled', () => { + try { + validateIndexerFeatureFlags( + makeConfig({ + ENABLE_INDEXER_DEDUPE: false, + ENABLE_INDEXER_DLQ: true, + }) + ); + fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(IndexerFlagsConfigError); + expect((err as IndexerFlagsConfigError).issues[0]).toMatch(/DLQ/); + } + }); + + it('passes when both DLQ and dedupe are disabled together', () => { + expect(() => + validateIndexerFeatureFlags( + makeConfig({ + ENABLE_INDEXER_DEDUPE: false, + ENABLE_INDEXER_DLQ: false, + }) + ) + ).not.toThrow(); + }); + + it('aggregates multiple issues into a single error', () => { + try { + validateIndexerFeatureFlags( + makeConfig({ + INDEXER_JITTER_FACTOR: 5, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: 0, + ENABLE_INDEXER_DEDUPE: false, + ENABLE_INDEXER_DLQ: true, + }) + ); + fail('expected to throw'); + } catch (err) { + expect(err).toBeInstanceOf(IndexerFlagsConfigError); + expect((err as IndexerFlagsConfigError).issues.length).toBeGreaterThanOrEqual(3); + } + }); + + it('error message lists every issue with bullet prefixes', () => { + try { + validateIndexerFeatureFlags( + makeConfig({ INDEXER_JITTER_FACTOR: 9 }) + ); + fail('expected to throw'); + } catch (err) { + expect((err as Error).message).toMatch( + /Invalid indexer feature flag configuration/ + ); + expect((err as Error).message).toContain(' - '); + } + }); +}); diff --git a/src/utils/indexer-flags-startup-check.utils.ts b/src/utils/indexer-flags-startup-check.utils.ts new file mode 100644 index 0000000..e601eec --- /dev/null +++ b/src/utils/indexer-flags-startup-check.utils.ts @@ -0,0 +1,114 @@ +import { envConfig } from '../config'; +import { logger } from './logger.utils'; + +export interface IndexerFlagsConfig { + ENABLE_INDEXER_DEDUPE: boolean; + ENABLE_INDEXER_DLQ: boolean; + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: boolean; + INDEXER_JITTER_FACTOR: number; + INDEXER_CURSOR_STALE_AGE_WARNING_MS: number; +} + +/** + * Thrown when one or more indexer feature flag invariants fail at startup. + * The `issues` array carries one human-readable, actionable message per + * problem so the operator can fix every issue in a single pass. + */ +export class IndexerFlagsConfigError extends Error { + readonly issues: string[]; + + constructor(issues: string[]) { + super( + `Invalid indexer feature flag configuration:\n - ${issues.join('\n - ')}` + ); + this.name = 'IndexerFlagsConfigError'; + this.issues = issues; + } +} + +/** + * Validates the cross-field invariants of the indexer feature flags. Each + * env var is already type-checked by Zod in `src/config.ts`; this helper + * additionally enforces relationships between flags and their thresholds. + * + * The function throws an `IndexerFlagsConfigError` with **all** detected + * issues — operators see every problem at once instead of fixing them one + * boot at a time. + */ +export function validateIndexerFeatureFlags( + config: IndexerFlagsConfig +): void { + const issues: string[] = []; + + if ( + !Number.isFinite(config.INDEXER_JITTER_FACTOR) || + config.INDEXER_JITTER_FACTOR < 0 || + config.INDEXER_JITTER_FACTOR > 1 + ) { + issues.push( + `INDEXER_JITTER_FACTOR must be a number between 0 and 1 (got ${config.INDEXER_JITTER_FACTOR})` + ); + } + + if ( + !Number.isInteger(config.INDEXER_CURSOR_STALE_AGE_WARNING_MS) || + config.INDEXER_CURSOR_STALE_AGE_WARNING_MS <= 0 + ) { + issues.push( + `INDEXER_CURSOR_STALE_AGE_WARNING_MS must be a positive integer in milliseconds (got ${config.INDEXER_CURSOR_STALE_AGE_WARNING_MS})` + ); + } + + if ( + config.ENABLE_INDEXER_CURSOR_STALENESS_WARNING && + config.INDEXER_CURSOR_STALE_AGE_WARNING_MS < 1000 + ) { + issues.push( + `ENABLE_INDEXER_CURSOR_STALENESS_WARNING is on, but INDEXER_CURSOR_STALE_AGE_WARNING_MS=${config.INDEXER_CURSOR_STALE_AGE_WARNING_MS} is below the 1000ms minimum — either disable the flag or raise the threshold` + ); + } + + if ( + !config.ENABLE_INDEXER_DEDUPE && + config.ENABLE_INDEXER_DLQ + ) { + issues.push( + 'ENABLE_INDEXER_DLQ is on but ENABLE_INDEXER_DEDUPE is off — the DLQ relies on dedupe to identify duplicate failures, enable both or neither' + ); + } + + if (issues.length > 0) { + throw new IndexerFlagsConfigError(issues); + } +} + +/** + * Wrapper for use in the bootstrapping flow. Reads the active env config, + * validates it, and logs a one-line acknowledgement on success. Failures + * are re-thrown so the caller can decide how to exit (e.g. process.exit(1) + * with a stable, machine-readable code). + */ +export function runIndexerFeatureFlagsStartupCheck(): void { + validateIndexerFeatureFlags({ + ENABLE_INDEXER_DEDUPE: envConfig.ENABLE_INDEXER_DEDUPE, + ENABLE_INDEXER_DLQ: envConfig.ENABLE_INDEXER_DLQ, + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: + envConfig.ENABLE_INDEXER_CURSOR_STALENESS_WARNING, + INDEXER_JITTER_FACTOR: envConfig.INDEXER_JITTER_FACTOR, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: + envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS, + }); + + logger.info( + { + enableDedupe: envConfig.ENABLE_INDEXER_DEDUPE, + enableDlq: envConfig.ENABLE_INDEXER_DLQ, + enableStalenessWarning: + envConfig.ENABLE_INDEXER_CURSOR_STALENESS_WARNING, + cursorStaleAgeWarningMs: + envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS, + jitterFactor: envConfig.INDEXER_JITTER_FACTOR, + }, + 'Indexer feature flags validated' + ); +} diff --git a/src/utils/route-error.utils.test.ts b/src/utils/route-error.utils.test.ts new file mode 100644 index 0000000..a4f55e1 --- /dev/null +++ b/src/utils/route-error.utils.test.ts @@ -0,0 +1,78 @@ +import { mapUnknownRouteError } from './route-error.utils'; +import { ErrorCode } from '../constants/error.constants'; + +describe('mapUnknownRouteError()', () => { + it('maps a vanilla Error to a 500 with the generic safe message', () => { + const result = mapUnknownRouteError(new Error('boom')); + expect(result.statusCode).toBe(500); + expect(result.body.success).toBe(false); + expect(result.body.code).toBe(ErrorCode.INTERNAL_ERROR); + expect(result.body.message).toBe('Internal server error'); + }); + + it('preserves an explicit statusCode on the error', () => { + const err = Object.assign(new Error('nope'), { statusCode: 418 }); + const result = mapUnknownRouteError(err); + expect(result.statusCode).toBe(418); + }); + + it('preserves an explicit status property on the error', () => { + const err = Object.assign(new Error('nope'), { status: 502 }); + const result = mapUnknownRouteError(err); + expect(result.statusCode).toBe(502); + }); + + it('embeds the request id when provided', () => { + const result = mapUnknownRouteError(new Error('boom'), { + requestId: 'req-abc-123', + }); + expect(result.body.requestId).toBe('req-abc-123'); + }); + + it('omits requestId when none provided', () => { + const result = mapUnknownRouteError(new Error('boom')); + expect(result.body).not.toHaveProperty('requestId'); + }); + + it('hides the original message and omits debug fields by default', () => { + const err = new Error('secret leak'); + err.stack = 'stack-trace'; + const result = mapUnknownRouteError(err); + expect(result.body.message).toBe('Internal server error'); + expect(result.body).not.toHaveProperty('stack'); + expect(result.body).not.toHaveProperty('error'); + }); + + it('exposes message, stack and raw error when includeDebug is true', () => { + const err = new Error('debug detail'); + err.stack = 'stack-trace-here'; + const result = mapUnknownRouteError(err, { + requestId: 'r1', + includeDebug: true, + }); + expect(result.body.message).toBe('debug detail'); + expect(result.body.stack).toBe('stack-trace-here'); + expect(result.body.error).toBe(err); + }); + + it('falls back to a default message when err.message is empty in debug mode', () => { + const err = new Error(''); + const result = mapUnknownRouteError(err, { includeDebug: true }); + expect(result.body.message).toBe('Something went wrong'); + }); + + it('handles non-Error throwables (string, plain object) without crashing', () => { + expect(mapUnknownRouteError('oops').statusCode).toBe(500); + expect(mapUnknownRouteError({ statusCode: 503 }).statusCode).toBe(503); + expect(mapUnknownRouteError(null).statusCode).toBe(500); + expect(mapUnknownRouteError(undefined).statusCode).toBe(500); + }); + + it('uses errorCode from the error when present', () => { + const err = Object.assign(new Error('x'), { + errorCode: ErrorCode.RATE_LIMIT, + }); + const result = mapUnknownRouteError(err); + expect(result.body.code).toBe(ErrorCode.RATE_LIMIT); + }); +}); diff --git a/src/utils/route-error.utils.ts b/src/utils/route-error.utils.ts new file mode 100644 index 0000000..48cb4d8 --- /dev/null +++ b/src/utils/route-error.utils.ts @@ -0,0 +1,78 @@ +import { ErrorCode, ErrorCodeType } from '../constants/error.constants'; + +export interface RouteSafeErrorEnvelope { + success: false; + code: ErrorCodeType; + message: string; + requestId?: string; + stack?: string; + error?: unknown; +} + +export interface RouteSafeErrorResult { + statusCode: number; + body: RouteSafeErrorEnvelope; +} + +export interface MapUnknownRouteErrorOptions { + requestId?: string; + includeDebug?: boolean; +} + +/** + * Maps an unknown error thrown from a route handler to a safe API error envelope. + * + * The helper is intended for the fallback path only — known errors (Zod, JWT, + * Prisma, ApiError, payload-too-large, malformed JSON, etc.) should still be + * mapped by their dedicated branches in the global error middleware so their + * existing status codes and codes are preserved. + * + * In production-safe mode (`includeDebug=false`) the message is generic and no + * stack or raw error is leaked. When `includeDebug` is true (development) the + * original message and stack are surfaced for local debugging. + * + * The route context id (typically the `X-Request-ID`) is embedded so an + * operator can correlate the response with server logs. + */ +export function mapUnknownRouteError( + err: unknown, + options: MapUnknownRouteErrorOptions = {} +): RouteSafeErrorResult { + const { requestId, includeDebug = false } = options; + + const fromErr = err as { + statusCode?: number; + status?: number; + message?: string; + stack?: string; + errorCode?: ErrorCodeType; + }; + + const statusCode = + typeof fromErr?.statusCode === 'number' + ? fromErr.statusCode + : typeof fromErr?.status === 'number' + ? fromErr.status + : 500; + + const message = includeDebug + ? fromErr?.message || 'Something went wrong' + : 'Internal server error'; + + const body: RouteSafeErrorEnvelope = { + success: false, + code: fromErr?.errorCode || ErrorCode.INTERNAL_ERROR, + message, + }; + + if (requestId) { + body.requestId = requestId; + } + + if (includeDebug) { + if (fromErr?.stack) body.stack = fromErr.stack; + body.error = err; + } + + return { statusCode, body }; +}