From d4257121b6fedaddda052648d42b9ddfec671c89 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 5 Aug 2026 16:54:08 -0600 Subject: [PATCH 01/22] breaking: pass all errors through `handleError`, update the function contract --- .changeset/handle-error-all-errors.md | 5 + .../docs/20-core-concepts/20-load.md | 2 +- .../20-core-concepts/60-remote-functions.md | 4 +- .../docs/30-advanced/10-advanced-routing.md | 2 +- documentation/docs/30-advanced/20-hooks.md | 52 ++++++++-- documentation/docs/30-advanced/25-errors.md | 37 ++++--- .../src/core/sync/write_client_manifest.js | 2 +- packages/kit/src/exports/index.js | 8 +- packages/kit/src/exports/internal/shared.js | 9 ++ packages/kit/src/exports/public.d.ts | 89 ++++++++++++----- packages/kit/src/runtime/client/client.js | 40 +++++--- .../client/remote-functions/form.svelte.js | 15 ++- .../remote-functions/prerender.svelte.js | 4 +- .../remote-functions/query-batch.svelte.js | 4 +- .../query-live/instance.svelte.js | 10 +- .../remote-functions/query-live/iterator.js | 10 +- .../remote-functions/query/instance.svelte.js | 7 +- .../client/remote-functions/shared.svelte.js | 23 ++--- .../remote-functions/shared.transport.spec.js | 10 +- packages/kit/src/runtime/server/errors.js | 64 +++++++----- packages/kit/src/runtime/server/index.js | 5 +- packages/kit/src/types/ambient.d.ts | 2 +- packages/kit/src/utils/error.js | 19 +--- packages/kit/src/utils/error.spec.js | 26 ++--- .../kit/test/apps/async/src/hooks.client.js | 12 ++- .../kit/test/apps/async/src/hooks.server.js | 12 ++- packages/kit/test/apps/basics/.gitignore | 2 +- .../kit/test/apps/basics/src/hooks.client.js | 26 ++++- .../kit/test/apps/basics/src/hooks.server.js | 36 +++++-- .../basics/test/cross-platform/client.test.js | 20 ++++ .../apps/basics/test/cross-platform/test.js | 83 +++++++++++++--- .../kit/test/apps/basics/test/server.test.js | 24 +++-- packages/kit/test/apps/basics/test/setup.js | 4 +- packages/kit/test/apps/basics/test/test.js | 8 +- .../test/apps/dev-only/src/hooks.client.js | 5 +- .../test/apps/dev-only/src/hooks.server.js | 5 +- .../types/app-error-enhanced/error.test.ts | 34 +++++++ packages/kit/test/types/error.test.ts | 33 ++++++- packages/kit/test/utils.js | 17 +++- packages/kit/types/index.d.ts | 97 ++++++++++++++----- 40 files changed, 607 insertions(+), 260 deletions(-) create mode 100644 .changeset/handle-error-all-errors.md diff --git a/.changeset/handle-error-all-errors.md b/.changeset/handle-error-all-errors.md new file mode 100644 index 000000000000..f40f914478e3 --- /dev/null +++ b/.changeset/handle-error-all-errors.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': major +--- + +breaking: run all errors through the `handleError` hook diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 490a09446d23..b947615029ae 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -453,7 +453,7 @@ export function load({ locals }) { Calling `error(...)` will throw an exception, making it easy to stop execution from inside helper functions. -If an [_unexpected_](errors#Unexpected-errors) error is thrown, SvelteKit will invoke [`handleError`](hooks#handleError) and treat it as a 500 Internal Error. +Every error — expected or otherwise — is passed to the [`handleError`](hooks#handleError) hook. An [_unexpected_](errors#Unexpected-errors) error is treated as a 500 Internal Error unless the hook says otherwise. > [!NOTE] [In SvelteKit 1.x](migrating-to-sveltekit-2#redirect-and-error-are-no-longer-thrown-by-you) you had to `throw` the error yourself diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index fefdefa7e0fe..d8582cc3b755 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -1236,7 +1236,7 @@ As long as _you're_ not passing invalid data to your remote functions, there are - the function signature changed between deployments, and some users are currently on an older version of your app - someone is trying to attack your site by poking your exposed endpoints with bad data -In the second case, we don't want to give the attacker any help, so SvelteKit will generate a generic [400 Bad Request](https://http.dog/400) response. You can control the message by implementing the [`handleValidationError`](hooks#handleValidationError) server hook, which, like [`handleError`](hooks#handleError), must return an [`App.Error`](errors#Type-safety) (which defaults to `{ message: string }`): +In the second case, we don't want to give the attacker any help, so SvelteKit will generate a generic [400 Bad Request](https://http.dog/400) response. You can control the message by implementing the [`handleValidationError`](hooks#handleValidationError) server hook, which must return an object matching [`App.Error`](errors#Type-safety) (which defaults to `{ status: number, message: string }`, with `status` defaulting to `400` if you omit it): ```js /// file: src/hooks.server.js @@ -1248,6 +1248,8 @@ export function handleValidationError({ event, issues }) { } ``` +The object you return becomes the body of an [expected error](errors#Expected-errors), which then passes through [`handleError`](hooks#handleError) with a `kind` of `'expected'`. + If you know what you're doing and want to opt out of validation, you can pass the string `'unchecked'` in place of a schema: ```ts diff --git a/documentation/docs/30-advanced/10-advanced-routing.md b/documentation/docs/30-advanced/10-advanced-routing.md index 9647754182d8..5071d9336864 100644 --- a/documentation/docs/30-advanced/10-advanced-routing.md +++ b/documentation/docs/30-advanced/10-advanced-routing.md @@ -61,7 +61,7 @@ export function load(event) { } ``` -> [!NOTE] If you don't handle 404 cases, they will appear in [`handleError`](hooks#handleError) +> [!NOTE] If you don't handle 404 cases, they will appear in [`handleError`](hooks#handleError) as [framework errors](errors#Framework-errors), with a `kind` of `'framework'`. Otherwise, they will appear with a `kind` of `'expected'`. ## Optional parameters diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index 6d1275d8665d..8100adc7f7a7 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -177,18 +177,32 @@ export function handleValidationError({ issues }) { Be thoughtful about what information you expose here, as the most likely reason for validation to fail is that someone is sending malicious requests to your server. +The object you return here becomes the body of an [expected error](errors#Expected-errors), which means it subsequently passes through [`handleError`](hooks#handleError) as an error with a `kind` of `'expected'`. + ## handleError > [!NOTE] Can be added to `src/hooks.server.js` and `src/hooks.client.js` -If an [unexpected error](errors#Unexpected-errors) is thrown during loading, rendering, or from an endpoint, this function will be called with the `error`, `event`, `status` code and `message`. This allows for two things: +This function is called for _every_ error thrown while loading, rendering, or responding to a request. This allows for two things: - you can log the error -- you can generate a custom representation of the error that is safe to show to users, omitting sensitive details like messages and stack traces. The returned value, which defaults to `{ message }`, becomes the value of `page.error`. +- you can generate a custom representation of the error that is safe to show to users, omitting sensitive details like messages and stack traces. The returned value becomes the value of `page.error`. + +Alongside the `event`, the hook receives a `kind` discriminant that tells you where the error came from, and the `error` itself: + +| `kind` | thrown by | `error` | defaults to | +| -------------- | ----------------------------------- | -------------------------------------------------------------------- | -------------------------------------------- | +| `'expected'` | [`error(...)`](@sveltejs-kit#error) | the error body, which matches [`App.Error`](types#Error) | the error body itself | +| `'unexpected'` | your code, or code it calls | the thrown value, which may contain information unsafe to expose | `{ status: 500, message: 'Internal Error' }` | +| `'framework'` | SvelteKit — 404s, 405s, 413s... | `{ status, message }`, where `message` is safe text like `Not Found` | that same `{ status, message }` | + +Errors from [`handleValidationError`](hooks#handleValidationError) arrive as _expected_ errors. Redirects are not errors, and never reach the hook. -For errors thrown from your code (or library code called by your code) the status will be 500 and the message will be "Internal Error". While `error.message` may contain sensitive information that should not be exposed to users, `message` is safe (albeit meaningless to the average user). +The hook returns an object matching [`App.Error`](types#Error), in which `status` and `message` are **optional** — return them only to override the defaults in the table above. Any property you omit is inherited from the caught error, so returning `{}` (or nothing at all) leaves the error untouched, while returning `{ status: 404 }` changes only the status. -To add more information to the `page.error` object in a type-safe way, you can customize the expected shape by declaring an `App.Error` interface (which must include `message: string`, to guarantee sensible fallback behavior). This allows you to — for example — append a tracking ID for users to quote in correspondence with your technical support staff: +> [!NOTE] If you augment `App.Error` with additional _required_ properties, the hook must return them. + +To add more information to the `page.error` object in a type-safe way, you can customize the expected shape by declaring an `App.Error` interface (which must include `status: number` and `message: string`, to guarantee sensible fallback behavior). This allows you to — for example — append a tracking ID for users to quote in correspondence with your technical support staff: ```ts /// file: src/app.d.ts @@ -220,14 +234,28 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({/*...*/}) /** @type {import('@sveltejs/kit').HandleServerError} */ -export async function handleError({ error, event, status, message }) { +export async function handleError({ kind, error, event }) { + if (kind === 'expected') { + // you created this error with `error(...)`, so it already + // matches `App.Error` — pass it through unchanged + return error; + } + const errorId = crypto.randomUUID(); + if (kind === 'framework') { + // a 404 (or similar) — `error.status` and `error.message` are safe to + // expose, so we keep them and just add our own property + return { ...error, errorId }; + } + // example integration with https://sentry.io/ Sentry.captureException(error, { extra: { event, errorId, status } }); + // `status` and `message` are optional — we only override `message`, + // so the status stays at its default of 500 return { message: 'Whoops!', errorId @@ -251,12 +279,20 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({/*...*/}) /** @type {import('@sveltejs/kit').HandleClientError} */ -export async function handleError({ error, event, status, message }) { +export async function handleError({ kind, error, event }) { + if (kind === 'expected') { + return error; + } + const errorId = crypto.randomUUID(); + if (kind === 'framework') { + return { ...error, errorId }; + } + // example integration with https://sentry.io/ Sentry.captureException(error, { - extra: { event, errorId, status } + extra: { event, errorId } }); return { @@ -268,7 +304,7 @@ export async function handleError({ error, event, status, message }) { > [!NOTE] In `src/hooks.client.js`, the type of `handleError` is `HandleClientError` instead of `HandleServerError`, and `event` is a `NavigationEvent` rather than a `RequestEvent`. -This function is not called for _expected_ errors (those thrown with the [`error`](@sveltejs-kit#error) function imported from `@sveltejs/kit`). +Errors that were already transformed by the server-side hook are not passed to the client-side hook a second time. During development, if an error occurs because of a syntax error in your Svelte code, the passed in error has a `frame` property appended highlighting the location of the error. diff --git a/documentation/docs/30-advanced/25-errors.md b/documentation/docs/30-advanced/25-errors.md index fcb0f2daabeb..68f293623321 100644 --- a/documentation/docs/30-advanced/25-errors.md +++ b/documentation/docs/30-advanced/25-errors.md @@ -6,7 +6,7 @@ Errors are an inevitable fact of software development. SvelteKit handles errors ## Error objects -SvelteKit distinguishes between expected and unexpected errors, both of which are represented as simple `{ status: number, message: string }` objects by default. +SvelteKit distinguishes between expected errors, unexpected errors, and errors generated by the framework itself, all of which are represented as simple `{ status: number, message: string }` objects by default. Whichever kind it is, every error passes through the [`handleError`](hooks#handleError) hook — which can log it, and customise it — before it is rendered. You can add additional properties, like a `code` or a tracking `id`, as shown in the examples below. (When using TypeScript this requires you to redefine the `Error` type as described in [type safety](errors#Type-safety)). @@ -40,6 +40,8 @@ export async function load({ params }) { This throws an exception that SvelteKit catches, causing it to set the response status code to 404 and render an [`+error.svelte`](routing#error) component, where the `error` is an `App.Error` object with the provided `status` and `message`. +On its way there, the error passes through the [`handleError`](hooks#handleError) hook with a `kind` of `'expected'`. Since the shape of an expected error is determined by you, it is considered safe to expose, and the hook can pass it through unchanged. + ```svelte