Skip to content

Search docs

Jump between documentation pages.

Browse docs

Errors & problem+json

DaloyJS errors are first-class. Every thrown HttpError serializes to RFC 9457 problem+json with a stable type URI, a request-id, and the appropriate Content-Type.

From thrown error to problem+json
  1. 01handlerthrow new NotFoundError(...)any HttpError subclass
  2. 02frameworkMap to RFC 9457 fieldstype · title · status · detail
  3. 03productionRedact 5xx detailshown only when env is development or test
  4. 04wireSerialized responseapplication/problem+json + x-request-id
A thrown HttpError becomes a problem+json document automatically. Unless the environment is explicitly development or test, the detail field on 5xx responses is redacted so stack traces and SQL fragments never reach the client, while the full error still goes to your onError hook.

Built-in error classes

ts
import {
  HttpError,                         // base class: any status, custom type/title/headers
  BadRequestError,                   // 400
  UnauthorizedError,                 // 401 + cache-control: no-store
  ForbiddenError,                    // 403 + cache-control: no-store
  NotFoundError,                     // 404
  MethodNotAllowedError,             // 405 + Allow header
  RequestTimeoutError,               // 408 / 503 / 504 + cache-control: no-store
  ConflictError,                     // 409 + cache-control: no-store
  PayloadTooLargeError,              // 413
  UnsupportedMediaTypeError,         // 415
  ValidationError,                   // 422
  TooManyRequestsError,              // 429 + Retry-After + cache-control: no-store
  RequestHeaderFieldsTooLargeError,  // 431
  InternalError,                     // 500 (detail redacted outside dev/test)
  ServiceUnavailableError,           // 503 + Retry-After + cache-control: no-store
} from "@daloyjs/core";

TooManyRequestsError and ServiceUnavailableError validate retryAfterSeconds the same way: anything other than a non-negative safe integer throws a RangeError at construction, so a NaN from a missing env var never becomes an invalid Retry-After header. RequestTimeoutError (the requestTimeoutMs deadline, see requestTimeoutStatus) is no-store for every status variant since 1.16.0, so a shared cache never keeps serving a 503/504 after the backend recovers. A bare new HttpError(status, …) gets a https://httpstatuses.io/<status> problem type; the built-in classes carry stable https://daloyjs.dev/errors/<slug> URIs.

Throwing in a handler

ts
import { NotFoundError } from "@daloyjs/core";

app.get(
  "/users/:id",
  {
    operationId: "getUser",
    responses: { 200: { description: "ok" }, 404: { description: "missing" } },
  },
  async ({ params }) => {
    const user = await db.find(params.id);
    if (!user) throw new NotFoundError(`user ${params.id} not found`);
    return { status: 200, body: user };
  },
);

Wire format

The request id is returned to the client in two places: the x-request-id response header, and (per RFC 9457 §3.1) the problem document's instance field as a urn:request:<uuid> URN. There is no top-level requestId property, clients should read the header or parse the URN from instance.

json
HTTP/1.1 404 Not Found
content-type: application/problem+json
x-request-id: c9aa8e1c-7a6e-4f1e-9f44-c2e5d2c4a431

{
  "type": "https://daloyjs.dev/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "user 42 not found",
  "instance": "urn:request:c9aa8e1c-7a6e-4f1e-9f44-c2e5d2c4a431"
}

Production redaction

DaloyJS strips the detail field on any 5xx response unless the environment is positively development or test, so internal error messages and SQL fragments don't leak to clients. The full error is still emitted to your logger and to the onError hook.

Configuration5xx detail
env: "development" or env: "test"shown
no env, NODE_ENV=development or testshown
no env, NODE_ENV unset, empty, or anything elseredacted
env: "production", production: true, or NODE_ENV=productionredacted
production: false with NODE_ENV unsetredacted

An unset environment fails closed on purpose. Edge runtimes such as Cloudflare Workers and Deno Deploy have no NODE_ENV, and treating that as development would send exception messages to clients. daloy dev sets NODE_ENV=development for its child process when you haven't set it, so local development still shows the detail. Errors you serialize yourself (for example a response body built from err.message in a catch block) are not redacted. Throw them instead, or throw an HttpError with a safe detail.

Custom error classes

ts
import { HttpError } from "@daloyjs/core";

export class QuotaExceededError extends HttpError {
  constructor(resource: string) {
    super(429, {
      title: "Quota exceeded",
      type: "https://api.example.com/errors/quota-exceeded",
      detail: `Quota exceeded for ${resource}`,
    });
  }
}

The built-in classes above all set a https://daloyjs.dev/errors/<slug> type. A plain HttpError thrown without a type falls back to https://httpstatuses.io/<status>. First-party middleware that answers 503 (loadShedding(), concurrencyLimit(), requestTimeoutStatus: 503) sets its own daloyjs.dev type, so the fallback only applies to errors you construct yourself. Set type when clients key on it, or use a built-in class:

ts
throw new HttpError(503, { title: "Service Unavailable" });
// "type": "https://httpstatuses.io/503"

throw new HttpError(503, {
  title: "Service Unavailable",
  type: "https://api.example.com/errors/service-unavailable",
});

Service unavailable

For maintenance mode, a dependency outage, or a draining instance, throw ServiceUnavailableError. It answers 503 with a stable https://daloyjs.dev/errors/service-unavailable type, an optional Retry-After, and cache-control: no-store, so a CDN never keeps serving the outage after you recover.

ts
import { ServiceUnavailableError } from "@daloyjs/core";

if (await maintenance.isOn()) {
  // Retry-After: 120. The detail is redacted outside development/test,
  // so the header is what the client actually gets to act on.
  throw new ServiceUnavailableError(120, "scheduled database maintenance");
}

retryAfterSeconds must be a non-negative integer. Anything else (NaN from a missing environment variable, -1, 1.5) throws a RangeError instead of sending a malformed Retry-After header.

Custom onError

ts
app.use({
  onError: async (error, ctx) => {
    logger.error({ err: error, requestId: ctx?.state.requestId }, "request failed");
    // return a Response to override; otherwise DaloyJS serializes problem+json
  },
});
Edit this page on GitHub

Last updated