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 14 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
4 changes: 2 additions & 2 deletions documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,10 @@ The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`:

### handleError

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:
If an unexpected error is thrown during loading or rendering, this function will be called with the `error`, the `event`, a `status` code and a `message`. This allows for two things:
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

It might not be clear what "unexpected error" here refers to


- 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` passed in to `handleError`, which doesn't reveal any sensitive information and will be `{ message: 'Internal Error' }` for most errors. 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
25 changes: 16 additions & 9 deletions documentation/docs/30-advanced/25-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ By default, unexpected errors are printed to the console (or, in production, you
{ "message": "Internal Error" }
```

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.
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. `handleError` is passed the `error` along with the `event` and a `status` and `message`. `message` is just `"Internal Error"` for unforseen errors, in which case the `status` is 500. `status` may also contain other values such as 404 (page not found) or 415 for actions (wrong content-type), in which case a more specific but still safe `message` is provided.
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

```js
/// file: src/hooks.server.js
Expand All @@ -95,14 +95,21 @@ import * as Sentry from '@sentry/sveltekit';
Sentry.init({/*...*/})

/** @type {import('@sveltejs/kit').HandleServerError} */
export function handleError({ error, event }) {
// example integration with https://sentry.io/
Sentry.captureException(error, { extra: { event } });

return {
message: 'Whoops!',
code: error?.code ?? 'UNKNOWN'
};
export function handleError({ error, event, status, message }) {
if (status !== 500) {
return {
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'
};
}
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved
}
```

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. They were handled either by using the `error` function or throwing a regular `Error` on a case-by-case basis. This meant it's a) not easy to distinguish between real unexpected errors and others such as someone calling your action endpoint with the wrong content type and b) introduces a potential bug where properties that may be required due to a custom `App.Error` interface are missing.

SvelteKit 2 cleans this up by providing two new properties to `handleError`: `status` and `message`. For unforseen errors, the `status` is 500 and the `message` just `"Internal Error"`, for internal but handleable errors a more specific status code and message is provided, one which is still safe to show to the user.

## 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
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
4 changes: 4 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,8 @@ export type Handle = (input: {
export type HandleServerError = (input: {
error: unknown;
event: RequestEvent;
status: number;
message: string;
}) => MaybePromise<void | App.Error>;

/**
Expand All @@ -668,6 +670,8 @@ export type HandleServerError = (input: {
export type HandleClientError = (input: {
error: unknown;
event: NavigationEvent;
status: number;
message: string;
}) => MaybePromise<void | App.Error>;

/**
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,
SvelteKitError: control_module_vite.SvelteKitError
});
}
align_exports();
Expand Down
24 changes: 15 additions & 9 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, SvelteKitError } 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_message, 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 SvelteKitError(404, 'Not Found', `Not found: ${url.pathname}`), {
url,
params: {},
route: { id: null }
Expand Down Expand Up @@ -1377,11 +1377,17 @@ export function create_client(app, target) {
console.warn('The next HMR update will cause the page to reload');
}

const message = get_message(error);

return (
app.hooks.handleError({ error, event }) ??
app.hooks.handleError({
error,
event,
status: get_status(error),
message
}) ??
/** @type {any} */ ({
message:
event.route.id === null && error instanceof NotFound ? 'Not Found' : 'Internal Error'
message
})
);
}
Expand Down Expand Up @@ -1908,7 +1914,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.
* `SvelteKitError` goes through `handleError`.
*/
export class SvelteKitError extends Error {
/**
* @param {string} pathname
* @param {number} status
* @param {string} message
* @param {string} [details]
*/
constructor(pathname) {
super();

this.status = 404;
this.message = `Not found: ${pathname}`;
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}

Expand Down Expand Up @@ -66,6 +71,7 @@ export class ActionFailure {
* ActionFailure: typeof ActionFailure;
* HttpError: typeof HttpError;
* Redirect: typeof Redirect;
* SvelteKitError: typeof SvelteKitError;
* }} 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
SvelteKitError = implementations.SvelteKitError; // 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, SvelteKitError, 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 SvelteKitError
? 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
27 changes: 17 additions & 10 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 { json } from '../../../exports/index.js';
import { normalize_error } from '../../../utils/error.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, SvelteKitError } 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 SvelteKitError(
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,11 @@ 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 SvelteKitError(
405,
'Method Not Allowed',
'POST method not allowed. No actions exist for this page'
)
};
}

Expand Down Expand Up @@ -201,7 +204,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 | SvelteKitError | Error}
*/
async function call_action(event, actions) {
const url = new URL(event.request.url);
Expand All @@ -219,12 +222,16 @@ async function call_action(event, actions) {

const action = actions[name];
if (!action) {
throw new Error(`No action with name '${name}' found`);
throw new SvelteKitError(404, 'Not Found', `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 SvelteKitError(
415,
'Unsupported Media Type',
`Form actions expect form-encoded data — received ${event.request.headers.get(
'content-type'
)}`
);
}

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