diff --git a/.changeset/cyan-walls-leave.md b/.changeset/cyan-walls-leave.md new file mode 100644 index 000000000000..dc01035227d5 --- /dev/null +++ b/.changeset/cyan-walls-leave.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow error boundaries to catch errors on the server diff --git a/documentation/docs/30-advanced/25-errors.md b/documentation/docs/30-advanced/25-errors.md index 53d86b1ef802..e5ef14752c81 100644 --- a/documentation/docs/30-advanced/25-errors.md +++ b/documentation/docs/30-advanced/25-errors.md @@ -100,6 +100,54 @@ By default, unexpected errors are printed to the console (or, in production, you 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 which becomes `page.error`. +## Rendering errors + +Ordinarily, if an error happens during server-side rendering (for example inside a component's ` + +

{error.message}

+``` + +The same applies for other error boundaries you define in your code: + +```svelte + + ... + {#snippet failed(error)} + + {error.message} + {/snippet} + +``` + ## Responses If an error occurs inside `handle` or inside a [`+server.js`](routing#server) request handler, SvelteKit will respond with either a fallback error page or a JSON representation of the error object, depending on the request's `Accept` headers. diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 14ac3361701b..6b0e9808affc 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -81,7 +81,8 @@ const get_defaults = (prefix = '') => ({ tracing: { server: false }, instrumentation: { server: false }, remoteFunctions: false, - forkPreloads: false + forkPreloads: false, + handleRenderingErrors: false }, files: { src: join(prefix, 'src'), diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index d16ba4253582..5734e6abea78 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -133,7 +133,8 @@ const options = object( server: boolean(false) }), remoteFunctions: boolean(false), - forkPreloads: boolean(false) + forkPreloads: boolean(false), + handleRenderingErrors: boolean(false) }), files: object({ diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js index d8643e989128..d9680e8ab65f 100644 --- a/packages/kit/src/core/sync/sync.js +++ b/packages/kit/src/core/sync/sync.js @@ -33,7 +33,7 @@ export function create(config) { write_client_manifest(config.kit, manifest_data, `${output}/client`); write_server(config, output); - write_root(manifest_data, output); + write_root(manifest_data, config, output); write_all_types(config, manifest_data); write_non_ambient(config.kit, manifest_data); diff --git a/packages/kit/src/core/sync/write_root.js b/packages/kit/src/core/sync/write_root.js index 5866b3d8e783..445c17e45dbc 100644 --- a/packages/kit/src/core/sync/write_root.js +++ b/packages/kit/src/core/sync/write_root.js @@ -2,11 +2,14 @@ import { dedent, isSvelte5Plus, write_if_changed } from './utils.js'; /** * @param {import('types').ManifestData} manifest_data + * @param {import('types').ValidatedConfig} config * @param {string} output */ -export function write_root(manifest_data, output) { +export function write_root(manifest_data, config, output) { // TODO remove default layout altogether + const use_boundaries = config.kit.experimental.handleRenderingErrors && isSvelte5Plus(); + const max_depth = Math.max( ...manifest_data.routes.map((route) => route.page ? route.page.layouts.filter(Boolean).length + 1 : 0 @@ -20,8 +23,38 @@ export function write_root(manifest_data, output) { } let l = max_depth; + /** @type {string} */ + let pyramid; - let pyramid = dedent` + if (isSvelte5Plus() && use_boundaries) { + // with the @const we force the data[depth] access to be derived, which is important to not fire updates needlessly + // TODO in Svelte 5 we should rethink the client.js side, we can likely make data a $state and only update indexes that changed there, simplifying this a lot + pyramid = dedent` + {#snippet pyramid(depth)} + {@const Pyramid = constructors[depth]} + {#snippet failed(error)} + {@const ErrorPage = errors[depth]} + + {/snippet} + + {#if constructors[depth + 1]} + {@const d = data[depth]} + + + {@render pyramid(depth + 1)} + + {:else} + {@const d = data[depth]} + + + {/if} + + {/snippet} + + {@render pyramid(0)} + `; + } else { + pyramid = dedent` ${ isSvelte5Plus() ? ` @@ -29,8 +62,8 @@ export function write_root(manifest_data, output) { : `` }`; - while (l--) { - pyramid = dedent` + while (l--) { + pyramid = dedent` {#if constructors[${l + 1}]} ${ isSvelte5Plus() @@ -57,6 +90,7 @@ export function write_root(manifest_data, output) { {/if} `; + } } write_if_changed( @@ -72,9 +106,10 @@ export function write_root(manifest_data, output) { ${ isSvelte5Plus() ? dedent` - let { stores, page, constructors, components = [], form, ${levels + let { stores, page, constructors, components = [], form, ${use_boundaries ? 'errors = [], error, ' : ''}${levels .map((l) => `data_${l} = null`) .join(', ')} } = $props(); + ${use_boundaries ? `let data = $derived({${levels.map((l) => `'${l}': data_${l}`).join(', ')}})` : ''} ` : dedent` export let stores; @@ -108,7 +143,7 @@ export function write_root(manifest_data, output) { isSvelte5Plus() ? dedent` $effect(() => { - stores;page;constructors;components;form;${levels.map((l) => `data_${l}`).join(';')}; + stores;page;constructors;components;form;${use_boundaries ? 'errors;error;' : ''}${levels.map((l) => `data_${l}`).join(';')}; stores.page.notify(); }); ` diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index ea7d9dfe0c99..711a007345e0 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -50,6 +50,7 @@ export const options = { root, service_worker: ${has_service_worker}, service_worker_options: ${config.kit.serviceWorker.register ? s(config.kit.serviceWorker.options) : 'null'}, + server_error_boundaries: ${s(!!config.kit.experimental.handleRenderingErrors)}, templates: { app: ({ head, body, assets, nonce, env }) => ${s(template) .replace('%sveltekit.head%', '" + head + "') diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index b620044814d5..89383e69fea4 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -513,6 +513,15 @@ export interface KitConfig { * @default false */ forkPreloads?: boolean; + + /** + * Whether to enable the experimental handling of rendering errors. + * When enabled, `` is used to wrap components at each level + * where there's an `+error.svelte`, rendering the error page if the component fails. + * In addition, error boundaries also work on the server and the error object goes through `handleError`. + * @default false + */ + handleRenderingErrors?: boolean; }; /** * Where to find various files within your project. diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 72db04111e56..84a435740be1 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -367,7 +367,8 @@ async function kit({ svelte_config }) { __SVELTEKIT_PATHS_RELATIVE__: s(kit.paths.relative), __SVELTEKIT_CLIENT_ROUTING__: s(kit.router.resolution === 'client'), __SVELTEKIT_HASH_ROUTING__: s(kit.router.type === 'hash'), - __SVELTEKIT_SERVER_TRACING_ENABLED__: s(kit.experimental.tracing.server) + __SVELTEKIT_SERVER_TRACING_ENABLED__: s(kit.experimental.tracing.server), + __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__: s(kit.experimental.handleRenderingErrors) }; if (is_build) { diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 0faada3d4b01..d6ece5753984 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -56,6 +56,14 @@ export { load_css }; const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']); let errored = false; +/** + * Set via transformError, reset and read at the end of navigate. + * Necessary because a navigation might succeed loading but during rendering + * an error occurs, at which point the navigation result needs to be overridden with the error result. + * TODO this is all very hacky, rethink for SvelteKit 3 where we can assume Svelte 5 and do an overhaul of client.js + * @type {{ error: App.Error, status: number } | null} + */ +let rendering_error = null; // We track the scroll position associated with each history entry in sessionStorage, // rather than on history.state itself, because when navigation is driven by @@ -238,12 +246,14 @@ const on_navigate_callbacks = new Set(); /** @type {Set<(navigation: import('@sveltejs/kit').AfterNavigate) => void>} */ const after_navigate_callbacks = new Set(); -/** @type {import('./types.js').NavigationState} */ +/** @type {import('./types.js').NavigationState & { nav: import('@sveltejs/kit').NavigationEvent }} */ let current = { branch: [], error: null, // @ts-ignore - we need the initial value to be null - url: null + url: null, + // @ts-ignore - we need the initial value to be null + nav: null }; /** this being true means we SSR'd */ @@ -415,7 +425,7 @@ async function _invalidate(include_load_functions = true, reset_page_state = tru navigation_result.props.page.state = prev_state; } update(navigation_result.props.page); - current = navigation_result.state; + current = { ...navigation_result.state, nav: current.nav }; reset_invalidation(); root.$set(navigation_result.props); } else { @@ -581,7 +591,17 @@ async function _preload_code(url) { async function initialize(result, target, hydrate) { if (DEV && result.state.error && document.querySelector('vite-error-overlay')) return; - current = result.state; + /** @type {import('@sveltejs/kit').NavigationEvent} */ + const nav = { + params: current.params, + route: { id: current.route?.id ?? null }, + url: new URL(location.href) + }; + + current = { + ...result.state, + nav + }; const style = document.querySelector('style[data-sveltekit]'); if (style) style.remove(); @@ -593,7 +613,17 @@ async function initialize(result, target, hydrate) { props: { ...result.props, stores, components }, hydrate, // @ts-ignore Svelte 5 specific: asynchronously instantiate the component, i.e. don't call flushSync - sync: false + sync: false, + // @ts-ignore Svelte 5 specific: transformError allows to transform errors before they are passed to boundaries + transformError: __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__ + ? /** @param {unknown} e */ async (e) => { + const error = await handle_error(e, current.nav); + rendering_error = { error, status: get_status(e) }; + page.error = error; + page.status = rendering_error.status; + return error; + } + : undefined }); // Wait for a microtask in case svelte experimental async is enabled, @@ -607,9 +637,7 @@ async function initialize(result, target, hydrate) { const navigation = { from: null, to: { - params: current.params, - route: { id: current.route?.id ?? null }, - url: new URL(location.href), + ...nav, scroll: scroll_positions[current_history_index] ?? scroll_state() }, willUnload: false, @@ -629,13 +657,23 @@ async function initialize(result, target, hydrate) { * url: URL; * params: Record; * branch: Array; + * errors?: Array; * status: number; * error: App.Error | null; * route: import('types').CSRRoute | null; * form?: Record | null; * }} opts */ -function get_navigation_result_from_branch({ url, params, branch, status, error, route, form }) { +async function get_navigation_result_from_branch({ + url, + params, + branch, + errors, + status, + error, + route, + form +}) { /** @type {import('types').TrailingSlash} */ let slash = 'never'; @@ -670,6 +708,32 @@ function get_navigation_result_from_branch({ url, params, branch, status, error, } }; + if (errors && __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__) { + let last_idx = -1; + result.props.errors = await Promise.all( + // eslint-disable-next-line @typescript-eslint/await-thenable + branch + .map((b, i) => { + if (i === 0) return undefined; // root layout wraps root error component, not the other way around + if (!b) return null; + + i--; + // Find the closest error component up to the previous branch + while (i > last_idx + 1 && !errors[i]) i -= 1; + last_idx = i; + return errors[i]?.() + .then((e) => e.component) + .catch(() => undefined); + }) + // filter out indexes where there was no branch, but keep indexes where there was a branch but no error component + .filter((e) => e !== null) + ); + } + + if (error && __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__) { + result.props.error = error; + } + if (form !== undefined) { result.props.form = form; } @@ -1201,6 +1265,7 @@ async function load_route({ id, invalidating, url, params, route, preload }) { url, params, branch: branch.slice(0, error_load.idx).concat(error_load.node), + errors, status, error, route @@ -1220,6 +1285,7 @@ async function load_route({ id, invalidating, url, params, route, preload }) { url, params, branch, + errors, status: 200, error: null, route, @@ -1325,6 +1391,7 @@ async function load_root_error_page({ status, error, url, route }) { branch: [root_layout, root_error], status, error, + errors: [], route: null }); } catch (error) { @@ -1732,7 +1799,16 @@ async function navigate({ }); } - current = navigation_result.state; + // Type-casts are save because we know this resolved a proper SvelteKit route + const target = /** @type {import('@sveltejs/kit').NavigationTarget} */ (nav.navigation.to); + current = { + ...navigation_result.state, + nav: { + params: /** @type {Record} */ (target.params), + route: target.route, + url: target.url + } + }; // reset url before updating page store if (navigation_result.props.page) { @@ -1744,7 +1820,12 @@ async function navigate({ if (fork) { commit_promise = fork.commit(); } else { + rendering_error = null; // TODO this can break with forks, rethink for SvelteKit 3 where we can assume Svelte 5 root.$set(navigation_result.props); + // Check for sync rendering error + if (rendering_error) { + Object.assign(navigation_result.props.page, rendering_error); + } update(navigation_result.props.page); commit_promise = svelte.settled?.(); @@ -1800,6 +1881,10 @@ async function navigate({ autoscroll = true; if (navigation_result.props.page) { + // Check for async rendering error + if (rendering_error) { + Object.assign(navigation_result.props.page, rendering_error); + } Object.assign(page, navigation_result.props.page); } @@ -2401,16 +2486,17 @@ export async function set_nearest_error_page(error, status = 500) { const error_load = await load_nearest_error_page(current.branch.length, branch, route.errors); if (error_load) { - const navigation_result = get_navigation_result_from_branch({ + const navigation_result = await get_navigation_result_from_branch({ url, params: current.params, branch: branch.slice(0, error_load.idx).concat(error_load.node), status, error, + // do not set errors, we haven't changed the page so the previous ones are still current route }); - current = navigation_result.state; + current = { ...navigation_result.state, nav: current.nav }; root.$set(navigation_result.props); update(navigation_result.props.page); @@ -2829,12 +2915,13 @@ async function _hydrate( } } - result = get_navigation_result_from_branch({ + result = await get_navigation_result_from_branch({ url, params, branch, status, error, + errors: parsed_route?.errors, // TODO load earlier? form, route: parsed_route ?? null }); diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 927b5b7a1041..f91124f962c7 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -85,9 +85,11 @@ export type NavigationFinished = { state: NavigationState; props: { constructors: Array; + errors?: Array; components?: SvelteComponent[]; page: Page; form?: Record | null; + error?: App.Error; [key: `data_${number}`]: Record; }; }; diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 33afd6ce0ba1..f5bb852a68aa 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -285,6 +285,11 @@ export async function render_page( const layouts = compact(branch.slice(0, j + 1)); const nodes = new PageNodes(layouts.map((layout) => layout.node)); + const error_branch = layouts.concat({ + node, + data: null, + server_data: null + }); return await render_response({ event, @@ -299,11 +304,14 @@ export async function render_page( }, status, error, - branch: layouts.concat({ - node, - data: null, - server_data: null - }), + error_components: await load_error_components( + options, + ssr, + error_branch, + page, + manifest + ), + branch: error_branch, fetched, data_serializer }); @@ -350,11 +358,11 @@ export async function render_page( }, status, error: null, - branch: ssr === false ? [] : compact(branch), + branch: !ssr ? [] : compact(branch), action_result, fetched, - data_serializer: - ssr === false ? server_data_serializer(event, event_state, options) : data_serializer + data_serializer: !ssr ? server_data_serializer(event, event_state, options) : data_serializer, + error_components: await load_error_components(options, ssr, branch, page, manifest) }); } catch (e) { // a remote function could have thrown a redirect during render @@ -376,3 +384,44 @@ export async function render_page( }); } } + +/** + * + * @param {import('types').SSROptions} options + * @param {boolean} ssr + * @param {Array} branch + * @param {import('types').PageNodeIndexes} page + * @param {import('@sveltejs/kit').SSRManifest} manifest + */ +async function load_error_components(options, ssr, branch, page, manifest) { + /** @type {Array | undefined} */ + let error_components; + + if (options.server_error_boundaries && ssr) { + let last_idx = -1; + error_components = await Promise.all( + // eslint-disable-next-line @typescript-eslint/await-thenable + branch + .map((b, i) => { + if (i === 0) return undefined; // root layout wraps root error component, not the other way around + if (!b) return null; + + i--; + // Find the closest error component up to the previous branch + while (i > last_idx + 1 && page.errors[i] === undefined) i -= 1; + last_idx = i; + + const idx = page.errors[i]; + if (idx == null) return undefined; + + return manifest._.nodes[idx]?.() + .then((e) => e.component?.()) + .catch(() => undefined); + }) + // filter out indexes where there was no branch, but keep indexes where there was a branch but no error component + .filter((e) => e !== null) + ); + } + + return error_components; +} diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 7648a757e02c..7fe7ad1506fe 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -15,8 +15,9 @@ import { create_server_routing_response, generate_route_object } from './server_ import { add_resolution_suffix } from '../../pathname.js'; import { try_get_request_store, with_request_store } from '@sveltejs/kit/internal/server'; import { text_encoder } from '../../utils.js'; -import { get_global_name } from '../utils.js'; +import { get_global_name, handle_error_and_jsonify } from '../utils.js'; import { create_remote_key } from '../../shared.js'; +import { get_status } from '../../../utils/error.js'; // TODO rename this function/module @@ -40,7 +41,8 @@ const updated = { * event_state: import('types').RequestState; * resolve_opts: import('types').RequiredResolveOptions; * action_result?: import('@sveltejs/kit').ActionResult; - * data_serializer: import('./types.js').ServerDataSerializer + * data_serializer: import('./types.js').ServerDataSerializer; + * error_components?: Array * }} opts */ export async function render_response({ @@ -56,7 +58,8 @@ export async function render_response({ event_state, resolve_opts, action_result, - data_serializer + data_serializer, + error_components }) { if (state.prerendering) { if (options.csp.mode === 'nonce') { @@ -147,6 +150,13 @@ export async function render_response({ form: form_value }; + if (error_components) { + if (error) { + props.error = error; + } + props.errors = error_components; + } + let data = {}; // props_n (instead of props[n]) makes it easy to avoid @@ -176,7 +186,15 @@ export async function render_response({ } ] ]), - csp: csp.script_needs_nonce ? { nonce: csp.nonce } : { hash: csp.script_needs_hash } + csp: csp.script_needs_nonce ? { nonce: csp.nonce } : { hash: csp.script_needs_hash }, + transformError: error_components + ? /** @param {unknown} e */ async (e) => { + const transformed = await handle_error_and_jsonify(event, event_state, options, e); + props.page.error = props.error = error = transformed; + props.page.status = status = get_status(e); + return transformed; + } + : undefined }; const fetch = globalThis.fetch; diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index 0767e177d124..71717e209c4b 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -101,6 +101,7 @@ export async function respond_with_error({ status, error: await handle_error_and_jsonify(event, event_state, options, error), branch, + error_components: [], fetched, event, event_state, diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts index 94a63ea2e010..c08d43b3de64 100644 --- a/packages/kit/src/types/global-private.d.ts +++ b/packages/kit/src/types/global-private.d.ts @@ -57,6 +57,7 @@ declare global { * to throw an error if the feature would fail in production. */ var __SVELTEKIT_TRACK__: (label: string) => void; + var __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__: boolean; var Bun: object; var Deno: object; } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 0be5b25d7655..e1dbedb6e910 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -468,6 +468,7 @@ export interface SSROptions { root: SSRComponent['default']; service_worker: boolean; service_worker_options: RegistrationOptions; + server_error_boundaries: boolean; templates: { app(values: { head: string; diff --git a/packages/kit/test/apps/async/src/hooks.client.js b/packages/kit/test/apps/async/src/hooks.client.js new file mode 100644 index 000000000000..af343e2d1022 --- /dev/null +++ b/packages/kit/test/apps/async/src/hooks.client.js @@ -0,0 +1,13 @@ +import { isRedirect } from '@sveltejs/kit'; + +/** @type {import('@sveltejs/kit').HandleClientError} */ +export const handleError = ({ error: e, event, status, message }) => { + // helps us catch sveltekit redirects thrown in component code + if (isRedirect(e)) { + throw new Error("Redirects shouldn't trigger the handleError hook"); + } + + const error = /** @type {Error} */ (e); + + return { message: `${error.message} (${status} ${message}, on ${event.url.pathname})` }; +}; diff --git a/packages/kit/test/apps/async/src/hooks.server.js b/packages/kit/test/apps/async/src/hooks.server.js index 595b33bf3e8a..4a1d3f5e8ffd 100644 --- a/packages/kit/test/apps/async/src/hooks.server.js +++ b/packages/kit/test/apps/async/src/hooks.server.js @@ -25,7 +25,7 @@ export const handleValidationError = ({ issues }) => { }; /** @type {import('@sveltejs/kit').HandleServerError} */ -export const handleError = ({ error: e, status, message }) => { +export const handleError = ({ error: e, event, status, message }) => { // helps us catch sveltekit redirects thrown in component code if (isRedirect(e)) { throw new Error("Redirects shouldn't trigger the handleError hook"); @@ -33,7 +33,7 @@ export const handleError = ({ error: e, status, message }) => { const error = /** @type {Error} */ (e); - return { message: `${error.message} (${status} ${message})` }; + return { message: `${error.message} (${status} ${message}, on ${event.url.pathname})` }; }; // @ts-ignore this doesn't exist in old Node TODO remove SvelteKit 3 (same in test-basics) diff --git a/packages/kit/test/apps/async/src/routes/+error.svelte b/packages/kit/test/apps/async/src/routes/+error.svelte index 61ad908c0b25..31f3cb324e47 100644 --- a/packages/kit/test/apps/async/src/routes/+error.svelte +++ b/packages/kit/test/apps/async/src/routes/+error.svelte @@ -1,14 +1,16 @@ - Custom error page: {page.error.message} + Custom error page: {error.message}

{page.status}

-

This is your custom error page saying: "{page.error.message}"

+

This is your custom error page saying: "{error.message}"