From 1f5cec781bf281656f9d00aa2a5b1fa3acb5c757 Mon Sep 17 00:00:00 2001 From: Baskarayelu Date: Thu, 27 Aug 2026 17:54:29 +0530 Subject: [PATCH] feat(errors): standardize public error envelopes --- src/errors/errorEnvelopePolicy.test.ts | 106 ++++++++++ src/errors/errorEnvelopePolicy.ts | 129 ++++++++++++ .../envelope.error-contract.test.ts | 194 ++++++++++++++++++ src/middleware/errorHandler.contract.test.ts | 144 +++++++++++++ src/middleware/errorHandler.ts | 63 ++---- 5 files changed, 586 insertions(+), 50 deletions(-) create mode 100644 src/errors/errorEnvelopePolicy.test.ts create mode 100644 src/errors/errorEnvelopePolicy.ts create mode 100644 src/middleware/envelope.error-contract.test.ts create mode 100644 src/middleware/errorHandler.contract.test.ts diff --git a/src/errors/errorEnvelopePolicy.test.ts b/src/errors/errorEnvelopePolicy.test.ts new file mode 100644 index 00000000..7ea2b275 --- /dev/null +++ b/src/errors/errorEnvelopePolicy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from '@jest/globals'; +import { + boundedRetryAfterMs, + isPublicErrorCode, + normalizeError, + normalizePublicCode, + publicCodeForStatus, + safePublicMessage, + safeValidationDetails, +} from './errorEnvelopePolicy.js'; + +describe('error envelope policy', () => { + it('maps all supported HTTP statuses to stable public codes', () => { + const expected: Array<[number, string]> = [ + [400, 'BAD_REQUEST'], [401, 'UNAUTHORIZED'], [402, 'PAYMENT_REQUIRED'], + [403, 'FORBIDDEN'], [404, 'NOT_FOUND'], [408, 'REQUEST_TIMEOUT'], + [409, 'CONFLICT'], [413, 'REQUEST_BODY_TOO_LARGE'], [415, 'UNSUPPORTED_MEDIA_TYPE'], + [422, 'UNPROCESSABLE_ENTITY'], [429, 'TOO_MANY_REQUESTS'], [500, 'INTERNAL_SERVER_ERROR'], + [502, 'BAD_GATEWAY'], [503, 'SERVICE_UNAVAILABLE'], [504, 'GATEWAY_TIMEOUT'], + ]; + for (const [status, code] of expected) expect(publicCodeForStatus(status)).toBe(code); + }); + + it('uses safe fallback codes for unknown statuses and invalid supplied codes', () => { + expect(publicCodeForStatus(501)).toBe('INTERNAL_SERVER_ERROR'); + expect(publicCodeForStatus(418)).toBe('BAD_REQUEST'); + expect(normalizePublicCode('NOT_A_PUBLIC_CODE', 502)).toBe('BAD_GATEWAY'); + expect(normalizePublicCode('CONFLICT', 500)).toBe('CONFLICT'); + expect(isPublicErrorCode('NOT_FOUND')).toBe(true); + expect(isPublicErrorCode('database_error')).toBe(false); + }); + + it('preserves trusted application messages', () => { + expect(safePublicMessage('Wallet is suspended', 403, true)).toBe('Wallet is suspended'); + expect(safePublicMessage('', 403, true)).toBe('Request failed'); + expect(safePublicMessage('body too large', 413, true)).toBe('Request body too large'); + }); + + it('hides unknown production failures and sensitive development messages', () => { + expect(safePublicMessage('database password=secret', 500, false, true)).toBe('Internal server error'); + expect(safePublicMessage('connection string postgres://...', 502, false, true)).toBe('Internal server error'); + expect(safePublicMessage('upstream unavailable', 502, false, false)).toBe('Internal server error'); + expect(safePublicMessage('bad input', 400, false, true)).toBe('bad input'); + }); + + it('normalizes and bounds validation details', () => { + expect(safeValidationDetails([ + { field: 'body.email', message: 'Invalid email', code: 'INVALID_FORMAT' }, + { field: 4, message: 'ignored', code: 'BAD' }, + null, + ])).toEqual([{ field: 'body.email', message: 'Invalid email', code: 'INVALID_FORMAT' }]); + expect(safeValidationDetails([])).toBeUndefined(); + expect(safeValidationDetails('not-details')).toBeUndefined(); + expect(safeValidationDetails([{ field: 'x', message: 'y' }])).toBeUndefined(); + }); + + it('bounds untrusted retry metadata to one day', () => { + expect(boundedRetryAfterMs(0)).toBe(0); + expect(boundedRetryAfterMs(12.9)).toBe(12); + expect(boundedRetryAfterMs(999_999_999)).toBe(86_400_000); + expect(boundedRetryAfterMs(-1)).toBeUndefined(); + expect(boundedRetryAfterMs(Number.NaN)).toBeUndefined(); + expect(boundedRetryAfterMs('100')).toBeUndefined(); + }); + + it('normalizes trusted validation errors into the versioned contract', () => { + expect(normalizeError({ + statusCode: 422, + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + details: [{ field: 'body.amount', message: 'Required', code: 'INVALID_TYPE' }], + trusted: true, + })).toEqual({ + statusCode: 422, + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + details: [{ field: 'body.amount', message: 'Required', code: 'INVALID_TYPE' }], + }); + }); + + it('normalizes rate-limit metadata without exposing unsupported fields', () => { + expect(normalizeError({ statusCode: 429, message: 'Too many requests', retryAfterMs: 5_000, trusted: true })).toEqual({ + statusCode: 429, + code: 'TOO_MANY_REQUESTS', + message: 'Too many requests', + retryAfterMs: 5_000, + }); + }); + + it('uses generic messages for unknown client errors in production', () => { + const result = normalizeError({ statusCode: 400, message: 'private internal detail', trusted: false, development: false }); + expect(result).toMatchObject({ code: 'BAD_REQUEST', message: 'Request failed' }); + }); + + it('keeps safe developer diagnostics only for non-sensitive client failures', () => { + expect(normalizeError({ statusCode: 400, message: 'field x is invalid', trusted: false, development: true }).message).toBe('field x is invalid'); + expect(normalizeError({ statusCode: 500, message: 'field x is invalid', trusted: false, development: true }).message).toBe('Internal server error'); + }); + + it('truncates oversized validation values', () => { + const result = safeValidationDetails([{ field: 'f'.repeat(500), message: 'm'.repeat(800), code: 'c'.repeat(200) }]); + expect(result?.[0].field).toHaveLength(200); + expect(result?.[0].message).toHaveLength(500); + expect(result?.[0].code).toHaveLength(100); + }); +}); diff --git a/src/errors/errorEnvelopePolicy.ts b/src/errors/errorEnvelopePolicy.ts new file mode 100644 index 00000000..a7087c7d --- /dev/null +++ b/src/errors/errorEnvelopePolicy.ts @@ -0,0 +1,129 @@ +import type { ValidationErrorDetail } from '../middleware/validate.js'; + +export const PUBLIC_ERROR_CODES = [ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'PAYMENT_REQUIRED', + 'FORBIDDEN', + 'NOT_FOUND', + 'REQUEST_TIMEOUT', + 'CONFLICT', + 'REQUEST_BODY_TOO_LARGE', + 'UNSUPPORTED_MEDIA_TYPE', + 'UNPROCESSABLE_ENTITY', + 'TOO_MANY_REQUESTS', + 'INTERNAL_SERVER_ERROR', + 'BAD_GATEWAY', + 'SERVICE_UNAVAILABLE', + 'GATEWAY_TIMEOUT', + 'VALIDATION_ERROR', + 'INVALID_BODY', + 'INVALID_QUERY', + 'INVALID_PARAMS', + 'INVALID_VALUE', + 'INSUFFICIENT_BALANCE', + 'NETWORK_UNAVAILABLE', + 'NETWORK_MISMATCH', + 'SOROBAN_RPC_TIMEOUT', + 'SOROBAN_RPC_ERROR', + 'BILLING_DEDUCTION_FAILED', + 'BILLING_REQUEST_NOT_FOUND', + 'DEVELOPER_NOT_FOUND', + 'API_ACCESS_FORBIDDEN', + 'API_KEY_NOT_FOUND', + 'API_KEY_FORBIDDEN', + 'NOT_AUTHENTICATED', + 'REFRESH_FAILED', + 'REVOKE_FAILED', + 'VAULT_NOT_FOUND', + 'INTERNAL_ERROR', +] as const; + +export type PublicErrorCode = typeof PUBLIC_ERROR_CODES[number]; + +const codeByStatus: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 402: 'PAYMENT_REQUIRED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 408: 'REQUEST_TIMEOUT', + 409: 'CONFLICT', + 413: 'REQUEST_BODY_TOO_LARGE', + 415: 'UNSUPPORTED_MEDIA_TYPE', + 422: 'UNPROCESSABLE_ENTITY', + 429: 'TOO_MANY_REQUESTS', + 500: 'INTERNAL_SERVER_ERROR', + 502: 'BAD_GATEWAY', + 503: 'SERVICE_UNAVAILABLE', + 504: 'GATEWAY_TIMEOUT', +}; + +const sensitiveMessage = /(?:password|secret|token|authorization|stack|postgres|sql|database url|connection string)/i; + +export function isPublicErrorCode(value: unknown): value is PublicErrorCode { + return typeof value === 'string' && (PUBLIC_ERROR_CODES as readonly string[]).includes(value); +} + +export function publicCodeForStatus(status: number): PublicErrorCode { + return codeByStatus[status] ?? (status >= 500 ? 'INTERNAL_SERVER_ERROR' : 'BAD_REQUEST'); +} + +export function normalizePublicCode(value: unknown, status: number): PublicErrorCode { + return isPublicErrorCode(value) ? value : publicCodeForStatus(status); +} + +export function safePublicMessage(message: unknown, status: number, isTrustedError: boolean, isDevelopment = false): string { + if (status === 413) return 'Request body too large'; + if (isTrustedError && typeof message === 'string' && message.trim() !== '') return message; + if (isDevelopment && status < 500 && typeof message === 'string' && message.trim() !== '' && !sensitiveMessage.test(message)) return message; + return status >= 500 ? 'Internal server error' : 'Request failed'; +} + +export function safeValidationDetails(value: unknown): ValidationErrorDetail[] | undefined { + if (!Array.isArray(value)) return undefined; + const details = value.filter((item): item is ValidationErrorDetail => { + if (!item || typeof item !== 'object') return false; + const candidate = item as Record; + return typeof candidate.field === 'string' && typeof candidate.message === 'string' && typeof candidate.code === 'string'; + }).map((detail) => ({ + field: detail.field.slice(0, 200), + message: detail.message.slice(0, 500), + code: detail.code.slice(0, 100), + })); + return details.length > 0 ? details : undefined; +} + +export function boundedRetryAfterMs(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined; + return Math.min(Math.floor(value), 86_400_000); +} + +export interface NormalizedError { + statusCode: number; + code: PublicErrorCode; + message: string; + details?: ValidationErrorDetail[]; + retryAfterMs?: number; +} + +export function normalizeError(input: { + statusCode: number; + code?: unknown; + message?: unknown; + details?: unknown; + retryAfterMs?: unknown; + trusted: boolean; + development?: boolean; +}): NormalizedError { + const normalized: NormalizedError = { + statusCode: input.statusCode, + code: normalizePublicCode(input.code, input.statusCode), + message: safePublicMessage(input.message, input.statusCode, input.trusted, input.development ?? false), + }; + const details = safeValidationDetails(input.details); + const retryAfterMs = boundedRetryAfterMs(input.retryAfterMs); + if (details) normalized.details = details; + if (retryAfterMs !== undefined) normalized.retryAfterMs = retryAfterMs; + return normalized; +} diff --git a/src/middleware/envelope.error-contract.test.ts b/src/middleware/envelope.error-contract.test.ts new file mode 100644 index 00000000..b5d4d3d2 --- /dev/null +++ b/src/middleware/envelope.error-contract.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; +import { z } from 'zod'; +import { + createResponseValidatorMiddleware, + envelopeMiddleware, + errorEnvelopeSchema, + successEnvelopeSchema, +} from './envelope.js'; + +interface FakeResult { + body: unknown; + statusCode: number; + contentType?: string; + sent: boolean; +} + +function response(statusCode = 200, contentType?: string) { + const result: FakeResult = { body: undefined, statusCode, contentType, sent: false }; + const value = { + headersSent: false, + statusCode, + getHeader(name: string) { + return name.toLowerCase() === 'content-type' ? result.contentType : undefined; + }, + status(code: number) { + result.statusCode = code; + value.statusCode = code; + return value; + }, + json(body: unknown) { + result.body = body; + result.sent = true; + return value; + }, + send(body: unknown) { + result.body = body; + result.sent = true; + return value; + }, + } as unknown as Response; + return { result, value }; +} + +function request(id = 'envelope-test-id'): Request { + return { id, path: '/contract-test' } as Request; +} + +function runMiddleware(middleware: (req: Request, res: Response, next: NextFunction) => void, res: Response) { + const next = jest.fn() as unknown as NextFunction; + middleware(request(), res, next); + expect(next).toHaveBeenCalledTimes(1); +} + +describe('envelope middleware error contract', () => { + it('wraps a plain JSON client error and preserves supported diagnostics', () => { + const output = response(422); + runMiddleware(envelopeMiddleware, output.value); + + output.value.json({ + code: 'INVALID_BODY', + message: 'amount must be positive', + details: [{ field: 'amount', message: 'must be positive', code: 'MINIMUM' }], + retryAfterMs: 1500, + }); + + expect(output.result.statusCode).toBe(422); + expect(errorEnvelopeSchema.parse(output.result.body)).toEqual(expect.objectContaining({ + success: false, + requestId: 'envelope-test-id', + error: { + code: 'INVALID_BODY', + message: 'amount must be positive', + details: [{ field: 'amount', message: 'must be positive', code: 'MINIMUM' }], + retryAfterMs: 1500, + }, + })); + }); + + it('uses the internal error code when a bare server error is sent', () => { + const output = response(500); + runMiddleware(envelopeMiddleware, output.value); + output.value.json({ message: 'unexpected failure' }); + + expect(errorEnvelopeSchema.parse(output.result.body).error).toEqual({ + code: 'INTERNAL_SERVER_ERROR', + message: 'unexpected failure', + }); + }); + + it('uses the generic client code when an error body has no code', () => { + const output = response(400); + runMiddleware(envelopeMiddleware, output.value); + output.value.json({ error: 'invalid filter' }); + + expect(errorEnvelopeSchema.parse(output.result.body).error).toEqual({ + code: 'BAD_REQUEST', + message: 'invalid filter', + }); + }); + + it('does not double-wrap an error envelope from errorHandler', () => { + const output = response(409); + runMiddleware(envelopeMiddleware, output.value); + const existing = { + success: false as const, + error: { code: 'CONFLICT', message: 'already exists' }, + requestId: 'handler-id', + timestamp: new Date().toISOString(), + }; + output.value.json(existing); + + expect(output.result.body).toBe(existing); + }); + + it('wraps ordinary successful JSON data with the request metadata', () => { + const output = response(); + runMiddleware(envelopeMiddleware, output.value); + output.value.json({ accountId: 'acct-1', active: true }); + + const envelope = successEnvelopeSchema.parse(output.result.body); + expect(envelope).toEqual(expect.objectContaining({ + success: true, + data: { accountId: 'acct-1', active: true }, + requestId: 'envelope-test-id', + })); + }); + + it('wraps JSON strings sent by legacy route handlers', () => { + const output = response(); + runMiddleware(envelopeMiddleware, output.value); + output.value.send(JSON.stringify({ ok: true })); + + expect(successEnvelopeSchema.parse(JSON.parse(output.result.body as string)).data).toEqual({ ok: true }); + }); + + it('leaves non-JSON and streamed content untouched', () => { + const csv = response(200, 'text/csv; charset=utf-8'); + runMiddleware(envelopeMiddleware, csv.value); + csv.value.send('id,name\n1,Callora'); + expect(csv.result.body).toBe('id,name\n1,Callora'); + + const stream = response(200, 'text/event-stream'); + runMiddleware(envelopeMiddleware, stream.value); + stream.value.send('data: ping\n\n'); + expect(stream.result.body).toBe('data: ping\n\n'); + }); +}); + +describe('response envelope validator contract', () => { + it('accepts a fully formed error envelope without changing it', () => { + const output = response(429); + runMiddleware(createResponseValidatorMiddleware(), output.value); + const envelope = { + success: false as const, + error: { code: 'TOO_MANY_REQUESTS', message: 'slow down', retryAfterMs: 2000 }, + requestId: 'validator-id', + timestamp: new Date().toISOString(), + }; + output.value.json(envelope); + + expect(output.result.body).toBe(envelope); + expect(output.result.statusCode).toBe(429); + }); + + it('turns an invalid envelope into a safe server error envelope', () => { + const output = response(200); + runMiddleware(createResponseValidatorMiddleware(), output.value); + output.value.json({ success: true, data: { missing: 'metadata' } }); + + expect(output.result.statusCode).toBe(500); + expect(errorEnvelopeSchema.parse(output.result.body).error).toEqual({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Response contract violation: envelope validation failed', + }); + }); + + it('validates endpoint data schemas inside the success envelope', () => { + const output = response(); + runMiddleware( + createResponseValidatorMiddleware(z.object({ id: z.string() })), + output.value, + ); + output.value.json({ + success: true, + data: { id: 42 }, + requestId: 'validator-id', + timestamp: new Date().toISOString(), + }); + + expect(output.result.statusCode).toBe(500); + expect(errorEnvelopeSchema.parse(output.result.body).error.code).toBe('INTERNAL_SERVER_ERROR'); + }); +}); diff --git a/src/middleware/errorHandler.contract.test.ts b/src/middleware/errorHandler.contract.test.ts new file mode 100644 index 00000000..41f99521 --- /dev/null +++ b/src/middleware/errorHandler.contract.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; +import { AppError, BadRequestError, ConflictError, TooManyRequestsError } from '../errors/index.js'; +import { ValidationError } from './validate.js'; +import { errorHandler } from './errorHandler.js'; + +function response() { + const result = { statusCode: 200, body: undefined as unknown, sent: false }; + const value = { + headersSent: false, + status(code: number) { result.statusCode = code; return value; }, + json(body: unknown) { result.body = body; result.sent = true; return value; }, + } as unknown as Response; + return { result, value }; +} + +function request(id = 'req-123'): Request { + return { id } as Request; +} + +function body(result: { body: unknown }): Record { + return result.body as Record; +} + +describe('errorHandler contract', () => { + it('maps a trusted bad request to the versioned envelope', () => { + const output = response(); + errorHandler(new BadRequestError('amount is invalid'), request(), output.value, jest.fn() as unknown as NextFunction); + expect(output.result.statusCode).toBe(400); + expect(body(output.result)).toEqual(expect.objectContaining({ + success: false, + requestId: 'req-123', + error: { code: 'BAD_REQUEST', message: 'amount is invalid' }, + })); + expect(body(output.result)).toHaveProperty('timestamp'); + }); + + it('preserves a stable domain-specific public code', () => { + const output = response(); + errorHandler(new AppError('wallet blocked', 403, 'API_ACCESS_FORBIDDEN'), request('domain-id'), output.value, jest.fn() as unknown as NextFunction); + expect(body(output.result).error).toEqual({ code: 'API_ACCESS_FORBIDDEN', message: 'wallet blocked' }); + expect(body(output.result).requestId).toBe('domain-id'); + }); + + it('maps conflict and rate-limit errors to their public codes', () => { + const conflict = response(); + errorHandler(new ConflictError('duplicate request'), request(), conflict.value, jest.fn() as unknown as NextFunction); + expect((body(conflict.result).error as Record).code).toBe('CONFLICT'); + const limited = response(); + errorHandler(new TooManyRequestsError('slow down'), request(), limited.value, jest.fn() as unknown as NextFunction); + expect(limited.result.statusCode).toBe(429); + expect((body(limited.result).error as Record).code).toBe('TOO_MANY_REQUESTS'); + }); + + it('returns field-level validation details without a stack', () => { + const output = response(); + const error = new ValidationError([ + { field: 'body.email', message: 'must be an email', code: 'INVALID_FORMAT' }, + { field: 'body.amount', message: 'required', code: 'REQUIRED' }, + ]); + errorHandler(error, request('validation-id'), output.value, jest.fn() as unknown as NextFunction); + expect(output.result.statusCode).toBe(400); + expect(body(output.result)).toEqual(expect.objectContaining({ success: false, requestId: 'validation-id' })); + expect((body(output.result).error as Record).details).toEqual([ + { field: 'body.email', message: 'must be an email', code: 'INVALID_FORMAT' }, + { field: 'body.amount', message: 'required', code: 'REQUIRED' }, + ]); + expect(JSON.stringify(output.result.body)).not.toContain('stack'); + }); + + it('uses a generic message for unknown production failures', () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const output = response(); + errorHandler(new Error('postgres password=secret'), request('prod-id'), output.value, jest.fn() as unknown as NextFunction); + expect(output.result.statusCode).toBe(500); + expect((body(output.result).error as Record).message).toBe('Internal server error'); + expect(JSON.stringify(output.result.body)).not.toContain('postgres'); + expect(JSON.stringify(output.result.body)).not.toContain('secret'); + } finally { + if (previous === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previous; + } + }); + + it('allows safe client diagnostics in development but never server failures', () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + const client = response(); + const clientError = Object.assign(new Error('invalid cursor format'), { status: 400 }); + errorHandler(clientError, request(), client.value, jest.fn() as unknown as NextFunction); + expect((body(client.result).error as Record).message).toBe('invalid cursor format'); + const server = response(); + errorHandler(new Error('service crashed'), request(), server.value, jest.fn() as unknown as NextFunction); + expect((body(server.result).error as Record).message).toBe('Internal server error'); + } finally { + if (previous === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previous; + } + }); + + it('derives a public fallback code for an unknown status', () => { + const output = response(); + const error = new Error('client failure') as Error & { status: number }; + error.status = 418; + errorHandler(error, request(), output.value, jest.fn() as unknown as NextFunction); + expect(output.result.statusCode).toBe(418); + expect((body(output.result).error as Record).code).toBe('BAD_REQUEST'); + }); + + it('does not overwrite a response that already sent headers', () => { + const output = response(); + (output.value as Response).headersSent = true; + errorHandler(new Error('already handled'), request(), output.value, jest.fn() as unknown as NextFunction); + expect(output.result.sent).toBe(false); + }); + + it('keeps error responses JSON-compatible for null and non-Error throws', () => { + for (const thrown of [null, 'failure', 42, { reason: 'failure' }]) { + const output = response(); + errorHandler(thrown, request('coercion-id'), output.value, jest.fn() as unknown as NextFunction); + expect(output.result.statusCode).toBe(500); + expect(body(output.result)).toEqual(expect.objectContaining({ success: false, requestId: 'coercion-id' })); + } + }); + + it('handles status-bearing upstream errors without echoing their message', () => { + const output = response(); + const upstream = Object.assign(new Error('upstream api-key leaked'), { status: 502 }); + errorHandler(upstream, request('upstream-id'), output.value, jest.fn() as unknown as NextFunction); + expect(output.result.statusCode).toBe(502); + expect(body(output.result).error).toEqual({ code: 'BAD_GATEWAY', message: 'Internal server error' }); + }); + + it('keeps the request id stable across every error class', () => { + for (const error of [new BadRequestError(), new ConflictError(), new TooManyRequestsError()]) { + const output = response(); + errorHandler(error, request('stable-correlation'), output.value, jest.fn() as unknown as NextFunction); + expect(body(output.result).requestId).toBe('stable-correlation'); + } + }); +}); diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index da469d3a..ebe5a38f 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -5,6 +5,7 @@ import type { ValidationErrorDetail } from './validate.js'; import { ValidationError } from './validate.js'; import { buildErrorEnvelope } from './envelope.js'; import type { ErrorEnvelope } from '../types/ResponseEnvelope.js'; +import { normalizeError } from '../errors/errorEnvelopePolicy.js'; const isProduction = process.env.NODE_ENV === "production"; @@ -24,43 +25,6 @@ function extractValidationDetails(err: unknown): ValidationErrorDetail[] | undef return undefined; } -function deriveErrorCode(statusCode: number): string { - switch (statusCode) { - case 400: - return "BAD_REQUEST"; - case 401: - return "UNAUTHORIZED"; - case 402: - return "PAYMENT_REQUIRED"; - case 403: - return "FORBIDDEN"; - case 404: - return "NOT_FOUND"; - case 408: - return "REQUEST_TIMEOUT"; - case 409: - return "CONFLICT"; - case 413: - return "REQUEST_BODY_TOO_LARGE"; - case 415: - return "UNSUPPORTED_MEDIA_TYPE"; - case 422: - return "UNPROCESSABLE_ENTITY"; - case 429: - return "TOO_MANY_REQUESTS"; - case 500: - return "INTERNAL_SERVER_ERROR"; - case 502: - return "BAD_GATEWAY"; - case 503: - return "SERVICE_UNAVAILABLE"; - case 504: - return "GATEWAY_TIMEOUT"; - default: - return statusCode >= 500 ? "INTERNAL_SERVER_ERROR" : "BAD_REQUEST"; - } -} - /** * Global error-handling middleware (4-arg form). * - Catches errors thrown in routes/services @@ -75,10 +39,11 @@ export function errorHandler( res: Response, _next: NextFunction, ): void { + const statusCarrier = err !== null && typeof err === 'object' ? err as Record : undefined; const statusCode = isAppError(err) ? err.statusCode - : typeof (err as Record).status === "number" - ? (err as { status: number }).status + : typeof statusCarrier?.status === "number" + ? statusCarrier.status : 500; const rawMessage = @@ -88,18 +53,16 @@ export function errorHandler( ? err.message : "Internal server error"; - const code = isAppError(err) - ? (err.code ?? deriveErrorCode(statusCode)) - : deriveErrorCode(statusCode); const requestId = req.id || "unknown"; - - let finalMessage = rawMessage; - if (process.env.NODE_ENV !== "development" && !isAppError(err)) { - finalMessage = "Internal server error"; - } - - const details = extractValidationDetails(err); - const body = buildErrorEnvelope(code, finalMessage, requestId, details); + const normalized = normalizeError({ + statusCode, + code: isAppError(err) ? err.code : undefined, + message: rawMessage, + details: extractValidationDetails(err), + trusted: isAppError(err), + development: process.env.NODE_ENV === 'development', + }); + const body = buildErrorEnvelope(normalized.code, normalized.message, requestId, normalized.details, normalized.retryAfterMs); if (!res.headersSent) { res.status(statusCode).json(body);