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

#Error Handling

Every error in Alepha is declared with $error: a name, and a schema for the data it carries. HTTP errors are one family of them, rooted at HttpError, whose data carries the HTTP status. The router answers any HttpError with that status; anything else becomes 500 Internal Server Error.

To make the errors an action may answer with part of its contract (typed data, OpenAPI, instanceof in the browser, and a 500 for anything else), declare them on the action: see Typed errors.

#HttpError

Throw an HttpError for a one-off status. The data comes first, the message (and a cause) last, as for Error:

typescript
1import { HttpError } from "alepha/server";2 3throw new HttpError({ status: 502 }, { message: "Upstream failed" });

error.status reads error.data.status.

#Built-in Errors

Alepha declares the common statuses, each an HttpError.extend(...):

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 has a name, a status and a default message:

Constant Name Status Default message
BadRequestError BAD_REQUEST 400 "Invalid request body"
ValidationError VALIDATION_FAILED 400 "Validation has failed"
UnauthorizedError UNAUTHORIZED 401 "Not allowed to access this resource"
ForbiddenError FORBIDDEN 403 "No permission to access this resource"
NotFoundError NOT_FOUND 404 "Resource not found"
ConflictError CONFLICT 409 "Entity already exists"
TooManyRequestsError TOO_MANY_REQUESTS 429 "Too Many Requests"
InternalServerError INTERNAL_SERVER_ERROR 500 "Internal Server Error"
ServiceUnavailableError SERVICE_UNAVAILABLE 503 "Service Unavailable"

#Your Own Errors

Derive an error from any of them with .extend, adding a schema for the data the client needs:

typescript
 1import { z } from "alepha"; 2import { HttpError, NotFoundError } from "alepha/server"; 3  4export const SlugTaken = HttpError.extend({ 5  name: "SLUG_TAKEN", 6  default: { status: 409 }, 7  schema: z.object({ slug: z.text() }), 8  message: (data) => `Slug '${data.slug}' is already taken`, 9});10 11export const ProjectNotFound = NotFoundError.extend({12  name: "PROJECT_NOT_FOUND",13});14 15throw new SlugTaken({ slug: "home" });

There are no error classes to write: check:conventions refuses one. See Typed errors for the full $error API.

#Error Identification

instanceof works on every declared error, its parents included (ProjectNotFound is a NotFoundError and an HttpError). For an error that crossed some boundary without being rebuilt, isHttpError checks the shape:

typescript
1import { isHttpError } from "alepha/server";2 3if (isHttpError(error)) {4  // anything with a numeric status5}6 7if (isHttpError(error, 404)) {8  // specifically a 4049}

#Error Chaining

Pass a cause to keep the original error:

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

The cause's name and message go to the response under cause:

json
1{2  "name": "BAD_GATEWAY",3  "message": "Upstream service failed",4  "data": { "status": 502, "requestId": "abc-123" },5  "cause": { "name": "FetchError", "message": "Connection refused" }6}

#Error Response Format

Every error is serialized the same way, { name, message, data, cause? }:

json
1{2  "name": "NOT_FOUND",3  "message": "User not found",4  "data": { "status": 404, "requestId": "abc-123" }5}
  • name is the error's name. A one-off new HttpError({ status }) is named after its status (PAYLOAD_TOO_LARGE).
  • data is the error's data: status, the requestId, an optional details (longer prose), plus whatever its own schema declares.

HttpClient rebuilds the declared class from that body, so instanceof SlugTaken holds in the browser.

#Production Sanitization

In production (NODE_ENV=production), 5xx responses are stripped before they reach the client: message becomes "Internal Server Error", and cause and data.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:request:error hook below to ship the unsanitized error to your logger or Sentry.

#Errors from Elsewhere

An error that is not an HttpError but carries a numeric status (common in upstream libraries) is answered with that status, named after it (502 is BAD_GATEWAY), under the same 5xx sanitization. Anything else becomes a 500 INTERNAL_SERVER_ERROR.

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:request:error 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:request:error", 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:request:error hook receives { error, request, route } and fires after the error is caught but before the response is sent. The reply's body and status are reset at this point. A hook that sets request.reply.status (a custom error page, for instance) owns the response, and the default JSON error is not written.

#Schema Validation Errors

When a request fails schema validation (invalid body, params, or query), Alepha throws a ValidationError (VALIDATION_FAILED, status 400) automatically, its data.details naming the path that failed. You do not need to validate request data manually.