Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

breaking: tighten up error handling #11289

Merged
merged 31 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
64565c0
breaking: tighten up error handling
dummdidumm Dec 13, 2023
fddc54f
how did this happen
dummdidumm Dec 13, 2023
7613649
fix tests
dummdidumm Dec 13, 2023
5bf57fb
lint
dummdidumm Dec 13, 2023
34acad2
Merge branch 'version-2' into tighten-up-error-handling
dummdidumm Dec 13, 2023
ae79081
Merge branch 'version-2' into tighten-up-error-handling
dummdidumm Dec 13, 2023
067c3fa
types
dummdidumm Dec 13, 2023
43e7d2e
Merge branch 'version-2' into tighten-up-error-handling
dummdidumm Dec 13, 2023
a5da208
adjust wording (is this even a breaking change now?)
dummdidumm Dec 13, 2023
8f0fbab
adjust
dummdidumm Dec 13, 2023
6d77aa5
pass status and message to handleError
dummdidumm Dec 13, 2023
c1c280c
merge
Rich-Harris Dec 13, 2023
da81262
Apply suggestions from code review
dummdidumm Dec 13, 2023
e4c96b7
lint
dummdidumm Dec 13, 2023
e5ad6f1
Update documentation/docs/30-advanced/20-hooks.md
Rich-Harris Dec 13, 2023
a291b70
Merge branch 'version-2' into tighten-up-error-handling
Rich-Harris Dec 13, 2023
f75abdc
lint
Rich-Harris Dec 13, 2023
bbe2c5d
simplify example
Rich-Harris Dec 13, 2023
7f5df13
tweak docs
Rich-Harris Dec 13, 2023
877605f
Update documentation/docs/30-advanced/20-hooks.md
Rich-Harris Dec 13, 2023
99b54e4
Merge branch 'tighten-up-error-handling' of github.com:sveltejs/kit i…
Rich-Harris Dec 13, 2023
824198d
various tweaks. we can be less duplicative i think
Rich-Harris Dec 13, 2023
7ecbca5
tweak
Rich-Harris Dec 13, 2023
24bdfd9
tweak
Rich-Harris Dec 13, 2023
c1265d8
handle too large body after streaming has started
Rich-Harris Dec 13, 2023
452e264
cancel stream from the inside if content-length exceeds limit
Rich-Harris Dec 13, 2023
5a914a2
remove unnecessary try-catch, bump adapter-node/adapter-vercel majors
Rich-Harris Dec 13, 2023
95bb358
migration docs
Rich-Harris Dec 13, 2023
b24e8eb
tweak/fix tests
Rich-Harris Dec 13, 2023
d31afbb
fix more
Rich-Harris Dec 13, 2023
231f212
more
Rich-Harris Dec 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/real-pets-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sveltejs/kit": major
---

breaking: tighten up error handling
2 changes: 1 addition & 1 deletion documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:
If an unexpected error is thrown during loading or rendering, this function will be called with the `error` and the `event`. 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 becomes the value of `$page.error`. It defaults to `{ message: 'Not Found' }` in case of a 404 (you can detect them through `event.route.id` being `null`) and to `{ message: 'Internal Error' }` for everything else. To make this type-safe, you can customize the expected shape by declaring an `App.Error` interface (which must include `message: string`, to guarantee sensible fallback behavior).
- 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`. It defaults to the message of `NonFatalError` for non-fatal errors within SvelteKit (such as `{ message: 'Not found: ..' }` in case of a 404) and to `{ message: 'Internal Error' }` for everything else. To make this type-safe, you can customize the expected shape by declaring an `App.Error` interface (which must include `message: string`, to guarantee sensible fallback behavior).

The following code shows an example of typing the error shape as `{ message: string; errorId: string }` and returning it accordingly from the `handleError` functions:

Expand Down
37 changes: 37 additions & 0 deletions documentation/docs/30-advanced/25-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,43 @@ export function handleError({ error, event }) {
}
```

SvelteKit may handle certain errors as non-fatal. In these cases, it throws a `NonFatalError` which contains a non-sensitive message and the status code. Examples for this are 404s (page not found) or 415s for actions (wrong content-type). These go through `handleError` as well, and you can distinguish them from other unexpected errors via an `instanceof` check.

```js
/// file: src/hooks.server.js
// @errors: 2322 1360 2571 2339 2353
// @filename: ambient.d.ts
declare module '@sentry/sveltekit' {
export const init: (opts: any) => void;
export const captureException: (error: any, opts: any) => void;
}

// @filename: index.js
// ---cut---
import * as Sentry from '@sentry/sveltekit';
import { NonFatalError } from '@sveltejs/kit';

Sentry.init({/*...*/})

/** @type {import('@sveltejs/kit').HandleServerError} */
export function handleError({ error, event }) {
if (error instanceof NonFatalError) {
return {
message: error.message, // safe to forward
code: 'UNKNOWN'
};
} else {
// example integration with https://sentry.io/
Sentry.captureException(error, { extra: { event } });

return {
message: 'Whoops!',
code: error?.code ?? 'UNKNOWN'
};
}
}
```

> Make sure that `handleError` _never_ throws an error

## Responses
Expand Down
6 changes: 6 additions & 0 deletions documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ As such, SvelteKit 2 replaces `resolvePath` with a (slightly better named) funct

`svelte-migrate` will do the method replacement for you, though if you later prepend the result with `base`, you need to remove that yourself.

## Improved error handling

Some errors are handled internally by SvelteKit, but they are not unexpected. In SvelteKit 1 they were all handled by throwing a regular `Error` on a case-by-case basis. This meant it's not easy to distinguish between real unexpected errors and others such as someone calling your action endpoint with the wrong content type.

SvelteKit 2 cleans this up by introducing a new `NonFatalError` object which extends `Error` and is thrown by all internal code paths were applicable. You can distinguish these non-fatal errors by doing an `instanceof NonFatalError` check inside `handleError`.

## Dynamic environment variables cannot be used during prerendering

The `$env/dynamic/public` and `$env/dynamic/private` modules provide access to _run time_ environment variables, as opposed to the _build time_ environment variables exposed by `$env/static/public` and `$env/static/private`.
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/exports/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { HttpError, Redirect, ActionFailure } from '../runtime/control.js';
import { HttpError, Redirect, ActionFailure, NonFatalError } from '../runtime/control.js';
import { BROWSER, DEV } from 'esm-env';

export { VERSION } from '../version.js';
export { NonFatalError };

/**
* @template {number} TNumber
Expand Down
23 changes: 10 additions & 13 deletions packages/kit/src/exports/node/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as set_cookie_parser from 'set-cookie-parser';
import { error } from '../index.js';

/**
* @param {import('http').IncomingMessage} req
Expand Down Expand Up @@ -28,10 +27,10 @@ function get_raw_body(req, body_size_limit) {
if (!length) {
length = body_size_limit;
} else if (length > body_size_limit) {
error(
413,
`Received content-length of ${length}, but only accept up to ${body_size_limit} bytes.`
);
throw {
status: 413,
message: `Received content-length of ${length}, but only accept up to ${body_size_limit} bytes.`
};
Copy link
Member

Choose a reason for hiding this comment

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

Why doesn't this use SvelteKitError?

Suggested change
throw {
status: 413,
message: `Received content-length of ${length}, but only accept up to ${body_size_limit} bytes.`
};
throw new SvelteKitError(413, 'Payload Too Large', `Received content-length of ${length}, but only accept up to ${body_size_limit} bytes.`);

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think an error at this stage will be handled by handelError, so I kept this clear of it. But we could use it.

Copy link
Member

Choose a reason for hiding this comment

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

Not at this stage, no. But the thing that reads the body will either catch the error (in which case it is then responsible for setting a status code) or, more likely, the error will go uncaught. At that point, it's useful if the error was a SvelteKitError with a correct status code. If it's a POJO then handleError will be called with a 500 Internal Error.

Copy link
Member

Choose a reason for hiding this comment

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

More correctly: the above applied to the second occurrence, once the stream has already started being read.

In this case, we're still creating the Request object. Right now, every usage of getRequest has to be wrapped in a try-catch, and the catch clause always looks like this:

res.statusCode = err.status || 400;
res.end('Invalid request body');

This makes no sense — there's only one possible error (the 413), and the diagnostic message gets swallowed.

I think the best solution is to throw the 'content length exceeds limit' error immediately when the stream starts. That way, getRequest callers don't need to try-catch, and we get both user-friendly and developer-friendly error messages.

}
}

Expand Down Expand Up @@ -62,14 +61,12 @@ function get_raw_body(req, body_size_limit) {
size += chunk.length;
if (size > length) {
cancelled = true;
controller.error(
error(
413,
`request body size exceeded ${
content_length ? "'content-length'" : 'BODY_SIZE_LIMIT'
} of ${length}`
)
);
controller.error({
status: 413,
message: `request body size exceeded ${
content_length ? "'content-length'" : 'BODY_SIZE_LIMIT'
} of ${length}`
});
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
return;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,8 @@ export async function dev(vite, vite_config, svelte_config) {
control_module_node.replace_implementations({
ActionFailure: control_module_vite.ActionFailure,
HttpError: control_module_vite.HttpError,
Redirect: control_module_vite.Redirect
Redirect: control_module_vite.Redirect,
NonFatalError: control_module_vite.NonFatalError
});
}
align_exports();
Expand Down
15 changes: 7 additions & 8 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ import { base } from '__sveltekit/paths';
import * as devalue from 'devalue';
import { compact } from '../../utils/array.js';
import { validate_page_exports } from '../../utils/exports.js';
import { HttpError, Redirect, NotFound } from '../control.js';
import { HttpError, Redirect, NonFatalError } from '../control.js';
import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM, validate_depends } from '../shared.js';
import { INDEX_KEY, PRELOAD_PRIORITIES, SCROLL_KEY, SNAPSHOT_KEY } from './constants.js';
import { stores } from './singletons.js';
import { get_status } from '../../utils/error.js';

let errored = false;

Expand Down Expand Up @@ -704,7 +705,7 @@ export function create_client(app, target) {
server_data = await load_data(url, invalid_server_nodes);
} catch (error) {
return load_root_error_page({
status: error instanceof HttpError ? error.status : 500,
status: get_status(error),
error: await handle_error(error, { url, params, route: { id: route.id } }),
url,
route
Expand Down Expand Up @@ -788,7 +789,7 @@ export function create_client(app, target) {
};
}

let status = 500;
let status = get_status(err);
/** @type {App.Error} */
let error;

Expand All @@ -798,7 +799,6 @@ export function create_client(app, target) {
status = /** @type {import('types').ServerErrorNode} */ (err).status ?? status;
error = /** @type {import('types').ServerErrorNode} */ (err).error;
} else if (err instanceof HttpError) {
status = err.status;
error = err.body;
} else {
// Referenced node could have been removed due to redeploy, check
Expand Down Expand Up @@ -1060,7 +1060,7 @@ export function create_client(app, target) {
navigation_result = await server_fallback(
url,
{ id: null },
await handle_error(new Error(`Not found: ${url.pathname}`), {
await handle_error(new NonFatalError(404, 'Not Found', `Not found: ${url.pathname}`), {
url,
params: {},
route: { id: null }
Expand Down Expand Up @@ -1380,8 +1380,7 @@ export function create_client(app, target) {
return (
app.hooks.handleError({ error, event }) ??
/** @type {any} */ ({
message:
event.route.id === null && error instanceof NotFound ? 'Not Found' : 'Internal Error'
message: error instanceof NonFatalError ? error.message : 'Internal Error'
})
);
}
Expand Down Expand Up @@ -1908,7 +1907,7 @@ export function create_client(app, target) {
}

result = await load_root_error_page({
status: error instanceof HttpError ? error.status : 500,
status: get_status(error),
error: await handle_error(error, { url, params, route }),
url,
route
Expand Down
22 changes: 15 additions & 7 deletions packages/kit/src/runtime/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,20 @@ export class Redirect {
}
}

export class NotFound extends Error {
/**
* An error that was thrown from within the SvelteKit runtime that is not fatal and doesn't result in a 500, such as a 404.
* `NonFatalError` goes through `handleError` and you can distinguish it from other errors using `instanceof NonFatalError`.
*/
export class NonFatalError extends Error {
/**
* @param {string} pathname
* @param {number} status
* @param {string} message
* @param {string} [context]
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
*/
constructor(pathname) {
super();

this.status = 404;
this.message = `Not found: ${pathname}`;
constructor(status, message, context) {
super(message);
this.status = status;
this.context = context;
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down Expand Up @@ -66,6 +71,7 @@ export class ActionFailure {
* ActionFailure: typeof ActionFailure;
* HttpError: typeof HttpError;
* Redirect: typeof Redirect;
* NonFatalError: typeof NonFatalError;
* }} implementations
*/
export function replace_implementations(implementations) {
Expand All @@ -75,4 +81,6 @@ export function replace_implementations(implementations) {
HttpError = implementations.HttpError; // eslint-disable-line no-class-assign
// @ts-expect-error
Redirect = implementations.Redirect; // eslint-disable-line no-class-assign
// @ts-expect-error
NonFatalError = implementations.NonFatalError; // eslint-disable-line no-class-assign
}
7 changes: 5 additions & 2 deletions packages/kit/src/runtime/server/data/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HttpError, Redirect } from '../../control.js';
import { HttpError, NonFatalError, Redirect } from '../../control.js';
import { normalize_error } from '../../../utils/error.js';
import { once } from '../../../utils/functions.js';
import { load_server_data } from '../page/load_data.js';
Expand Down Expand Up @@ -109,7 +109,10 @@ export async function render_data(
return /** @type {import('types').ServerErrorNode} */ ({
type: 'error',
error: await handle_error_and_jsonify(event, options, error),
status: error instanceof HttpError ? error.status : undefined
status:
error instanceof HttpError || error instanceof NonFatalError
? error.status
: undefined
});
})
)
Expand Down
7 changes: 0 additions & 7 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,6 @@ export class Server {
* @param {import('types').RequestOptions} options
*/
async respond(request, options) {
// TODO this should probably have been removed for 1.0 — i think we can get rid of it?
if (!(request instanceof Request)) {
throw new Error(
'The first argument to server.respond must be a Request object. See https://github.com/sveltejs/kit/pull/3384 for details'
);
}

return respond(request, this.#options, this.#manifest, {
...options,
error: false,
Expand Down
23 changes: 12 additions & 11 deletions packages/kit/src/runtime/server/page/actions.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as devalue from 'devalue';
import { error, json } from '../../../exports/index.js';
import { normalize_error } from '../../../utils/error.js';
import { json } from '../../../exports/index.js';
import { get_status, normalize_error } from '../../../utils/error.js';
import { is_form_content_type, negotiate } from '../../../utils/http.js';
import { HttpError, Redirect, ActionFailure } from '../../control.js';
import { HttpError, Redirect, ActionFailure, NonFatalError } from '../../control.js';
import { handle_error_and_jsonify } from '../utils.js';

/** @param {import('@sveltejs/kit').RequestEvent} event */
Expand All @@ -24,8 +24,7 @@ export async function handle_action_json_request(event, options, server) {
const actions = server?.actions;

if (!actions) {
// TODO should this be a different error altogether?
const no_actions_error = new HttpError(
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
const no_actions_error = new NonFatalError(
405,
'POST method not allowed. No actions exist for this page'
);
Expand Down Expand Up @@ -84,7 +83,7 @@ export async function handle_action_json_request(event, options, server) {
error: await handle_error_and_jsonify(event, options, check_incorrect_fail_use(err))
},
{
status: err instanceof HttpError ? err.status : 500
status: get_status(err)
}
);
}
Expand Down Expand Up @@ -142,7 +141,7 @@ export async function handle_action_request(event, server) {
});
return {
type: 'error',
error: new HttpError(405, 'POST method not allowed. No actions exist for this page')
error: new NonFatalError(405, 'POST method not allowed. No actions exist for this page')
};
}

Expand Down Expand Up @@ -201,7 +200,7 @@ function check_named_default_separate(actions) {
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {NonNullable<import('types').SSRNode['server']['actions']>} actions
* @throws {Redirect | ActionFailure | HttpError | Error}
* @throws {Redirect | HttpError | NonFatalError | Error}
*/
async function call_action(event, actions) {
const url = new URL(event.request.url);
Expand All @@ -219,12 +218,14 @@ async function call_action(event, actions) {

const action = actions[name];
if (!action) {
throw new Error(`No action with name '${name}' found`);
throw new NonFatalError(404, `No action with name '${name}' found`);
}

if (!is_form_content_type(event.request)) {
throw new Error(
`Actions expect form-encoded data (received ${event.request.headers.get('content-type')})`
throw new NonFatalError(
415,
'Actions expect form-encoded data',
`Received ${event.request.headers.get('content-type')}`
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
);
}

Expand Down
9 changes: 4 additions & 5 deletions packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { text } from '../../../exports/index.js';
import { compact } from '../../../utils/array.js';
import { normalize_error } from '../../../utils/error.js';
import { get_status, normalize_error } from '../../../utils/error.js';
import { add_data_suffix } from '../../../utils/url.js';
import { HttpError, Redirect } from '../../control.js';
import { Redirect } from '../../control.js';
import { redirect_response, static_error_page, handle_error_and_jsonify } from '../utils.js';
import {
handle_action_json_request,
Expand Down Expand Up @@ -65,8 +65,7 @@ export async function render_page(event, page, options, manifest, state, resolve
return redirect_response(action_result.status, action_result.location);
}
if (action_result?.type === 'error') {
const error = action_result.error;
status = error instanceof HttpError ? error.status : 500;
status = get_status(action_result.error);
}
if (action_result?.type === 'failure') {
status = action_result.status;
Expand Down Expand Up @@ -221,7 +220,7 @@ export async function render_page(event, page, options, manifest, state, resolve
return redirect_response(err.status, err.location);
}

const status = err instanceof HttpError ? err.status : 500;
const status = get_status(err);
const error = await handle_error_and_jsonify(event, options, err);

while (i--) {
Expand Down
5 changes: 3 additions & 2 deletions packages/kit/src/runtime/server/page/respond_with_error.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { render_response } from './render.js';
import { load_data, load_server_data } from './load_data.js';
import { handle_error_and_jsonify, static_error_page, redirect_response } from '../utils.js';
import { get_option } from '../../../utils/options.js';
import { HttpError, Redirect } from '../../control.js';
import { Redirect } from '../../control.js';
import { get_status } from '../../../utils/error.js';

/**
* @typedef {import('./types.js').Loaded} Loaded
Expand Down Expand Up @@ -103,7 +104,7 @@ export async function respond_with_error({

return static_error_page(
options,
e instanceof HttpError ? e.status : 500,
get_status(e),
(await handle_error_and_jsonify(event, options, e)).message
);
}
Expand Down