Skip to content

ref(sveltekit): Improve SvelteKit 2.0 Not Found server error handling #9901

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

Merged
merged 5 commits into from
Dec 19, 2023
Merged
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
6 changes: 3 additions & 3 deletions packages/sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@
"sorcery": "0.11.0"
},
"devDependencies": {
"@sveltejs/kit": "^1.11.0",
"@sveltejs/kit": "^2.0.2",
"rollup": "^3.20.2",
"svelte": "^3.44.0",
"vite": "4.0.5"
"svelte": "^4.2.8",
"vite": "^5.0.10"
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
5 changes: 4 additions & 1 deletion packages/sveltekit/src/client/handleError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ function defaultErrorHandler({ error }: Parameters<HandleClientError>[0]): Retur
});
}

// TODO: add backwards-compatible type for kit 1.x (soon)
type HandleClientErrorInput = Parameters<HandleClientError>[0];
Comment on lines +14 to +15
Copy link
Member Author

Choose a reason for hiding this comment

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

Will add this in a follow-up PR where I'll add filtering out "Not Found" errors on the client side


/**
* Wrapper for the SvelteKit error handler that sends the error to Sentry.
*
* @param handleError The original SvelteKit error handler.
*/
export function handleErrorWithSentry(handleError: HandleClientError = defaultErrorHandler): HandleClientError {
return (input: { error: unknown; event: NavigationEvent }): ReturnType<HandleClientError> => {
return (input: HandleClientErrorInput): ReturnType<HandleClientError> => {
captureException(input.error, {
mechanism: {
type: 'sveltekit',
Expand Down
35 changes: 28 additions & 7 deletions packages/sveltekit/src/server/handleError.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { captureException } from '@sentry/node';
import type { HandleServerError, RequestEvent } from '@sveltejs/kit';
import type { HandleServerError } from '@sveltejs/kit';

import { flushIfServerless } from './utils';

Expand All @@ -11,14 +11,28 @@ function defaultErrorHandler({ error }: Parameters<HandleServerError>[0]): Retur
console.error(error && error.stack);
}

type HandleServerErrorInput = Parameters<HandleServerError>[0];

/**
* Backwards-compatible HandleServerError Input type for SvelteKit 1.x and 2.x
* `message` and `status` were added in 2.x.
* For backwards-compatibility, we make them optional
*
* @see https://kit.svelte.dev/docs/migrating-to-sveltekit-2#improved-error-handling
*/
type SafeHandleServerErrorInput = Omit<HandleServerErrorInput, 'status' | 'message'> &
Partial<Pick<HandleServerErrorInput, 'status' | 'message'>>;

/**
* Wrapper for the SvelteKit error handler that sends the error to Sentry.
*
* @param handleError The original SvelteKit error handler.
*/
export function handleErrorWithSentry(handleError: HandleServerError = defaultErrorHandler): HandleServerError {
return async (input: { error: unknown; event: RequestEvent }): Promise<void | App.Error> => {
return async (input: SafeHandleServerErrorInput): Promise<void | App.Error> => {
if (isNotFoundError(input)) {
// We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput
// @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type
return handleError(input);
}

Expand All @@ -31,19 +45,26 @@ export function handleErrorWithSentry(handleError: HandleServerError = defaultEr

await flushIfServerless();

// We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput
// @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type
return handleError(input);
};
}

/**
* When a page request fails because the page is not found, SvelteKit throws a "Not found" error.
* In the error handler here, we can't access the response yet (which we do in the load instrumentation),
* so we have to check if the error is a "Not found" error by checking if the route id is missing and
* by checking the error message on top of the raw stack trace.
*/
function isNotFoundError(input: { error: unknown; event: RequestEvent }): boolean {
const { error, event } = input;
function isNotFoundError(input: SafeHandleServerErrorInput): boolean {
const { error, event, status } = input;

// SvelteKit 2.0 offers a reliable way to check for a Not Found error:
if (status === 404) {
return true;
}

// SvelteKit 1.x doesn't offer a reliable way to check for a Not Found error.
// So we check the route id (shouldn't exist) and the raw stack trace
// We can delete all of this below whenever we drop Kit 1.x support
const hasNoRouteId = !event.route || !event.route.id;

const rawStack: string =
Expand Down
2 changes: 1 addition & 1 deletion packages/sveltekit/test/common/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('isHttpError', () => {
expect(isHttpError(httpErrorObject)).toBe(true);
});

it.each([new Error(), redirect(301, '/users/id'), 'string error', { status: 404 }, { body: 'Not found' }])(
it.each([new Error(), { status: 301, message: '/users/id' }, 'string error', { status: 404 }, { body: 'Not found' }])(
Copy link
Member Author

Choose a reason for hiding this comment

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

calling redirect throws since Sveltekit 2.0, so I rewrote this test to what redirect used to return in kit 1.x (this is not breaking or relevant for kit 2.0)

'returns `false` for other thrown objects (%s)',
httpErrorObject => {
expect(isHttpError(httpErrorObject)).toBe(false);
Expand Down
32 changes: 29 additions & 3 deletions packages/sveltekit/test/server/handleError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('handleError', () => {
consoleErrorSpy.mockClear();
});

it('doesn\'t capture "Not found" errors for incorrect navigations', async () => {
it('doesn\'t capture "Not found" errors for incorrect navigations [Kit 1.x]', async () => {
const wrappedHandleError = handleErrorWithSentry();
const mockError = new Error('Not found: /asdf/123');
const mockEvent = {
Expand All @@ -35,18 +35,39 @@ describe('handleError', () => {
// ...
} as RequestEvent;

// @ts-expect-error - purposefully omitting status and message to cover SvelteKit 1.x compatibility
const returnVal = await wrappedHandleError({ error: mockError, event: mockEvent });

expect(returnVal).not.toBeDefined();
expect(mockCaptureException).toHaveBeenCalledTimes(0);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});

it('doesn\'t capture "Not found" errors for incorrect navigations [Kit 2.x]', async () => {
const wrappedHandleError = handleErrorWithSentry();

const returnVal = await wrappedHandleError({
error: new Error('404 /asdf/123'),
event: requestEvent,
status: 404,
message: 'Not Found',
});

expect(returnVal).not.toBeDefined();
expect(mockCaptureException).toHaveBeenCalledTimes(0);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});

describe('calls captureException', () => {
it('invokes the default handler if no handleError func is provided', async () => {
const wrappedHandleError = handleErrorWithSentry();
const mockError = new Error('test');
const returnVal = await wrappedHandleError({ error: mockError, event: requestEvent });
const returnVal = await wrappedHandleError({
error: mockError,
event: requestEvent,
status: 500,
message: 'Internal Error',
});

expect(returnVal).not.toBeDefined();
expect(mockCaptureException).toHaveBeenCalledTimes(1);
Expand All @@ -58,7 +79,12 @@ describe('handleError', () => {
it('invokes the user-provided error handler', async () => {
const wrappedHandleError = handleErrorWithSentry(handleError);
const mockError = new Error('test');
const returnVal = (await wrappedHandleError({ error: mockError, event: requestEvent })) as any;
const returnVal = (await wrappedHandleError({
error: mockError,
event: requestEvent,
status: 500,
message: 'Internal Error',
})) as any;

expect(returnVal.message).toEqual('Whoops!');
expect(mockCaptureException).toHaveBeenCalledTimes(1);
Expand Down
Loading