Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/seven-mangos-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: allow returning a custom HTTP status code from the `handleError` hook
3 changes: 2 additions & 1 deletion documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,9 @@ If an [unexpected error](errors#Unexpected-errors) is thrown during loading, ren

- 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 customise the status code of the response

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).
For errors thrown from your code (or library code called by your code) the status will default to 500 and the message will default to "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).

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:

Expand Down
20 changes: 20 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,26 @@ export interface RequestEvent<
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://svelte.dev/docs/kit/@sveltejs-kit#Cookies) API instead.
*/
setHeaders: (headers: Record<string, string>) => void;
/**
* Override the status code for error responses. This is useful when you want to customize the HTTP status code returned for an error, for example:
*
* ```js
* /// file: src/hooks.server.js
* export async function handleError({ error, event, status, message }) {
* // Return 503 Service Unavailable for database errors
* if (error.message.includes('database')) {
* event.setStatusCode(503);
* }
*
* return {
* message: 'An error occurred'
* };
* }
* ```
*
* This method should only be called from the `handleError` hook and will only affect error responses.
*/
setStatusCode: (code: number) => void;
/**
* The requested URL.
*/
Expand Down
20 changes: 14 additions & 6 deletions packages/kit/src/runtime/server/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,18 @@ export async function render_data(
// Math.min because array isn't guaranteed to resolve in order
length = Math.min(length, i + 1);

const error_body = await handle_error_and_jsonify(event, event_state, options, error);
// Use custom status code if set via event.setStatusCode()
const status =
event_state.error_status_code ??
(error instanceof HttpError || error instanceof SvelteKitError
? error.status
: undefined);

return /** @type {import('types').ServerErrorNode} */ ({
type: 'error',
error: await handle_error_and_jsonify(event, event_state, options, error),
status:
error instanceof HttpError || error instanceof SvelteKitError
? error.status
: undefined
error: error_body,
status
});
})
)
Expand Down Expand Up @@ -156,7 +161,10 @@ export async function render_data(
if (error instanceof Redirect) {
return redirect_json_response(error);
} else {
return json_response(await handle_error_and_jsonify(event, event_state, options, error), 500);
const error_body = await handle_error_and_jsonify(event, event_state, options, error);
// Use custom status code if set via event.setStatusCode()
const status = event_state.error_status_code ?? 500;
return json_response(error_body, status);
}
}
}
Expand Down
28 changes: 19 additions & 9 deletions packages/kit/src/runtime/server/page/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@ export async function handle_action_json_request(event, event_state, options, se
`POST method not allowed. No form actions exist for ${DEV ? `the page at ${event.route.id}` : 'this page'}`
);

const error = await handle_error_and_jsonify(event, event_state, options, no_actions_error);

// Use custom status code if set via event.setStatusCode()
const status = event_state.error_status_code ?? no_actions_error.status;

return action_json(
{
type: 'error',
error: await handle_error_and_jsonify(event, event_state, options, no_actions_error)
error
},
{
status: no_actions_error.status,
status,
headers: {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
// "The server must generate an Allow header field in a 405 status code response"
Expand Down Expand Up @@ -93,18 +98,23 @@ export async function handle_action_json_request(event, event_state, options, se
return action_json_redirect(err);
}

const error = await handle_error_and_jsonify(
event,
event_state,
options,
check_incorrect_fail_use(err)
);

// Use custom status code if set via event.setStatusCode()
const status = event_state.error_status_code ?? get_status(err);

return action_json(
{
type: 'error',
error: await handle_error_and_jsonify(
event,
event_state,
options,
check_incorrect_fail_use(err)
)
error
},
{
status: get_status(err)
status
}
);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ export async function render_page(
return redirect_response(err.status, err.location);
}

const status = get_status(err);
// Use custom status code if set via event.setStatusCode()
const status = event_state.error_status_code ?? get_status(err);
const error = await handle_error_and_jsonify(event, event_state, options, err);

while (i--) {
Expand Down
13 changes: 7 additions & 6 deletions packages/kit/src/runtime/server/page/respond_with_error.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export async function respond_with_error({
);
}

const error_body = await handle_error_and_jsonify(event, event_state, options, error);
const status = event_state.error_status_code ?? get_status(error);

return await render_response({
options,
manifest,
Expand All @@ -99,7 +102,7 @@ export async function respond_with_error({
csr
},
status,
error: await handle_error_and_jsonify(event, event_state, options, error),
error: error_body,
branch,
fetched,
event,
Expand All @@ -114,10 +117,8 @@ export async function respond_with_error({
return redirect_response(e.status, e.location);
}

return static_error_page(
options,
get_status(e),
(await handle_error_and_jsonify(event, event_state, options, e)).message
);
const status = event_state.error_status_code ?? get_status(e);
const error = await handle_error_and_jsonify(event, event_state, options, e);
return static_error_page(options, status, error.message);
}
}
18 changes: 13 additions & 5 deletions packages/kit/src/runtime/server/remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id)
try {
return { type: 'result', data: get_result(arg, i) };
} catch (error) {
const error_body = await handle_error_and_jsonify(event, state, options, error);
// Use custom status code if set via event.setStatusCode()
const status =
state.error_status_code ??
(error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500);
return {
type: 'error',
error: await handle_error_and_jsonify(event, state, options, error),
status:
error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500
error: error_body,
status
};
}
})
Expand Down Expand Up @@ -183,13 +187,17 @@ async function handle_remote_call_internal(event, state, options, manifest, id)
);
}

const error_body = await handle_error_and_jsonify(event, state, options, error);

// Use custom status code if set via event.setStatusCode()
const status =
error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500;
state.error_status_code ??
(error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500);

return json(
/** @type {RemoteFunctionResponse} */ ({
type: 'error',
error: await handle_error_and_jsonify(event, state, options, error),
error: error_body,
status
}),
{
Expand Down
8 changes: 8 additions & 0 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ export async function internal_respond(request, options, manifest, state) {
}
}
},
setStatusCode: (code) => {
if (typeof code !== 'number' || code < 100 || code > 599) {
Copy link
Author

@justinba1010 justinba1010 Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How should we handle an invalid http code, it feels like throwing is wrong here, but implied behavior also doesn't feel terribly correct?

Copy link
Member

@teemingc teemingc Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're not too far from what we're currently doing. See

if ((!BROWSER || DEV) && (isNaN(status) || status < 400 || status > 599)) {
throw new Error(`HTTP error status codes must be between 400 and 599 — ${status} is invalid`);

The only difference is the isNaN check and the error message format

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add those tomorrow 👍

throw new Error(
`Invalid status code: ${code}. Status code must be a number between 100 and 599`
);
}
event_state.error_status_code = code;
},
url,
isDataRequest: is_data_request,
isSubRequest: state.depth > 0,
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/runtime/server/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,11 @@ export function static_error_page(options, status, message) {
*/
export async function handle_fatal_error(event, state, options, error) {
error = error instanceof HttpError ? error : coalesce_to_error(error);
const status = get_status(error);
const body = await handle_error_and_jsonify(event, state, options, error);

// Use custom status code if set via event.setStatusCode()
const status = state.error_status_code ?? get_status(error);

// ideally we'd use sec-fetch-dest instead, but Safari — quelle surprise — doesn't support it
const type = negotiate(event.request.headers.get('accept') || 'text/html', [
'application/json',
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,8 @@ export interface RequestState {
remote_data?: Map<RemoteInfo, Record<string, MaybePromise<any>>>;
refreshes?: Record<string, Promise<any>>;
is_endpoint_request?: boolean;
/** Custom status code set by event.setStatusCode() */
error_status_code?: number;
}

export interface RequestStore {
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/test/apps/basics/src/hooks.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export const handleError = ({ event, error: e, status, message }) => {
message = ev.locals.message;
}

if (event.url.pathname.startsWith('/errors/custom-error')) {
event.setStatusCode(422);
}

return event.url.pathname.endsWith('404-fallback')
? undefined
: { message: `${error.message} (${status} ${message})` };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* This gets intercepted by the handleError hook and sets the status code to 422
*/
export function GET() {
throw new Error('Custom error');
}
5 changes: 5 additions & 0 deletions packages/kit/test/apps/basics/test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,11 @@ test.describe('Errors', () => {
expect(await response.text()).toMatch('thisvariableisnotdefined is not defined');
});

test('handleError hook can set the response status', async ({ request }) => {
const response = await request.get('/errors/custom-error');
expect(response.status()).toBe(422);
});

test('returns 400 when accessing a malformed URI', async ({ page }) => {
const response = await page.goto('/%c0%ae%c0%ae/etc/passwd');
if (process.env.DEV) {
Expand Down
20 changes: 20 additions & 0 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,26 @@ declare module '@sveltejs/kit' {
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://svelte.dev/docs/kit/@sveltejs-kit#Cookies) API instead.
*/
setHeaders: (headers: Record<string, string>) => void;
/**
* Override the status code for error responses. This is useful when you want to customize the HTTP status code returned for an error, for example:
*
* ```js
* /// file: src/hooks.server.js
* export async function handleError({ error, event, status, message }) {
* // Return 503 Service Unavailable for database errors
* if (error.message.includes('database')) {
* event.setStatusCode(503);
* }
*
Comment on lines +1497 to +1501
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to be too opinionated for an example, very open to suggestions, I'd feel partial to the convention we're using for our internal exceptions if you'd like as it's one common in monorepos.

* return {
* message: 'An error occurred'
* };
* }
* ```
*
* This method should only be called from the `handleError` hook and will only affect error responses.
*/
setStatusCode: (code: number) => void;
/**
* The requested URL.
*/
Expand Down
Loading