diff --git a/.changeset/ripe-tires-chew.md b/.changeset/ripe-tires-chew.md
new file mode 100644
index 000000000000..17a8c85a9fd4
--- /dev/null
+++ b/.changeset/ripe-tires-chew.md
@@ -0,0 +1,6 @@
+---
+"@sveltejs/kit": major
+---
+
+breaking: allow `handleError` to influence status code
+
\ No newline at end of file
diff --git a/documentation/docs/30-advanced/25-errors.md b/documentation/docs/30-advanced/25-errors.md
index 81b615e38691..da300b6fb0aa 100644
--- a/documentation/docs/30-advanced/25-errors.md
+++ b/documentation/docs/30-advanced/25-errors.md
@@ -100,6 +100,27 @@ By default, unexpected errors are printed to the console (or, in production, you
Unexpected errors will go through the [`handleError`](hooks#Shared-hooks-handleError) hook, where you can add your own error handling — for example, sending errors to a reporting service, or returning a custom error object which becomes `page.error`.
+You can override the HTTP status code used in the response by returning a `status` property:
+
+```js
+/// file: src/hooks.server.js
+// Assuming you have this ...
+class NotFound extends Error {}
+
+/** @type {import('@sveltejs/kit').HandleServerError} */
+export function handleError({ error, event, status, message }) {
+ // ... you can do this
+ if (error instanceof NotFound) {
+ return {
+ status: 404,
+ message: 'Not found'
+ };
+ }
+
+ return { message: 'Something went wrong' };
+}
+```
+
## Rendering errors
Ordinarily, if an error happens during server-side rendering (for example inside a component's `
diff --git a/packages/kit/test/apps/basics/src/routes/errors/handle-error-status/+page.js b/packages/kit/test/apps/basics/src/routes/errors/handle-error-status/+page.js
new file mode 100644
index 000000000000..25d076279a1d
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/errors/handle-error-status/+page.js
@@ -0,0 +1,4 @@
+/** @type {import('@sveltejs/kit').Load} */
+export async function load() {
+ throw new Error('Status override test');
+}
diff --git a/packages/kit/test/apps/basics/test/cross-platform/test.js b/packages/kit/test/apps/basics/test/cross-platform/test.js
index 9caab5539ab0..d93c31efc723 100644
--- a/packages/kit/test/apps/basics/test/cross-platform/test.js
+++ b/packages/kit/test/apps/basics/test/cross-platform/test.js
@@ -336,6 +336,16 @@ test.describe('Errors', () => {
expect(await page.textContent('#error-layout-data')).toBe('42');
});
+ test('handleError can override the status code of an unexpected error', async ({ page }) => {
+ const response = await page.goto('/errors/handle-error-status');
+
+ await expect(page.locator('h1')).toHaveText('404');
+ await expect(page.locator('#message')).toHaveText(
+ 'This is your custom error page saying: "Status override test (500 Internal Error)"'
+ );
+ expect(/** @type {Response} */ (response).status()).toBe(404);
+ });
+
test('error in endpoint', async ({ page, read_errors }) => {
const res = await page.goto('/errors/endpoint');
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index 8e4d76ebb4aa..23b4bc679d2b 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -576,7 +576,8 @@ test.describe('Errors', () => {
expect(res.status()).toBe(401);
expect(await res.json()).toEqual({
- message: 'You shall not pass'
+ message: 'You shall not pass',
+ status: 401
});
}
});
@@ -610,7 +611,8 @@ test.describe('Errors', () => {
error: {
message: process.env.DEV
? 'POST method not allowed. No form actions exist for the page at /errors/missing-actions (405 Method Not Allowed)'
- : 'POST method not allowed. No form actions exist for this page (405 Method Not Allowed)'
+ : 'POST method not allowed. No form actions exist for this page (405 Method Not Allowed)',
+ status: 405
}
});
});
@@ -641,7 +643,8 @@ test.describe('Errors', () => {
expect(error.stack).toBe(undefined);
expect(res.status()).toBe(500);
expect(error).toEqual({
- message: 'Error in handle (500 Internal Error)'
+ message: 'Error in handle (500 Internal Error)',
+ status: 500
});
}
});
@@ -672,7 +675,8 @@ test.describe('Errors', () => {
expect(error.stack).toBe(undefined);
expect(res.status()).toBe(500);
expect(error).toEqual({
- message: 'Expected error in handle'
+ message: 'Expected error in handle',
+ status: 500
});
}
});
diff --git a/packages/kit/test/types/app-error-enhanced/error.test.ts b/packages/kit/test/types/app-error-enhanced/error.test.ts
new file mode 100644
index 000000000000..29877e626c60
--- /dev/null
+++ b/packages/kit/test/types/app-error-enhanced/error.test.ts
@@ -0,0 +1,67 @@
+import { error, type HandleClientError, type HandleServerError } from '@sveltejs/kit';
+
+const app_error: App.Error = { status: 500, message: 'Unexpected error', additional: true };
+
+declare global {
+ namespace App {
+ interface Error {
+ additional: boolean;
+ }
+ }
+}
+
+// @ts-expect-error App.Error requires status
+const app_error_without_status: App.Error = { message: 'Unexpected error' };
+
+const handle_error_hooks: [
+ HandleServerError,
+ HandleServerError,
+ HandleClientError,
+ HandleClientError
+] = [
+ () => ({ message: 'Unexpected error', additional: true }),
+ // @ts-expect-error App.Error requires additional
+ () => ({ message: 'Unexpected error' }),
+ () => ({ message: 'Unexpected error', additional: true }),
+ // @ts-expect-error App.Error requires
+ () => ({ message: 'Unexpected error' })
+];
+
+void app_error;
+void app_error_without_status;
+void handle_error_hooks;
+
+function a() {
+ // @ts-expect-error App.Error requires additional
+ error(400, 'Bad request');
+}
+
+function b() {
+ // @ts-expect-error App.Error requires additional
+ error(400, { message: 'Bad request' });
+}
+
+function c() {
+ error(400, 'Bad request', { additional: true });
+}
+
+function d() {
+ // @ts-expect-error
+ error(400, { message: 'Bad request' });
+}
+
+function e() {
+ error(400, { message: 'Bad request', additional: true });
+}
+
+function f() {
+ // @ts-expect-error
+ error(400);
+}
+
+a;
+b;
+c;
+d;
+e;
+f;
diff --git a/packages/kit/test/types/app-error-enhanced/tsconfig.json b/packages/kit/test/types/app-error-enhanced/tsconfig.json
new file mode 100644
index 000000000000..cccbcf5d4ea7
--- /dev/null
+++ b/packages/kit/test/types/app-error-enhanced/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "paths": {
+ "@sveltejs/kit": ["../../../types/index.d.ts"]
+ }
+ },
+ "include": ["**/*.test.ts", "../../../types/index.d.ts"]
+}
diff --git a/packages/kit/test/types/error.test.ts b/packages/kit/test/types/error.test.ts
new file mode 100644
index 000000000000..8724a3cf1888
--- /dev/null
+++ b/packages/kit/test/types/error.test.ts
@@ -0,0 +1,48 @@
+import { error, type HandleClientError, type HandleServerError } from '@sveltejs/kit';
+
+const app_error: App.Error = { status: 500, message: 'Unexpected error' };
+
+// @ts-expect-error App.Error requires status
+const app_error_without_status: App.Error = { message: 'Unexpected error' };
+
+const handle_error_hooks: [HandleServerError, HandleClientError] = [
+ () => ({ message: 'Unexpected error' }),
+ () => ({ message: 'Unexpected error' })
+];
+
+void app_error;
+void app_error_without_status;
+void handle_error_hooks;
+
+function a() {
+ error(400, 'Bad request');
+}
+
+function b() {
+ error(400, { message: 'Bad request' });
+}
+
+function c() {
+ // @ts-expect-error
+ error(400, 'Bad request', { cause: new Error('cause') });
+}
+
+function d() {
+ error(400, { message: 'Bad request' });
+}
+
+function e() {
+ // @ts-expect-error
+ error(400, { message: 'Bad request', cause: new Error('cause') });
+}
+
+function f() {
+ error(400);
+}
+
+a;
+b;
+c;
+d;
+e;
+f;
diff --git a/packages/kit/test/types/tsconfig.json b/packages/kit/test/types/tsconfig.json
index 7675e28d941c..9773e7d707f1 100644
--- a/packages/kit/test/types/tsconfig.json
+++ b/packages/kit/test/types/tsconfig.json
@@ -3,5 +3,6 @@
"compilerOptions": {
"noEmit": true
},
- "include": ["**/*.test.ts", "../../src/types/*.d.ts"]
+ "include": ["**/*.test.ts", "../../src/types/*.d.ts"],
+ "exclude": ["../../**/write_types/test/**", "./app-error-enhanced/**"]
}
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index d5fc1126b55a..3582653f0590 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -8,6 +8,8 @@ declare module '@sveltejs/kit' {
// @ts-ignore this is an optional peer dependency so could be missing. Written like this so dts-buddy preserves the ts-ignore
type Span = import('@opentelemetry/api').Span;
+ type AppErrorWithOptionalStatus = Omit & { status?: App.Error['status'] };
+
/**
* [Adapters](https://svelte.dev/docs/kit/adapters) are responsible for taking the production build and turning it into something that can be deployed to a platform of your choosing.
*/
@@ -924,13 +926,16 @@ declare module '@sveltejs/kit' {
*
* If an unexpected error is thrown during loading or rendering, this function will be called with the error and the event.
* Make sure that this function _never_ throws an error.
+ *
+ * The returned object can include a `status` property to override the HTTP status code used in the response.
+ * If omitted, the status defaults to 500.
*/
export type HandleServerError = (input: {
error: unknown;
event: RequestEvent;
status: number;
message: string;
- }) => MaybePromise;
+ }) => MaybePromise;
/**
* The [`handleValidationError`](https://svelte.dev/docs/kit/hooks#Server-hooks-handleValidationError) hook runs when the argument to a remote function fails validation.
@@ -938,20 +943,23 @@ declare module '@sveltejs/kit' {
* It will be called with the validation issues and the event, and must return an object shape that matches `App.Error`.
*/
export type HandleValidationError =
- (input: { issues: Issue[]; event: RequestEvent }) => MaybePromise;
+ (input: { issues: Issue[]; event: RequestEvent }) => MaybePromise;
/**
* The client-side [`handleError`](https://svelte.dev/docs/kit/hooks#Shared-hooks-handleError) hook runs when an unexpected error is thrown while navigating.
*
* If an unexpected error is thrown during loading or the following render, this function will be called with the error and the event.
* Make sure that this function _never_ throws an error.
+ *
+ * The returned object can include a `status` property to override the HTTP status code used in the response.
+ * If omitted, the status defaults to 500.
*/
export type HandleClientError = (input: {
error: unknown;
event: NavigationEvent;
status: number;
message: string;
- }) => MaybePromise;
+ }) => MaybePromise;
/**
* The [`handleFetch`](https://svelte.dev/docs/kit/hooks#Server-hooks-handleFetch) hook allows you to modify (or replace) the result of an [`event.fetch`](https://svelte.dev/docs/kit/load#Making-fetch-requests) call that runs on the server (or during prerendering) inside an endpoint, `load`, `action`, `handle`, `handleError` or `reroute`.
@@ -1807,7 +1815,7 @@ declare module '@sveltejs/kit' {
| { type: 'success'; status: number; data?: Success }
| { type: 'failure'; status: number; data?: Failure }
| { type: 'redirect'; status: number; location: string }
- | { type: 'error'; status?: number; error: any };
+ | { type: 'error'; status?: number; error: App.Error };
/**
* The object returned by the [`error`](https://svelte.dev/docs/kit/@sveltejs-kit#error) function.
@@ -2859,20 +2867,38 @@ declare module '@sveltejs/kit' {
* @throws {import('./public.js').HttpError} This error instructs SvelteKit to initiate HTTP error handling.
* @throws {Error} If the provided status is invalid (not between 400 and 599).
*/
- export function error(status: number, body: App.Error): never;
+ export function error(status: number, body: Omit & {
+ status?: App.Error["status"];
+ }): never;
/**
* Throws an error with a HTTP status code and an optional message.
* When called during request handling, this will cause SvelteKit to
* return an error response without invoking `handleError`.
* Make sure you're not catching the thrown error, which would prevent SvelteKit from handling it.
* @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599.
- * @param body An object that conforms to the App.Error type. If a string is passed, it will be used as the message property.
+ * @param body The error message.
* @throws {import('./public.js').HttpError} This error instructs SvelteKit to initiate HTTP error handling.
* @throws {Error} If the provided status is invalid (not between 400 and 599).
*/
- export function error(status: number, body?: {
+ export function error(status: number, body: {
+ status: number;
message: string;
- } extends App.Error ? App.Error | string | undefined : never): never;
+ } extends App.Error ? string | void | undefined : never): never;
+ /**
+ * Throws an error with a HTTP status code and an optional message.
+ * When called during request handling, this will cause SvelteKit to
+ * return an error response without invoking `handleError`.
+ * Make sure you're not catching the thrown error, which would prevent SvelteKit from handling it.
+ * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599.
+ * @param body The error message.
+ * @param properties Additional properties of the App.Error type.
+ * @throws {import('./public.js').HttpError} This error instructs SvelteKit to initiate HTTP error handling.
+ * @throws {Error} If the provided status is invalid (not between 400 and 599).
+ */
+ export function error(status: number, body: string, properties: {
+ status: number;
+ message: string;
+ } extends App.Error ? never : Omit): never;
/**
* Checks whether this is an error thrown by {@link error}.
* @param status The status to filter for.
@@ -3796,6 +3822,7 @@ declare namespace App {
* Defines the common shape of expected and unexpected errors. Expected errors are thrown using the `error` function. Unexpected errors are handled by the `handleError` hooks which should return this shape.
*/
export interface Error {
+ status: number;
message: string;
}