#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:
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(...):
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:
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:
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:
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:
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? }:
1{2 "name": "NOT_FOUND",3 "message": "User not found",4 "data": { "status": 404, "requestId": "abc-123" }5}
nameis the error's name. A one-offnew HttpError({ status })is named after its status (PAYLOAD_TOO_LARGE).datais the error's data:status, therequestId, an optionaldetails(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:
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
Errortriggers 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:
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.