diff --git a/.changeset/seven-mangos-cheat.md b/.changeset/seven-mangos-cheat.md new file mode 100644 index 000000000000..d782549a30ae --- /dev/null +++ b/.changeset/seven-mangos-cheat.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow returning a custom HTTP status code from the `handleError` hook diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index 2091e49794c7..07a409db0252 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -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: diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 3516689dce67..d91ea4f94c64 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -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) => 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. */ diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 83e16c3a9d12..e127acacb8a0 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -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 }); }) ) @@ -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); } } } diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 0d4838cc8c61..35c96d4ab6d5 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -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" @@ -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 } ); } diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 15d315905ab6..0eaf8b580cad 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -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--) { diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index 0767e177d124..e64e75c52cae 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -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, @@ -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, @@ -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); } } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 88ae0d41387c..4b8dab331380 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -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 }; } }) @@ -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 }), { diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 9067418bc450..d30e99fe6228 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -193,6 +193,14 @@ export async function internal_respond(request, options, manifest, state) { } } }, + setStatusCode: (code) => { + if (typeof code !== 'number' || code < 100 || code > 599) { + 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, diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 3394bb038070..b790f4fc540b 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -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', diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 84242df90417..1fff474a619a 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -611,6 +611,8 @@ export interface RequestState { remote_data?: Map>>; refreshes?: Record>; is_endpoint_request?: boolean; + /** Custom status code set by event.setStatusCode() */ + error_status_code?: number; } export interface RequestStore { diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index 34d0e9cba874..9f833aa5d88e 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -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})` }; diff --git a/packages/kit/test/apps/basics/src/routes/errors/custom-error/+server.js b/packages/kit/test/apps/basics/src/routes/errors/custom-error/+server.js new file mode 100644 index 000000000000..b1ef7274e044 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/custom-error/+server.js @@ -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'); +} diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index eea785051107..0bb27c20bb9d 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -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) { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 0ddf7ca08844..4ceae54dd10a 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -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) => 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. */