Error Handling
Centralized error handling strategy, standard error codes, and developer guidelines.
Remote Eaze uses a centralized, standardized error handling mechanism to ensure consistent API responses, security, and observability.
Philosophy
- Fail Fast: Validate inputs immediately (Zod) and throw errors early.
- Fail Safe: Never crash the server. The Global Error Handler catches everything.
- Fail Secure: Never leak stack traces or internal database details to the client in production.
- Fail Loudly (Internally): Log 500s with full context (request ID, URL, stack) for debugging.
Standard Error Response Format
All API errors (4xx and 5xx) follow this JSON structure:
{
"code": "NOT_FOUND",
"message": "Resource not found",
"statusCode": 404,
"meta": { ... } // Optional — present on ValidationError, RateLimitError, etc.
}This shape is produced by AppError.toJSON() in apps/api/src/errors/app-error.ts and matches the ApiErrorResponse interface in @remote-eaze/shared.
Error Codes
Error codes are defined as a single source of truth in packages/shared/src/api/error-codes.ts and used by both the API (throwing) and the web app (handling).
| Status | Code | Error Class | Notes |
|---|---|---|---|
| 400 | BAD_REQUEST | BadRequestError | Generic client error. |
| 400 | VALIDATION_ERROR | ValidationError | Zod/schema validation failed. meta.errors contains [{ field, message }]. |
| 401 | UNAUTHORIZED | UnauthorizedError | Missing or invalid session. |
| 403 | FORBIDDEN | ForbiddenError | Authenticated but lacks permission. |
| 404 | NOT_FOUND | NotFoundError | Resource does not exist. |
| 409 | CONFLICT | ConflictError | Duplicate resource (unique constraint). |
| 429 | RATE_LIMIT_EXCEEDED | RateLimitError | Too many requests. meta.retryAfter may be present. |
| 500 | INTERNAL_SERVER_ERROR | AppError (base) | Unexpected crash. Message hidden in production. |
| 503 | SERVICE_UNAVAILABLE | ServiceUnavailableError | Dependency down (Redis/DB). meta.service may be present. |
How to Throw Errors
Do not throw generic Error objects. Use the typed classes from apps/api/src/errors/app-error.ts.
Wrong
if (!user) throw new Error("User not found");
// Result: 500 Internal Server Error (confusing, leaks message in dev)Right
import { NotFoundError } from "../../errors/app-error";
if (!user) throw new NotFoundError("User not found");
// Result: 404 { code: "NOT_FOUND", message: "User not found", statusCode: 404 }ValidationError (With Structured Errors)
import { ValidationError } from "../../errors/app-error";
throw new ValidationError("Invalid request data", [
{ field: "email", message: "Required" },
{ field: "name", message: "Must be at least 2 characters" },
]);
// Result: 400 { code: "VALIDATION_ERROR", ..., meta: { errors: [...] } }RateLimitError (With Retry Header)
import { RateLimitError } from "../../errors/app-error";
throw new RateLimitError("Too many requests", 60);
// Result: 429 { ..., meta: { retryAfter: 60 } }Global Error Handler
The Global Error Handler (plugins/global-error.ts) is a Fastify plugin that intercepts all thrown errors and normalizes them into the standard response format. It processes errors in this priority order:
1. Custom AppError Instances
Any error extending AppError is serialized via .toJSON(). Errors with statusCode >= 500 are logged as error; below 500 logged as info.
2. Zod Validation Errors
Zod errors (using .issues for Zod 4 compatibility) are converted into ValidationError with structured meta.errors:
{
"code": "VALIDATION_ERROR",
"message": "Invalid request data",
"statusCode": 400,
"meta": {
"errors": [
{ "field": "amount", "message": "Expected number, received string" }
]
}
}3. Fastify Native Validation Errors
Schema validation from Fastify's built-in validator (e.g., JSON Schema) is normalized into the same ValidationError shape.
4. Fastify 404s
Route-not-found errors are wrapped in NotFoundError.
5. Prisma Database Errors
Known Prisma errors are mapped to appropriate HTTP responses:
| Prisma Code | Description | HTTP Status | Error Class |
|---|---|---|---|
P2002 | Unique constraint violation | 409 | ConflictError — includes the target field name |
P2025 | Record not found | 404 | NotFoundError |
P2003 | Foreign key constraint violation | 400 | BadRequestError — "Invalid reference to related record" |
P2014 | Required relation missing | 400 | BadRequestError — "Required relation is missing" |
6. Database Connection Failures
PrismaClientInitializationError is caught and returned as 503 Service Unavailable. Logged at fatal level.
7. Unknown/Crash Errors
Anything not matching the above is treated as an unhandled crash:
- Logged as
errorwith full context (requestId,method,url,stack). - Returns 500 with a generic message in production (no stack trace leak). In development, the original
error.messageis returned.
Rule
Do not wrap your route handlers in try/catch unless you are performing a specific rollback, audit logging a failure, or custom recovery logic. Let errors bubble up to the Global Handler.
Logging Behavior
| Error Type | Log Level | Context |
|---|---|---|
| AppError (4xx) | info | Error object |
| AppError (5xx) | error | Error object |
| Prisma known errors | error | Full Prisma error |
| DB connection failure | fatal | Full error |
| Unknown crashes | error | requestId, method, url, stack |