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 = Record&lt;any, any&gt;&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
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 = Record&lt;any, any&gt;&gt;(statusCode: ErrorCodes, message: T) =&gt; [ServerError](#servererror)&lt;T&gt;

</td><td>

Expand Down
111 changes: 111 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,111 @@
---
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, { message: 'Product not found' })

// Or use the existing helper function
throw ev.error(404, { message: '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 => {
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
3 changes: 1 addition & 2 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 = Record<any, any>>(statusCode: ErrorCodes, message: T) => ServerError<T>;
// (undocumented)
readonly exit: () => AbortMessage;
readonly html: (statusCode: StatusCodes, html: string) => AbortMessage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,6 @@ export class ServerError<T = Record<any, any>> extends Error {
}
}

export class ErrorResponse extends Error {
DustinJSilk marked this conversation as resolved.
Show resolved Hide resolved
constructor(
public status: number,
message?: string
) {
super(message);
}
}

/** @public */
export function getErrorHtml(status: number, e: any) {
let message = 'Server Error';
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 = Record<any, 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 = Record<any, any>>(statusCode: ErrorCodes, message: T) => ServerError<T>;

/**
* Convenience method to send an text body response. The response will be automatically set the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { QwikSerializer, ServerRequestEvent, StatusCodes } from './types';
import type { RequestEvent, RequestHandler } from '@builder.io/qwik-city';
import { createRequestEvent, getRequestMode, type RequestEventInternal } from './request-event';
import { ErrorResponse, getErrorHtml, minimalHtmlResponse } from './error-handler';
import {
RequestEvQwikSerializer,
createRequestEvent,
getRequestMode,
type RequestEventInternal,
} from './request-event';
import { ServerError, minimalHtmlResponse } from './error-handler';
import { AbortMessage, RedirectMessage } from './redirect-handler';
import type { LoadedRoute } from '../../runtime/src/types';
import { encoder } from './resolve-request-handlers';
Expand Down Expand Up @@ -65,12 +70,17 @@ async function runNext(requestEv: RequestEventInternal, resolve: (value: any) =>
if (e instanceof RedirectMessage) {
const stream = requestEv.getWritableStream();
await stream.close();
} else if (e instanceof ErrorResponse) {
console.error(e);
DustinJSilk marked this conversation as resolved.
Show resolved Hide resolved
} else if (e instanceof ServerError) {
if (!requestEv.headersSent) {
const html = getErrorHtml(e.status, e);
const status = e.status as StatusCodes;
requestEv.html(status, html);
if (typeof e.data === 'object') {
const qwikSerializer = requestEv[RequestEvQwikSerializer];
requestEv.headers.set('Content-Type', 'application/qwik-json');
requestEv.send(status, await qwikSerializer._serializeData(e.data, true));
} else {
requestEv.headers.set('Content-Type', 'text/plain; charset=utf-8');
requestEv.send(status, e.data);
}
}
} else if (!(e instanceof AbortMessage)) {
if (getRequestMode(requestEv) !== 'dev') {
Expand Down
6 changes: 3 additions & 3 deletions packages/qwik-city/src/runtime/src/server-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,19 +479,19 @@ export const serverQrl = <T extends ServerFunction>(
} else if (contentType === 'application/qwik-json') {
const str = await res.text();
const obj = await _deserializeData(str, ctxElm ?? document.documentElement);
if (res.status >= 500) {
if (res.status >= 400) {
throw obj;
}
return obj;
} else if (contentType === 'application/json') {
const obj = await res.json();
if (res.status >= 500) {
if (res.status >= 400) {
throw obj;
}
return obj;
} else if (contentType === 'text/plain' || contentType === 'text/html') {
const str = await res.text();
if (res.status >= 500) {
if (res.status >= 400) {
throw str;
}
return str;
Expand Down
Loading
Loading