Error Handling

Hectoday HTTP has two error paths: thrown Error objects go to onError, and thrown Response objects bypass everything and go directly to the client.

onError

Catches all thrown Error objects from onRequest and route handlers.

setup({
  onError: ({ error, request, locals }) => {
    const url = new URL(request.url);
    console.error(`Error on ${request.method} ${url.pathname}:`, error.message);

    return Response.json(
      { error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } },
      { status: 500 },
    );
  },
  routes: [
    /* ... */
  ],
});

Parameters

ParameterTypeDescription
errorErrorThe thrown error
requestRequestThe original request
localsPartial<TLocals>Partial because onRequest may not have completed

Return value

Must return a Response. This response is passed through onResponse before being sent to the client.

Thrown Response objects

Throwing a Response object bypasses both onError and onResponse. The response goes directly to the client without modification.

onRequest: ({ request }) => {
  const key = request.headers.get("x-api-key");
  if (!key) {
    throw Response.json(
      { error: { code: "UNAUTHORIZED", message: "API key required" } },
      { status: 401 },
    );
  }
  return {};
},

Be aware that this skips onResponse entirely, so the response will not have any headers you normally add there (like X-Request-Id or X-Response-Time), and it will not be logged. Use this only when you need to bail out before onRequest finishes and you are fine with skipping the rest of the lifecycle.

Error Flow

Route handler or onRequest throws...

  Error object              Response object
       │                         │
       ▼                         ▼
  onError()              Sent to client directly
       │                 (bypasses onError AND onResponse)

  onResponse()


  Sent to client

Expected vs unexpected errors

onError is a safety net for unexpected errors: bugs, crashes, things you did not anticipate. It should not be used for control flow.

For expected outcomes like “not found,” “unauthorized,” or “forbidden,” return a Response directly from the handler instead of throwing:

resolve: (c) => {
  if (!c.input.ok) {
    return Response.json({ error: c.input.issues }, { status: 400 }); // expected: bad input
  }
  const { id } = c.input.params;
  const book = findBook(id);
  if (!book) {
    return Response.json(
      { error: { code: "NOT_FOUND", message: "Book not found" } },
      { status: 404 },
    ); // expected: resource does not exist
  }
  return Response.json(book); // expected: success
},

Every outcome is a returned Response. Nothing is thrown. onError only runs if something truly unexpected happens, like findBook crashing due to a bug.