alepha@docs:~/docs/framework/guides/server$
cat 5-error-handling.md | pretty
3 min read
Last commit:

#Error Handling

Alepha maps errors to HTTP responses through the HttpError class. Unhandled errors become 500 Internal Server Error.

#HttpError

Throw an HttpError to return a specific HTTP status code and message:

typescript
1import { HttpError } from "alepha/server";2 3throw new HttpError({ status: 404, message: "Product not found" });

#Named Error Classes

Alepha provides error subclasses for common HTTP statuses:

typescript
 1import { 2  BadRequestError, 3  UnauthorizedError, 4  ForbiddenError, 5  NotFoundError, 6  ConflictError, 7  ValidationError, 8} from "alepha/server"; 9 10throw new NotFoundError("User not found");11throw new BadRequestError("Invalid input");12throw new UnauthorizedError("Not authenticated");13throw new ForbiddenError("Access denied");14throw new ConflictError("Entity already exists");15throw new ValidationError("Validation has failed");

Each class has a sensible default message:

Class Status Default Message
BadRequestError 400 "Invalid request body"
ValidationError 400 "Validation has failed"
UnauthorizedError 401 "Not allowed to access this resource"
ForbiddenError 403 "No permission to access this resource"
NotFoundError 404 "Resource not found"
ConflictError 409 "Entity already exists"

#Error Identification

Check whether an error is an HTTP error, optionally filtering by status:

typescript
1import { HttpError } from "alepha/server";2 3if (HttpError.is(error)) {4  // any HTTP error5}6 7if (HttpError.is(error, 404)) {8  // specifically a 4049}

#Error Chaining

Pass a cause to preserve the original error context:

typescript
1try {2  await externalService.call();3} catch (error) {4  throw new HttpError(5    { status: 502, message: "Upstream service failed" },6    error,7  );8}

The cause is included in the error JSON response under the cause field:

json
1{2  "error": "BadGatewayError",3  "status": 502,4  "message": "Upstream service failed",5  "cause": {6    "name": "FetchError",7    "message": "Connection refused"8  }9}

#Error Response Format

All HTTP errors are serialized as JSON with a consistent structure:

json
1{2  "error": "NotFoundError",3  "status": 404,4  "message": "User not found",5  "requestId": "abc-123"6}

The error field is the class name (e.g. NotFoundError, ConflictError). For plain HttpError instances, it is derived from the status code.

#Production Sanitization

In production (NODE_ENV=production), 5xx responses are stripped before they reach the client: message becomes "Internal Server Error", and cause and details are dropped. A 5xx message is internal - it routinely carries DB connection strings, upstream hostnames, and credentials - so it belongs in your logs, not in the response. The requestId is always preserved, which is how you correlate the sanitized response with the full error in your logs.

4xx responses are never sanitized. A BadRequestError("age must be a positive integer") is deliberate, client-facing context and is passed through verbatim, cause included.

Outside production nothing is stripped, so local debugging shows the real error.

This rule applies to every path an error can take to a client, including batched actions (POST /api/_batch) - a sub-action that fails with a 5xx reports "Internal Server Error" in its error field, while a 4xx sub-action keeps its message:

json
1[2  { "action": "getUser", "status": 200, "data": { "id": "1" } },3  { "action": "chargeCard", "status": 500, "error": "Internal Server Error" },4  {5    "action": "updateAge",6    "status": 400,7    "error": "age must be a positive integer"8  }9]

Use the server:onError hook below to ship the unsanitized error to your logger or Sentry.

#Status Code Mapping

HttpError maps status codes to error names automatically:

Status Error Name
400 BadRequestError
401 UnauthorizedError
403 ForbiddenError
404 NotFoundError
409 ConflictError
413 PayloadTooLargeError
429 TooManyRequestsError
500 InternalServerError
502 BadGatewayError
503 ServiceUnavailableError

Less common codes map too: 405 MethodNotAllowedError, 410 GoneError, 415 UnsupportedMediaTypeError, 501 NotImplementedError, 504 GatewayTimeoutError.

Outside production, a handler that throws a bare Error triggers an explicit dev warning telling you it will surface as "Internal Server Error" once deployed - the sanitization above is invisible locally, so the warning is the only signal you get before production.

#Global Error Handling

Use the server:onError hook to intercept errors across all routes:

typescript
 1import { $hook } from "alepha"; 2import { $logger } from "alepha/logger"; 3  4class ErrorHandler { 5  log = $logger(); 6  7  onError = $hook({ 8    on: "server:onError", 9    handler: async ({ error, request }) => {10      this.log.error("Request failed", {11        error,12        path: request.url.pathname,13        method: request.method,14      });15    },16  });17}

The server:onError hook fires after the error is caught but before the response is sent. The response body is reset at this point and can be modified via request.reply.

#Schema Validation Errors

When a request fails schema validation (invalid body, params, or query), Alepha throws a ValidationError (status 400) automatically. You do not need to validate request data manually.