diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 4c5c932..a84dcb7 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -6,7 +6,7 @@ The live, authoritative reference is generated from the same Zod schemas that va - All routes are versioned under `/api/v1` except `/health*` and `/api-docs`. - Success responses: `{ "data": ..., "meta"?: {...} }`. -- Error responses: `{ "error": { "code": "...", "message": "...", "details"?: ... } }` — see `src/shared/errors` for the full code list. +- Error responses: `{ "error": { "code": "...", "message": "...", "details"?: ... }, "requestId": "" }` — see `src/shared/errors` for the full code list. The top-level `requestId` lets a user reporting a 500 be correlated to the matching `req.id` in the server's request log line. - Mutating endpoints that reflect on-chain state (deliveries, escrow, disputes, fleet) return a **pending transaction record**, not a synchronously-updated resource — the underlying resource only reaches its new state once the blockchain indexer confirms the corresponding on-chain event. See `ARCHITECTURE.md` §9. - Endpoints that require a wallet-owned signature (`sender`, `recipient`, `driver`, `fleet owner` actions per `PHASE_1_DOMAIN_ANALYSIS.md`) live under `/transactions/build/*` and return unsigned XDR — this backend never signs on a user's behalf (`AUTHENTICATION.md`). diff --git a/src/shared/errors/error-handler.spec.ts b/src/shared/errors/error-handler.spec.ts index f9d6ad9..1cf1fcf 100644 --- a/src/shared/errors/error-handler.spec.ts +++ b/src/shared/errors/error-handler.spec.ts @@ -4,20 +4,26 @@ import { handleError } from './error-handler.js'; import { AppError } from './app-error.js'; class BlockchainError extends AppError { + readonly statusCode = 502; + readonly code = 'BLOCKCHAIN_ERROR'; constructor(message: string, details?: unknown) { - super('BLOCKCHAIN_ERROR', message, 502, details); + super(message, details); } } class InternalError extends AppError { + readonly statusCode = 500; + readonly code = 'INTERNAL_ERROR'; constructor(message: string, details?: unknown) { - super('INTERNAL_ERROR', message, 500, details); + super(message, details); } } class ClientError extends AppError { + readonly statusCode = 400; + readonly code = 'BAD_REQUEST'; constructor(message: string, details?: unknown) { - super('BAD_REQUEST', message, 400, details); + super(message, details); } } @@ -32,6 +38,7 @@ describe('error-handler', () => { function createMockRequest(logFn: (level: string, arg: unknown, msg: string) => void): FastifyRequest { const mockRequest = { + id: 'req-123', log: { error: (arg: unknown, msg: string) => logFn('error', arg, msg), warn: (arg: unknown, msg: string) => logFn('warn', arg, msg), @@ -60,6 +67,7 @@ describe('error-handler', () => { expect(sendArg.error.message).toBe('Soroban RPC call failed'); expect(sendArg.error.code).toBe('BLOCKCHAIN_ERROR'); expect(sendArg.error.details).toBeUndefined(); + expect(sendArg.requestId).toBe('req-123'); expect(logs.length).toBeGreaterThan(0); const errorLog = logs.find((log) => log.level === 'error'); @@ -151,5 +159,18 @@ describe('error-handler', () => { const sendArg = (reply.send as any).mock.calls[0]?.[0]; expect(sendArg.error.message).toBe('An unexpected error occurred'); expect(sendArg.error.code).toBe('INTERNAL_ERROR'); + expect(sendArg.requestId).toBe('req-123'); + }); + + it('includes the request id on 4xx validation responses', () => { + const reply = createMockReply(); + const request = createMockRequest(() => {}); + + const error = new ClientError('Bad request', {}); + + handleError(error, request, reply); + + const sendArg = (reply.send as any).mock.calls[0]?.[0]; + expect(sendArg.requestId).toBe('req-123'); }); }); diff --git a/src/shared/errors/error-handler.ts b/src/shared/errors/error-handler.ts index c5e4c62..a891661 100644 --- a/src/shared/errors/error-handler.ts +++ b/src/shared/errors/error-handler.ts @@ -8,6 +8,9 @@ interface ErrorResponseBody { message: string; details?: unknown; }; + /** Fastify request id — appears in the request log line, so a user reporting + * an error can share this and support can correlate it to a logged incident. */ + requestId: string; } /** Duck-typed check — avoids a hard import dependency on @prisma/client's @@ -39,9 +42,16 @@ export function handleError( request: FastifyRequest, reply: FastifyReply, ): void { + const requestId = request.id; if (error instanceof AppError) { + // 5xx details (e.g. DB connection strings, RPC payloads) are logged + // server-side but never echoed back to clients, so they can't leak. const body: ErrorResponseBody = { - error: { code: error.code, message: error.message, details: error.details }, + error: + error.statusCode >= 500 + ? { code: error.code, message: error.message } + : { code: error.code, message: error.message, details: error.details }, + requestId, }; if (error.statusCode >= 500) { request.log.error({ err: error }, error.message); @@ -59,6 +69,7 @@ export function handleError( message: 'Request validation failed', details: zodToDetails(error), }, + requestId, }; void reply.status(400).send(body); return; @@ -78,6 +89,7 @@ export function handleError( message: 'Request validation failed', details: validationError.validation, }, + requestId, }; void reply.status(400).send(body); return; @@ -87,6 +99,7 @@ export function handleError( if (error.code === 'P2002') { const body: ErrorResponseBody = { error: { code: 'CONFLICT', message: 'Resource already exists', details: error.meta }, + requestId, }; void reply.status(409).send(body); return; @@ -94,6 +107,7 @@ export function handleError( if (error.code === 'P2025') { const body: ErrorResponseBody = { error: { code: 'NOT_FOUND', message: 'Resource not found' }, + requestId, }; void reply.status(404).send(body); return; @@ -105,6 +119,7 @@ export function handleError( message: 'A related resource required by this operation does not exist', details: error.meta, }, + requestId, }; void reply.status(409).send(body); return; @@ -115,6 +130,7 @@ export function handleError( code: 'WRITE_CONFLICT', message: 'The write conflicted with a concurrent transaction and may be retried', }, + requestId, }; void reply.status(409).send(body); return; @@ -122,6 +138,7 @@ export function handleError( if (error.code === 'P1001' || error.code === 'P1002') { const body: ErrorResponseBody = { error: { code: 'DATABASE_UNAVAILABLE', message: 'The database is currently unreachable' }, + requestId, }; void reply.status(503).send(body); return; @@ -132,6 +149,7 @@ export function handleError( if (typeof fastifyError.statusCode === 'number' && fastifyError.statusCode < 500) { const body: ErrorResponseBody = { error: { code: fastifyError.code ?? 'BAD_REQUEST', message: fastifyError.message }, + requestId, }; request.log.warn({ err: error }, error.message); void reply.status(fastifyError.statusCode).send(body); @@ -141,6 +159,7 @@ export function handleError( request.log.error({ err: error }, 'Unhandled error'); const body: ErrorResponseBody = { error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }, + requestId, }; void reply.status(500).send(body); }