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

fix: standardise error handling #7185

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
8 changes: 8 additions & 0 deletions .changeset/quiet-squids-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@builder.io/qwik-city': minor
---

fix: server$ functions now correctly throw 4xx errors on the client
fix: server$ errors can be caught by @plugin middleware
refactor: Error types are standardised across server$ functions and routeLoaders
feat: 499 is now a valid status code
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@
}
],
"kind": "Interface",
"content": "```typescript\nexport interface RequestEventCommon<PLATFORM = QwikCityPlatform> extends RequestEventBase<PLATFORM> \n```\n**Extends:** [RequestEventBase](#requesteventbase)<!-- -->&lt;PLATFORM&gt;\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[error](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: ErrorCodes, message: string) =&gt; ErrorResponse\n\n\n</td><td>\n\nWhen called, the response will immediately end with the given status code. This could be useful to end a response with `404`<!-- -->, and use the 404 handler in the routes directory. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used.\n\n\n</td></tr>\n<tr><td>\n\n[exit](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n() =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[html](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: StatusCodes, html: string) =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\nConvenience method to send an HTML body response. The response will be automatically set the `Content-Type` header to`text/html; charset=utf-8`<!-- -->. An `html()` response can only be called once.\n\n\n</td></tr>\n<tr><td>\n\n[json](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: StatusCodes, data: any) =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\nConvenience method to JSON stringify the data and send it in the response. The response will be automatically set the `Content-Type` header to `application/json; charset=utf-8`<!-- -->. A `json()` response can only be called once.\n\n\n</td></tr>\n<tr><td>\n\n[locale](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(local?: string) =&gt; string\n\n\n</td><td>\n\nWhich locale the content is in.\n\nThe locale value can be retrieved from selected methods using `getLocale()`<!-- -->:\n\n\n</td></tr>\n<tr><td>\n\n[redirect](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: RedirectCode, url: string) =&gt; [RedirectMessage](#redirectmessage)\n\n\n</td><td>\n\nURL to redirect to. When called, the response will immediately end with the correct redirect status and headers.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections\n\n\n</td></tr>\n<tr><td>\n\n[send](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\nSendMethod\n\n\n</td><td>\n\nSend a body response. The `Content-Type` response header is not automatically set when using `send()` and must be set manually. A `send()` response can only be called once.\n\n\n</td></tr>\n<tr><td>\n\n[status](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode?: StatusCodes) =&gt; number\n\n\n</td><td>\n\nHTTP response status code. Sets the status code when called with an argument. Always returns the status code, so calling `status()` without an argument will can be used to return the current status code.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Status\n\n\n</td></tr>\n<tr><td>\n\n[text](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: StatusCodes, text: string) =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\nConvenience method to send an text body response. The response will be automatically set the `Content-Type` header to`text/plain; charset=utf-8`<!-- -->. An `text()` response can only be called once.\n\n\n</td></tr>\n</tbody></table>",
"content": "```typescript\nexport interface RequestEventCommon<PLATFORM = QwikCityPlatform> extends RequestEventBase<PLATFORM> \n```\n**Extends:** [RequestEventBase](#requesteventbase)<!-- -->&lt;PLATFORM&gt;\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[error](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n&lt;T = any&gt;(statusCode: ErrorCodes, message: T) =&gt; [ServerError](#servererror)<!-- -->&lt;T&gt;\n\n\n</td><td>\n\nWhen called, the response will immediately end with the given status code. This could be useful to end a response with `404`<!-- -->, and use the 404 handler in the routes directory. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used.\n\n\n</td></tr>\n<tr><td>\n\n[exit](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n() =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[html](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: StatusCodes, html: string) =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\nConvenience method to send an HTML body response. The response will be automatically set the `Content-Type` header to`text/html; charset=utf-8`<!-- -->. An `html()` response can only be called once.\n\n\n</td></tr>\n<tr><td>\n\n[json](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: StatusCodes, data: any) =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\nConvenience method to JSON stringify the data and send it in the response. The response will be automatically set the `Content-Type` header to `application/json; charset=utf-8`<!-- -->. A `json()` response can only be called once.\n\n\n</td></tr>\n<tr><td>\n\n[locale](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(local?: string) =&gt; string\n\n\n</td><td>\n\nWhich locale the content is in.\n\nThe locale value can be retrieved from selected methods using `getLocale()`<!-- -->:\n\n\n</td></tr>\n<tr><td>\n\n[redirect](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: RedirectCode, url: string) =&gt; [RedirectMessage](#redirectmessage)\n\n\n</td><td>\n\nURL to redirect to. When called, the response will immediately end with the correct redirect status and headers.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections\n\n\n</td></tr>\n<tr><td>\n\n[send](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\nSendMethod\n\n\n</td><td>\n\nSend a body response. The `Content-Type` response header is not automatically set when using `send()` and must be set manually. A `send()` response can only be called once.\n\n\n</td></tr>\n<tr><td>\n\n[status](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode?: StatusCodes) =&gt; number\n\n\n</td><td>\n\nHTTP response status code. Sets the status code when called with an argument. Always returns the status code, so calling `status()` without an argument will can be used to return the current status code.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Status\n\n\n</td></tr>\n<tr><td>\n\n[text](#)\n\n\n</td><td>\n\n`readonly`\n\n\n</td><td>\n\n(statusCode: StatusCodes, text: string) =&gt; [AbortMessage](#abortmessage)\n\n\n</td><td>\n\nConvenience method to send an text body response. The response will be automatically set the `Content-Type` header to`text/plain; charset=utf-8`<!-- -->. An `text()` response can only be called once.\n\n\n</td></tr>\n</tbody></table>",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/types.ts",
"mdFile": "qwik-city.requesteventcommon.md"
},
Expand Down Expand Up @@ -411,7 +411,7 @@
}
],
"kind": "Class",
"content": "```typescript\nexport declare class ServerError<T = Record<any, any>> extends Error \n```\n**Extends:** Error\n\n\n<table><thead><tr><th>\n\nConstructor\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[(constructor)(status, data)](#)\n\n\n</td><td>\n\n\n</td><td>\n\nConstructs a new instance of the `ServerError` class\n\n\n</td></tr>\n</tbody></table>\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[data](#servererror-data)\n\n\n</td><td>\n\n\n</td><td>\n\nT\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[status](#servererror-status)\n\n\n</td><td>\n\n\n</td><td>\n\nnumber\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>",
"content": "```typescript\nexport declare class ServerError<T = any> extends Error \n```\n**Extends:** Error\n\n\n<table><thead><tr><th>\n\nConstructor\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[(constructor)(status, data)](#)\n\n\n</td><td>\n\n\n</td><td>\n\nConstructs a new instance of the `ServerError` class\n\n\n</td></tr>\n</tbody></table>\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[data](#servererror-data)\n\n\n</td><td>\n\n\n</td><td>\n\nT\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\n[status](#servererror-status)\n\n\n</td><td>\n\n\n</td><td>\n\nnumber\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/middleware/request-handler/error-handler.ts",
"mdFile": "qwik-city.servererror.md"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1216,7 +1216,7 @@ Description

</td><td>

(statusCode: ErrorCodes, message: string) =&gt; ErrorResponse
&lt;T = any&gt;(statusCode: ErrorCodes, message: T) =&gt; [ServerError](#servererror)&lt;T&gt;

</td><td>

Expand Down Expand Up @@ -1465,7 +1465,7 @@ export interface ResolveValue
## ServerError

```typescript
export declare class ServerError<T = Record<any, any>> extends Error
export declare class ServerError<T = any> extends Error
```

**Extends:** Error
Expand Down
112 changes: 112 additions & 0 deletions packages/docs/src/routes/docs/(qwikcity)/error-handling/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
title: Error handling | Qwik City
contributors:
- DustinJSilk
updated_at: '2025-01-11T18:00:00Z'
created_at: '2025-01-11T18:00:00Z'
---

# Error handling

When an error is thrown in a loader or `server$` function, a 500 error is returned to the client along with the error. This is useful during development but isn't always desirable for production systems. Qwik provides the tools necessary to customise how errors are handled.

Throwing a `ServerError` instance allows you to return custom errors to the browser with a different status code and serialised data.

> Loaders also provide a helper function on the event object to easily create new ServerErrors.

```tsx
// Throw ServerErrors from a routerLoader$
const useProduct = routeLoader$(async (ev) => {
const product = await fetch('api/product/1')

if (!product) {
// Throw a 404 with a custom payload
throw new ServerError(404, 'Product not found')

// Or use the existing helper function
throw ev.error(404, 'Product not found')
}

return product
})

// Throw ServerErrors from a server$
const getPrices = server$(() => {
if (!isAuthenticated()) {
throw new ServerError(401, { code: 401 })
}

return fetch('api/product/1/prices')
})

export default component$(() => {
const product = useProduct()

useVisibleTask(() => {
getPrices()
.then()
.catch(err => {
// The payload from a ServerError is deserialised as the error caught in the client
if (err.code === 401) {
// Navigate to login page
}

// Show generic error
})
})

return <div>Product page</div>
})
```

## Error interceptor

Intercepting errors with middleware has a few usecases: you might want to hide error details in production systems, add structured error logging, or map the error status codes from RPC API calls to HTTP status codes. This is all achieveable with middleware in a `plugin` file.

```tsx
// src/routes/[email protected]
import { type RequestHandler } from '@builder.io/qwik-city'
import { RedirectMessage } from '@builder.io/qwik-city/middleware/request-handler'
import { isDev } from '@builder.io/qwik/build'

export const onRequest: RequestHandler = async ({ next }) => {
try {
return await next();
} catch (err) {
// Pass through 3xx redirects
if (isRedirectMessage(err)) {
throw err
}

// Pass through ServerErrors
if (isServerError(err)) {
throw err
}

// Log unknown errors
console.error('unknown error', err)

if (isDev) {
throw err
} else {
throw new ServerError(500, 'Internal server error');
}
}
};

function isServerError(err: unknown): err is ServerError {
return (
err instanceof ServerError ||
// This is required for dev environments due to an issue with vite.
(isDev && err instanceof Error && err.constructor.name === "ServerError")
);
}

function isRedirectMessage(err: unknown): err is RedirectMessage {
return (
err instanceof RedirectMessage ||
// This is required for dev environments due to an issue with vite.
(isDev && err instanceof Error && err.constructor.name === "RedirectMessage")
);
}
```
1 change: 1 addition & 0 deletions packages/docs/src/routes/docs/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- [Endpoints](</docs/(qwikcity)/endpoints/index.mdx>)
- [Middleware](</docs/(qwikcity)/middleware/index.mdx>)
- [server$](</docs/(qwikcity)/server$/index.mdx>)
- [Error handling](</docs/(qwikcity)/error-handling/index.mdx>)
- [Re-exporting loaders](/docs/(qwikcity)/re-exporting-loaders/index.mdx)
- [Caching](</docs/(qwikcity)/caching/index.mdx>)
- [HTML attributes](</docs/(qwikcity)/html-attributes/index.mdx>)
Expand Down
5 changes: 2 additions & 3 deletions packages/qwik-city/src/middleware/request-handler/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ export interface RequestEventBase<PLATFORM = QwikCityPlatform> {
// @public (undocumented)
export interface RequestEventCommon<PLATFORM = QwikCityPlatform> extends RequestEventBase<PLATFORM> {
// Warning: (ae-forgotten-export) The symbol "ErrorCodes" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "ErrorResponse" needs to be exported by the entry point index.d.ts
readonly error: (statusCode: ErrorCodes, message: string) => ErrorResponse;
readonly error: <T = any>(statusCode: ErrorCodes, message: T) => ServerError<T>;
// (undocumented)
readonly exit: () => AbortMessage;
readonly html: (statusCode: StatusCodes, html: string) => AbortMessage;
Expand Down Expand Up @@ -176,7 +175,7 @@ export interface ResolveValue {
}

// @public (undocumented)
export class ServerError<T = Record<any, any>> extends Error {
export class ServerError<T = any> extends Error {
constructor(status: number, data: T);
// (undocumented)
data: T;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @public */
export class ServerError<T = Record<any, any>> extends Error {
export class ServerError<T = any> extends Error {
constructor(
public status: number,
public data: T
Expand All @@ -8,6 +8,7 @@ export class ServerError<T = Record<any, any>> extends Error {
}
}

/** @deprecated */
export class ErrorResponse extends Error {
DustinJSilk marked this conversation as resolved.
Show resolved Hide resolved
constructor(
public status: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type {
FailReturn,
} from '../../runtime/src/types';
import { Cookie } from './cookie';
import { ErrorResponse } from './error-handler';
import { AbortMessage, RedirectMessage } from './redirect-handler';
import { encoder } from './resolve-request-handlers';
import { createCacheControl } from './cache-control';
Expand All @@ -27,6 +26,7 @@ import type { QwikManifest, ResolvedManifest } from '@builder.io/qwik/optimizer'
import { IsQData, QDATA_JSON, QDATA_JSON_LEN } from './user-response';
import { isPromise } from './../../runtime/src/utils';
import { QDATA_KEY } from '../../runtime/src/constants';
import { ServerError } from './error-handler';

const RequestEvLoaders = Symbol('RequestEvLoaders');
const RequestEvMode = Symbol('RequestEvMode');
Expand Down Expand Up @@ -192,9 +192,9 @@ export function createRequestEvent(
return locale || '';
},

error: (statusCode: number, message: string) => {
error: <T = any>(statusCode: number, message: T) => {
status = statusCode;
return new ErrorResponse(statusCode, message);
return new ServerError(statusCode, message);
},

redirect: (statusCode: number, url: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
RouteModule,
ValidatorReturn,
} from '../../runtime/src/types';
import { ServerError } from './error-handler';
import { HttpStatus } from './http-status-codes';
import { RedirectMessage } from './redirect-handler';
import {
Expand Down Expand Up @@ -305,23 +304,12 @@ async function pureServerFunction(ev: RequestEvent) {
const [qrl, ...args] = data;
if (isQrl(qrl) && qrl.getHash() === fn) {
let result: unknown;
try {
if (isDev) {
result = await measure(ev, `server_${qrl.getSymbol()}`, () =>
(qrl as Function).apply(ev, args)
);
} else {
result = await (qrl as Function).apply(ev, args);
}
} catch (err) {
if (err instanceof ServerError) {
ev.headers.set('Content-Type', 'application/qwik-json');
ev.send(err.status, await qwikSerializer._serializeData(err.data, true));
return;
}
ev.headers.set('Content-Type', 'application/qwik-json');
ev.send(500, await qwikSerializer._serializeData(err, true));
return;
if (isDev) {
result = await measure(ev, `server_${qrl.getSymbol()}`, () =>
(qrl as Function).apply(ev, args)
);
} else {
result = await (qrl as Function).apply(ev, args);
}
if (isAsyncIterator(result)) {
ev.headers.set('Content-Type', 'text/qwik-json-stream');
Expand Down
7 changes: 4 additions & 3 deletions packages/qwik-city/src/middleware/request-handler/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Render, RenderOptions } from '@builder.io/qwik/server';
import type { QwikCityPlan, FailReturn, Action, Loader } from '@builder.io/qwik-city';
import type { ErrorResponse } from './error-handler';
import type { AbortMessage, RedirectMessage } from './redirect-handler';
import type { RequestEventInternal } from './request-event';
import type { _deserializeData, _serializeData, _verifySerializable } from '@builder.io/qwik';
import type { ServerError } from './error-handler';

/** @public */
export interface EnvGetter {
Expand Down Expand Up @@ -154,7 +154,8 @@ export type ClientErrorCode =
| 428 // Precondition Required
| 429 // Too Many Requests
| 431 // Request Header Fields Too Large
| 451; // Unavailable For Legal Reasons
| 451 // Unavailable For Legal Reasons
| 499; // Client closed request

/**
* HTTP Server Error Status Codes Status codes in the 5xx range indicate that the server encountered
Expand Down Expand Up @@ -205,7 +206,7 @@ export interface RequestEventCommon<PLATFORM = QwikCityPlatform>
* to end a response with `404`, and use the 404 handler in the routes directory. See
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used.
*/
readonly error: (statusCode: ErrorCodes, message: string) => ErrorResponse;
readonly error: <T = any>(statusCode: ErrorCodes, message: T) => ServerError<T>;

/**
* Convenience method to send an text body response. The response will be automatically set the
Expand Down
Loading
Loading