Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<fastify request id>" }` — 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`).

Expand Down
27 changes: 24 additions & 3 deletions src/shared/errors/error-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -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),
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
});
});
21 changes: 20 additions & 1 deletion src/shared/errors/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -59,6 +69,7 @@ export function handleError(
message: 'Request validation failed',
details: zodToDetails(error),
},
requestId,
};
void reply.status(400).send(body);
return;
Expand All @@ -78,6 +89,7 @@ export function handleError(
message: 'Request validation failed',
details: validationError.validation,
},
requestId,
};
void reply.status(400).send(body);
return;
Expand All @@ -87,13 +99,15 @@ 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;
}
if (error.code === 'P2025') {
const body: ErrorResponseBody = {
error: { code: 'NOT_FOUND', message: 'Resource not found' },
requestId,
};
void reply.status(404).send(body);
return;
Expand All @@ -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;
Expand All @@ -115,13 +130,15 @@ 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;
}
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;
Expand All @@ -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);
Expand All @@ -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);
}
Loading