Skip to content

[Bug?]: Errors thrown by API routes and middleware never reach Nitro's error hook #2378

Open

Description

@dennev

Duplicates

  • I have searched the existing issues

Latest version

  • I have tested the latest version

Current behavior 😯

With Nitro, an error thrown by an API route or a middleware never reaches Nitro's error hook or its errorHandler. Error reporters that listen on that hook do not see these errors, and a custom errorHandler does not apply to them.

createBaseHandler (packages/start/src/server/handler.ts) serves the app through an h3 app of its own (new H3()). h3 turns whatever a handler throws into a response, so the handler's fetch never rejects. Nitro's SSR renderer gets h3's {"status":500,"unhandled":true,"message":"HTTPError"} back as an ordinary response, and Nitro's onError, which calls the error hook and then the errorHandler, does not run.

An error thrown by a Nitro route reaches the hook. In SolidStart 1.5.1 (vinxi 0.5.11 with nitropack 2.13.6), the same API route's error reached Nitro's error hook too.

Expected behavior 🤔

An unhandled error thrown by an API route or a middleware reaches Nitro's error hook and errorHandler, as an unhandled error of a Nitro route does.

Steps to reproduce 🕹

Failing test and proposed fix: #2379 (the first commit is the failing e2e test, the second the fix). The steps below reproduce the bug in a new app.

Steps:

  1. Run pnpm create solid@latest -p repro -s --v2 -t basic --ts, then cd repro and pnpm add @solidjs/start@2.2.2 nitro@3.0.261103-rc.
  2. Add src/routes/api/throws.ts:
    export function GET() {
      throw new Error("thrown by an API route");
    }
  3. Add server-plugins/error-hook.ts:
    import { definePlugin } from "nitro";
    
    export default definePlugin(nitroApp => {
      nitroApp.hooks.hook("error", error => {
        console.log(`[error hook] ${error.message}`);
      });
    });
    and pass it to Nitro in vite.config.ts: nitro({ plugins: ["./server-plugins/error-hook.ts"] }).
  4. Run pnpm build and node .output/server/index.mjs, then curl -i http://localhost:3000/api/throws.
  5. The response is h3's JSON 500 and the server prints the error once (from h3), but [error hook] is never printed. A Nitro route that throws (for example export default () => { throw new Error("thrown by a Nitro route") }, added with handlers: [{ route: "/nitro-throws", handler: "./server-routes/nitro-throws.ts" }]) prints [error hook] thrown by a Nitro route.

Context 🔦

We report server errors from Nitro's error hook, so errors from our API routes and middleware go unreported.

#1660 shows how an app can catch these errors itself, with an h3 middleware that wraps await next() in try/catch. That works when each app knows it needs that middleware and reports from it, or forwards the error with Nitro's captureError. Otherwise, integrations that capture server errors from Nitro's error hook do not see the error. Either way, Nitro's errorHandler never answers it.

Proposed fix. The fix, with unit tests, is in #2379. When a route, a middleware or the handler itself throws an error that h3 would answer as unhandled, the handler's fetch rejects with it, so the server hosting the handler answers and reports it. With Nitro, that means the error hook, then the errorHandler. These cases are answered as before:

  • an h3 HTTPError not marked unhandled, or a status thrown as a number, by SolidStart;
  • an error reached through request(), by SolidStart;
  • an error reached through an h3 app that the handler is mounted in, by that app.

SolidStart's own dev server (without Nitro) then shows its existing error overlay for such errors, and its preview server answers them with a 500.

Trade-offs.

  • The handler's fetch can now reject, unlike h3's H3.fetch. A server or adapter that calls it directly answers such an error itself.
  • The host builds the error response. With Nitro, the body is Nitro's ({"error":true,"status":500,"unhandled":true} in production) and a custom errorHandler applies. An error that is not an h3 HTTPError but carries a status, which h3 used for its answer, now gets the host's 500, as it would in a Nitro route.
  • Headers set for the error response with event.res.errHeaders on SolidStart's event are not in the host's answer, because the host answers on its own event. Examples are headers from h3's CORS helpers and the Cache-Control: no-store that handleServerFunction sets before it rethrows. So when SolidStart's middleware sets CORS headers with h3's helpers, a cross-origin client can no longer read such a 500.
  • An h3 HTTPError not marked unhandled (a 5xx too), or a status thrown as a number, is still answered by SolidStart, so it does not reach the hook, although in a Nitro route it would.
  • Server function requests that fail before the function runs, such as an unknown function id, are now reported as unhandled errors. They were already answered as unhandled 500s.

Alternatives. Two alternatives I considered keep SolidStart's answers as they are: calling Nitro's captureError when Nitro hosts the app (the hook runs, the errorHandler does not), or an error option on the handler, like serverFunctions.onError (#1660 declined one for createMiddleware).

Why the proposed fix. Nitro runs SolidStart as a service and handles a service's errors itself: its dev worker deliberately does not catch them where it calls a service ("like production services, they propagate to the caller", dev-worker.mjs). The proposed fix lets an unhandled error take that path, as 1.x did. Of the three approaches, it is the only one where both the hook and the errorHandler run, and it does not depend on Nitro: any server that calls the handler's fetch gets the error, SolidStart's own dev server included. The two alternatives keep SolidStart's 500 and its headers, but the first needs Nitro-specific code in SolidStart and leaves out the errorHandler, and the second changes nothing until each app or integration wires it up. The fix is in #2379, so that the approach can be judged with the code; if you prefer one of the alternatives or another approach, I will change the pull request.

Not covered. Errors thrown in a server function's body: SolidStart answers them by design and passes them to serverFunctions.onError. Errors thrown while rendering a page: SolidStart answers them with its own 500 page.

Your environment 🌎

System:
  OS: Linux 6.18 Debian GNU/Linux 12 (bookworm) (Docker image node:24)
Binaries:
  Node: 24.21.0
  pnpm: 11.17.0
npmPackages:
  @solidjs/start: 2.2.2
  @solidjs/router: 1.0.0
  nitro: 3.0.261103-rc
  vite: 8.1.4
  solid-js: 1.9.14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions