DXAPI designStandards

Problem Details Done Right: RFC 9457 Errors in DaloyJS

Why every framework needs a predictable error contract — and how DaloyJS uses RFC 9457 application/problem+json for HttpError, ValidationError, UnauthorizedError, TooManyRequestsError, and the rest, with automatic 5xx redaction in production and a Retry-After story that just works.

Devlin DuldulaoFullstack cloud engineer12 min read

Hi, Devlin. Ten years of fullstack, currently in Norway, currently staring at a Slack message that says "the API is throwing a weird error" with a screenshot of a JSON blob whose shape I do not recognize. You've had this conversation. Everyone has had this conversation. The frontend has to special-case three error formats from three teams, the mobile app gave up and shows Something went wrong for every non-2xx, and the on-call engineer is in there asking which endpoint, can you re-send the request, what time exactly. Nobody is happy.

That conversation has a fix, and it's twenty years old at this point: RFC 9457 Problem Details for HTTP APIs (the freshly-renamed successor to RFC 7807). One Content-Type, one document shape, one set of optional fields, and you can build one client-side error helper that works for every endpoint. DaloyJS uses it for every error response — not sometimes, every time — and this post is the tour.

Why the "everyone invents their own" pattern hurts

the-status-quo.json
ts
// What most JSON APIs ship today. Sound familiar?
//
// Endpoint A:
// HTTP/1.1 400 Bad Request
// Content-Type: application/json
// { "error": "invalid email" }
//
// Endpoint B (same app, different team):
// HTTP/1.1 400 Bad Request
// Content-Type: application/json
// { "code": "VALIDATION", "message": "Email is invalid", "fields": [...] }
//
// Endpoint C (third-party we forward to):
// HTTP/1.1 400 Bad Request
// Content-Type: application/json
// { "errors": [{ "detail": "email: invalid" }] }
//
// The frontend ends up with a switch statement keyed on the endpoint URL
// just to know where to grab the message from. The mobile app skips that
// and renders "Something went wrong" for every 4xx. Everyone is sad.
three teams · three shapes · zero shared client code

That bottom panel is the actual cost. You can't write one fetchwrapper. Your TypeScript types for "the error case" are unknown. Your telemetry pipeline can't group errors by type because there is no canonical type field. Every "let's improve error handling" refactor I've been on in the last decade started here.

The contract: one document shape, forever

@daloyjs/core · errors.ts
ts
// RFC 9457 — Problem Details for HTTP APIs.
// (The successor to RFC 7807, same shape, clearer language.)
//
// One Content-Type: application/problem+json
// One required core:

interface ProblemDetails {
  type:     string;   // URI identifying the problem class (e.g. .../errors/validation)
  title:    string;   // Short human-readable summary, stable across occurrences
  status:   number;   // HTTP status code, mirrored from the response line
  detail?:  string;   // Optional human-readable explanation for THIS occurrence
  instance?: string;  // Optional URI identifying THIS specific occurrence
  // Plus any number of extension members — like `errors` for field-level issues.
  [extension: string]: unknown;
}
ProblemDetails — required core + open-ended extensions

That's it. Three required fields. Two optional. Anything else is an extension member you define yourself — and the framework uses errors as the conventional spot for field-level validation issues. The Content-Type application/problem+json tells the client (and any proxies in the middle) that this is a problem document, not a domain success response that happens to be JSON-shaped.

The handler experience: throw, don't return

The single biggest ergonomic win is that you write your handlers in terms of successful responses only. Anything that goes wrong, you throw. The framework does the rest.

src/routes/books.ts
ts
// src/routes/books.ts — throw, don't return.
import {
  NotFoundError,
  BadRequestError,
  UnauthorizedError,
  ValidationError,
  TooManyRequestsError,
} from "@daloyjs/core";

app.route({
  method: "GET",
  path: "/books/:id",
  operationId: "getBook",
  params: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
  responses: { 200: { description: "ok" }, 404: { description: "not found" } },
  handler: async ({ params }) => {
    const book = await db.books.findUnique({ where: { id: params.id } });
    if (!book) throw new NotFoundError(`No book with id ${params.id}`);
    return { status: 200, body: book };
  },
});

// You never write `return { status: 404, body: { error: "..." } }`.
// You throw. The framework serializes it as RFC 9457 problem+json with
// the right status, the right Content-Type, and the right shape.
throw NotFoundError(...) — never return a 404 by hand
GET /books/0192a8b3- · raw response
http
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "https://daloyjs.dev/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "No book with id 0192a8b3-9c5f-71d7-9a07-e0c0baa3f97e"
}
application/problem+json · the type URI is a stable identifier

The built-in error catalogue

The framework ships an HttpError subclass for every status code you actually use. You import the one you want and throw it. Status, title, type URI, and headers — handled.

0HttpError

Base class — instantiate directly only for unusual status codes or fully-custom problem documents.

400BadRequestError

The request is syntactically invalid in a way the client can fix without retrying.

401UnauthorizedError

Authentication is required and missing or invalid. Pair with WWW-Authenticate on the response.

403ForbiddenError

Authenticated but not permitted. Used by built-in CSRF and bearer-token middleware.

404NotFoundError

Resource doesn't exist. Also thrown internally for unmatched routes.

405MethodNotAllowedError

Path matched but method didn't. Includes Allow header automatically.

408RequestTimeoutError

Handler exceeded App.requestTimeoutMs. Framework aborts the in-flight handler.

413PayloadTooLargeError

Body exceeded the configured size cap. Stops parsing early.

415UnsupportedMediaTypeError

Content-Type doesn't match what the route declared in its schema.

422ValidationError

Schema validation failed. Auto-thrown by the request validator. Carries an errors array of {path, message}.

429TooManyRequestsError

Rate-limited. Optional retryAfterSeconds becomes a Retry-After header.

500InternalError

Last-resort wrap for unhandled exceptions. Detail is redacted in production.

ValidationError: the one you'll see most

You don't throw ValidationError by hand most of the time — the framework throws it for you the moment a request body, params, query, or headers fail their declared schema. It carries an errors array of { path, message } records, which is the shape every form library on the planet expects.

POST /books · invalid body
http
// Schema validation failure — automatic, no handler involvement.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://daloyjs.dev/errors/validation",
  "title": "Request validation failed",
  "status": 422,
  "detail": "Invalid body",
  "errors": [
    { "path": "email",       "message": "must match format \"email\"" },
    { "path": "tags/0",      "message": "must be string" },
    { "path": "age",         "message": "must be >= 0" }
  ]
}
422 · errors[] with JSON-pointer-ish paths

On the frontend, the entire universe of error handling reduces to one helper:

apps/web/lib/api.ts
ts
// apps/web/lib/api.ts — one helper, all errors.
import type { ProblemDetails } from "@daloyjs/core";

export async function api<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
  const res = await fetch(input, init);

  if (res.ok) return res.json() as Promise<T>;

  // application/problem+json is the contract. Trust it.
  const problem = (await res.json()) as ProblemDetails & {
    errors?: Array<{ path: string; message: string }>;
  };

  throw new ApiError(problem);
}

export class ApiError extends Error {
  readonly status: number;
  readonly type: string;
  readonly fieldIssues: ReadonlyArray<{ path: string; message: string }>;

  constructor(public readonly problem: ProblemDetails & {
    errors?: Array<{ path: string; message: string }>;
  }) {
    super(problem.title);
    this.status = problem.status;
    this.type = problem.type;
    this.fieldIssues = problem.errors ?? [];
  }

  isValidation(): boolean {
    return this.type === "https://daloyjs.dev/errors/validation";
  }
  isRateLimited(): boolean {
    return this.type === "https://daloyjs.dev/errors/too-many-requests";
  }
}
one helper · every endpoint · ProblemDetails-typed
apps/web/components/book-form.tsx
ts
// apps/web/components/book-form.tsx — react-hook-form, one render path.
async function onSubmit(values: BookFormValues) {
  try {
    await api("/books", { method: "POST", body: JSON.stringify(values) });
  } catch (err) {
    if (err instanceof ApiError && err.isValidation()) {
      // Map the framework's path strings into RHF errors. Generic.
      for (const issue of err.fieldIssues) {
        form.setError(issue.path as Path<BookFormValues>, { message: issue.message });
      }
      return;
    }
    if (err instanceof ApiError && err.isRateLimited()) {
      toast.warn("Slow down a little — try again in a moment.");
      return;
    }
    toast.error(err instanceof Error ? err.message : "Unknown error");
  }
}
react-hook-form · setError(path, message) · done

The rate-limit story (Retry-After done correctly)

TooManyRequestsError takes an optional retryAfterSeconds argument. The framework turns it into a real Retry-Afterheader on the response — so your retry-after-aware fetch helper doesn't need to re-parse the body to figure out how long to back off:

HTTP/1.1 429 · response
http
// Rate-limit example — note the response header, not just the body.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

{
  "type": "https://daloyjs.dev/errors/too-many-requests",
  "title": "Too Many Requests",
  "status": 429
}

// The TooManyRequestsError constructor takes a retryAfterSeconds and the
// middleware attaches a real Retry-After header. Your retry-after-aware
// fetch helper (every team eventually writes one) just works:
//
//   throw new TooManyRequestsError(30);
Retry-After header + problem+json body — both correct

Unauthorized vs Forbidden, sorted

Whoever named these two status codes did the field a disservice. Unauthorizedmeans "we don't know who you are" (a.k.a. unauthenticated). Forbiddenmeans "we know who you are; you can't do this". The framework picks the right one based on which middleware triggered it, and your bearerAuth automatically attaches the WWW-Authenticate challenge:

HTTP/1.1 401 · then HTTP/1.1 403
http
// Unauthorized vs Forbidden — the distinction nobody bothers with.
// The framework picks the right one; you just throw it.

// Endpoint behind bearerAuth, no token attached:
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="api"

{
  "type":  "https://daloyjs.dev/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Missing or invalid Bearer token"
}

// Endpoint behind CSRF with a missing token (you ARE logged in but the
// request can't be verified):
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type":  "https://daloyjs.dev/errors/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "CSRF token missing or invalid"
}
bearerAuth → 401 + WWW-Authenticate · csrf() → 403

Production redaction: leak nothing, log everything

One of my favorite quiet features of the error layer is the 5xx redaction rule. When NODE_ENV=production, any error with status ≥ 500 has its detail field stripped from the response before it leaves the server. The user gets the type, the title, the status, and a request-id-shaped instance — enough to file a support ticket. The server logs keep the full detail.

NOTES.md
bash
# Production redaction in one line of framework code:
#
#   if (isProd && this.status >= 500) {
#     delete out.detail;
#   }
#
# What it looks like in practice:

# NODE_ENV=development
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
  "type":   "https://daloyjs.dev/errors/internal",
  "title":  "Internal Server Error",
  "status": 500,
  "detail": "ENOENT: no such file or directory, open '/etc/secret-cache/abc'",
  "instance": "urn:request:01J2X3K6V0H8YEHMV7M2WD5XYJ"
}

# NODE_ENV=production (same error, same request id)
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
  "type":   "https://daloyjs.dev/errors/internal",
  "title":  "Internal Server Error",
  "status": 500,
  "instance": "urn:request:01J2X3K6V0H8YEHMV7M2WD5XYJ"
}
# The detail is gone from the response. It's NOT gone from your logs —
# it's still there, correlated to the same urn:request: instance.
dev vs prod · same code path · different exposure
src/app.ts
ts
// src/app.ts — the logger redacts NOTHING.
app.useOnError(async (err, ctx) => {
  if (err instanceof HttpError) {
    ctx.log.warn(
      {
        kind: "http-error",
        status: err.status,
        type: err.problem.type,
        detail: err.problem.detail,       // present even in prod
        requestId: ctx.requestId,         // matches problem.instance
        route: ctx.route?.operationId,
        userId: ctx.state.session?.id,
      },
      err.problem.title,
    );
  } else {
    ctx.log.error(
      { err, requestId: ctx.requestId, route: ctx.route?.operationId },
      "Unhandled error",
    );
  }
});

// In Datadog / Loki / Honeycomb you filter on requestId. The user gave you
// a screenshot of "Internal Server Error · urn:request:01J2X3..." — you
// paste that ULID into the log query and the full `detail` is right there.
// No more "can you reproduce it?".
logger sees everything · response sees nothing extra

The urn:request:<ULID> instance is the single most useful thing on a production error page. The user gives you that ULID; you paste it into Datadog or Loki; the full stack and detail come back. The customer-facing message stays useless to attackers and helpful to humans. Both win.

Your own domain errors are five lines

Anything more specific than the built-in catalogue is a five-line subclass. The framework cares about the status code and the document shape; everything else is yours. Use a URI you own for the type so the frontend can branch on it without parsing strings:

src/errors/seat-unavailable.ts
ts
// src/errors/seat-unavailable.ts — your own domain error.
import { HttpError } from "@daloyjs/core";

export class SeatUnavailableError extends HttpError {
  constructor(detail: string) {
    super(409, {
      type: "https://booking.example.com/errors/seat-unavailable",
      title: "Seat unavailable",
      detail,
    });
    this.name = "SeatUnavailableError";
  }
}

// In your handler:
//   if (await isSeatTaken(seat)) {
//     throw new SeatUnavailableError(`Seat ${seat} just got booked.`);
//   }
//
// What the client sees:
// HTTP/1.1 409 Conflict
// Content-Type: application/problem+json
// {
//   "type":   "https://booking.example.com/errors/seat-unavailable",
//   "title":  "Seat unavailable",
//   "status": 409,
//   "detail": "Seat 17B just got booked."
// }
//
// The frontend can branch on type === "https://booking.example.com/errors/seat-unavailable"
// and trigger the "pick a different seat" flow. No new code in the framework.
domain error · stable type URI · throws like a built-in

Contract tests: cheap because the shape is fixed

The single most boring sentence in this post: when every error response is the same shape, asserting against errors is trivial. There's nothing to special-case. A contract test for "this endpoint returns a 404 for a missing id" is six lines:

tests/books.contract.test.ts
ts
// tests/books.contract.test.ts — RFC 9457 makes contract tests trivial.
import { describe, it, expect } from "vitest";
import { runContractTests } from "@daloyjs/core/testing";
import { app } from "../src/app";

it("getBook returns RFC 9457 on missing id", async () => {
  const res = await app.request("/books/does-not-exist");
  expect(res.status).toBe(404);
  expect(res.headers.get("content-type")).toBe("application/problem+json");

  const problem = await res.json();
  expect(problem).toMatchObject({
    type:   "https://daloyjs.dev/errors/not-found",
    title:  "Not Found",
    status: 404,
  });
});

// runContractTests(app) also asserts the shape automatically against the
// generated OpenAPI document, so a new error response in your handler
// can't escape into prod without showing up in the contract first.
content-type assert · type URI assert · status assert

And because generateOpenAPI(app) emits a single ProblemDetails schema that every error response references, the typed-client codegen produces one matching TypeScript type. The frontend autocompletes problem.type, problem.detail, problem.errors. No drift between the docs, the wire format, and the types — they are literally the same source.

generated/openapi.json
json
// generated/openapi.json — every response slot inherits the same schema.
{
  "components": {
    "responses": {
      "ProblemDetails": {
        "description": "Problem details (RFC 9457).",
        "content": {
          "application/problem+json": {
            "schema": { "$ref": "#/components/schemas/ProblemDetails" }
          }
        }
      }
    },
    "schemas": {
      "ProblemDetails": {
        "type": "object",
        "required": ["type", "title", "status"],
        "properties": {
          "type":     { "type": "string", "format": "uri" },
          "title":    { "type": "string" },
          "status":   { "type": "integer" },
          "detail":   { "type": "string" },
          "instance": { "type": "string", "format": "uri" }
        },
        "additionalProperties": true
      }
    }
  }
}
// Hey API's pnpm gen picks this up and your typed client gets a
// ProblemDetails type for every error response. Frontend autocompletes
// problem.type, problem.status, problem.detail. No more guessing.
one ProblemDetails schema · every error response refs it

One paragraph of honest caveats

Problem Details isn't a magic protocol — it's a promise about response shape. It does nothing if you bypass it and return your own ad-hoc JSON from a handler (please don't). It does nothing for non-JSON error pages from upstream proxies (your CDN's 502 HTML is still HTML; deal with it in the client). And the type URI is a stable identifier, not a link the client necessarily dereferences — treat it like an enum value. Beyond those: it's the closest thing to a free lunch the HTTP standards world has given us in years.

Where to go next

The full reference for every built-in error class is in the errors docs. If you're still wiring up the surrounding pieces — typed client, sessions, rate limiting — the contract-first and sessions posts are the closest neighbors in spirit.

Thanks for reading. Now go open the fetch wrapper in your frontend and count how many error shapes it knows about. Whatever the number is, the target is one.

— Devlin

Devlin Duldulao

Ten years of fullstack, currently writing TypeScript from a desk in Norway. Has spent more of his career parsing other people's error responses than writing his own — would prefer to fix that, for everyone.