feat: add better error handling (#605)
* feat: add better error handling * refactor: update after review * refactor: remove unnecessary code * refactor: better path structure * refactor: fetch-wrapper after code review Co-authored-by: Mihai Colceriu <colceriumi@gmail.com>pull/617/head
parent
70f595ec08
commit
903f7729c7
|
@ -0,0 +1,72 @@
|
|||
import React from "react";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
type Props = {
|
||||
statusCode?: number | null;
|
||||
error?: Error | HttpError | null;
|
||||
message?: string;
|
||||
/** Display debugging information */
|
||||
displayDebug?: boolean;
|
||||
children?: never;
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
displayDebug: false,
|
||||
};
|
||||
|
||||
const ErrorDebugPanel: React.FC<{ error: Props["error"]; children?: never }> = (props) => {
|
||||
const { error: e } = props;
|
||||
|
||||
const debugMap = [
|
||||
["error.message", e?.message],
|
||||
["error.name", e?.name],
|
||||
["error.class", e instanceof Error ? e.constructor.name : undefined],
|
||||
["http.url", e instanceof HttpError ? e.url : undefined],
|
||||
["http.status", e instanceof HttpError ? e.statusCode : undefined],
|
||||
["http.cause", e instanceof HttpError ? e.cause?.message : undefined],
|
||||
["error.stack", e instanceof Error ? e.stack : undefined],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||
<dl className="sm:divide-y sm:divide-gray-200">
|
||||
{debugMap.map(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
return (
|
||||
<div key={key} className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt className="text-sm font-bold text-black">{key}</dt>
|
||||
<dd className="mt-1 text-sm text-black sm:mt-0 sm:col-span-2">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorPage: React.FC<Props> = (props) => {
|
||||
const { message, statusCode, error, displayDebug } = { ...defaultProps, ...props };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white min-h-screen px-4">
|
||||
<main className="max-w-xl mx-auto pb-6 pt-16 sm:pt-24">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold text-black uppercase tracking-wide">{statusCode}</p>
|
||||
<h1 className="mt-2 text-4xl font-extrabold text-gray-900 tracking-tight sm:text-5xl">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
</main>
|
||||
{displayDebug && (
|
||||
<div className="flex-wrap">
|
||||
<ErrorDebugPanel error={error} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,33 @@
|
|||
export class HttpError<TCode extends number = number> extends Error {
|
||||
public readonly cause: unknown;
|
||||
public readonly statusCode: TCode;
|
||||
public readonly message: string;
|
||||
public readonly url: string | undefined;
|
||||
public readonly method: string | undefined;
|
||||
|
||||
constructor(opts: { url?: string; method?: string; message?: string; statusCode: TCode; cause?: unknown }) {
|
||||
super(opts.message ?? `HTTP Error ${opts.statusCode} `);
|
||||
|
||||
Object.setPrototypeOf(this, HttpError.prototype);
|
||||
this.name = HttpError.prototype.constructor.name;
|
||||
|
||||
this.cause = opts.cause;
|
||||
this.statusCode = opts.statusCode;
|
||||
this.url = opts.url;
|
||||
this.method = opts.method;
|
||||
this.message = opts.message ?? `HTTP Error ${opts.statusCode}`;
|
||||
|
||||
if (opts.cause instanceof Error && opts.cause.stack) {
|
||||
this.stack = opts.cause.stack;
|
||||
}
|
||||
}
|
||||
|
||||
public static fromRequest(request: Request, response: Response) {
|
||||
return new HttpError({
|
||||
message: response.statusText,
|
||||
url: response.url,
|
||||
method: request.method,
|
||||
statusCode: response.status,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
// Base http Error
|
||||
export { HttpError } from "./http-error";
|
|
@ -0,0 +1,58 @@
|
|||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
async function http<T>(path: string, config: RequestInit): Promise<T> {
|
||||
const request = new Request(path, config);
|
||||
const response: Response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = HttpError.fromRequest(request, response);
|
||||
throw err;
|
||||
}
|
||||
// may error if there is no body, return empty array
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function get<T>(path: string, config?: RequestInit): Promise<T> {
|
||||
const init = { method: "GET", ...config };
|
||||
return await http<T>(path, init);
|
||||
}
|
||||
|
||||
export async function post<T, U>(path: string, body: T, config?: RequestInit): Promise<U> {
|
||||
const init = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
...config,
|
||||
};
|
||||
return await http<U>(path, init);
|
||||
}
|
||||
|
||||
export async function put<T, U>(path: string, body: T, config?: RequestInit): Promise<U> {
|
||||
const init = {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
...config,
|
||||
};
|
||||
return await http<U>(path, init);
|
||||
}
|
||||
|
||||
export async function patch<T, U>(path: string, body: T, config?: RequestInit): Promise<U> {
|
||||
const init = {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
...config,
|
||||
};
|
||||
return await http<U>(path, init);
|
||||
}
|
||||
|
||||
export async function remove<T, U>(path: string, body: T, config?: RequestInit): Promise<U> {
|
||||
const init = {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
...config,
|
||||
};
|
||||
return await http<U>(path, init);
|
||||
}
|
|
@ -1,19 +1,10 @@
|
|||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import { CreateEventType } from "@lib/types/event-type";
|
||||
import { EventType } from "@prisma/client";
|
||||
|
||||
const createEventType = async (data: CreateEventType) => {
|
||||
const response = await fetch("/api/availability/eventtype", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const response = await fetch.post<CreateEventType, EventType>("/api/availability/eventtype", data);
|
||||
return response;
|
||||
};
|
||||
|
||||
export default createEventType;
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
|
||||
const deleteEventType = async (data: { id: number }) => {
|
||||
const response = await fetch("/api/availability/eventtype", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const response = await fetch.remove<{ id: number }, Record<string, never>>(
|
||||
"/api/availability/eventtype",
|
||||
data
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
export default deleteEventType;
|
||||
|
|
|
@ -1,19 +1,10 @@
|
|||
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||
import { EventTypeInput } from "@lib/types/event-type";
|
||||
import { EventType } from "@prisma/client";
|
||||
|
||||
const updateEventType = async (data: EventTypeInput) => {
|
||||
const response = await fetch("/api/availability/eventtype", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const response = await fetch.patch<EventTypeInput, EventType>("/api/availability/eventtype", data);
|
||||
return response;
|
||||
};
|
||||
|
||||
export default updateEventType;
|
||||
|
|
|
@ -49,6 +49,15 @@ module.exports = withTM({
|
|||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback, // if you miss it, all the other options in fallback, specified
|
||||
// by next.js will be dropped. Doesn't make much sense, but how it is
|
||||
fs: false,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { AppProps as NextAppProps } from "next/app";
|
|||
import { DefaultSeo } from "next-seo";
|
||||
import { seoConfig } from "@lib/config/next-seo.config";
|
||||
|
||||
// Workaround for https://github.com/zeit/next.js/issues/8592
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = NextAppProps & {
|
||||
/** Will be defined only is there was an error */
|
||||
err?: Error;
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx) {
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
|
||||
class MyDocument extends Document<Props> {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { ...initialProps };
|
||||
}
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Typescript class based component for custom-error
|
||||
* @link https://nextjs.org/docs/advanced-features/custom-error-page
|
||||
*/
|
||||
import React from "react";
|
||||
import { NextPage, NextPageContext } from "next";
|
||||
import NextError, { ErrorProps } from "next/error";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { ErrorPage } from "@components/error/error-page";
|
||||
import logger from "@lib/logger";
|
||||
|
||||
// Adds HttpException to the list of possible error types.
|
||||
type AugmentedError = (NonNullable<NextPageContext["err"]> & HttpError) | null;
|
||||
type CustomErrorProps = {
|
||||
err?: AugmentedError;
|
||||
message?: string;
|
||||
hasGetInitialPropsRun?: boolean;
|
||||
} & Omit<ErrorProps, "err">;
|
||||
|
||||
type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
|
||||
err: AugmentedError;
|
||||
};
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[error]"] });
|
||||
|
||||
export function getErrorFromUnknown(cause: unknown): Error {
|
||||
if (cause instanceof Error) {
|
||||
return cause;
|
||||
}
|
||||
if (typeof cause === "string") {
|
||||
// @ts-expect-error https://github.com/tc39/proposal-error-cause
|
||||
return new Error(cause, { cause });
|
||||
}
|
||||
|
||||
return new Error(`Unhandled error of type '${typeof cause}''`);
|
||||
}
|
||||
|
||||
const CustomError: NextPage<CustomErrorProps> = (props) => {
|
||||
const { statusCode, err, message, hasGetInitialPropsRun } = props;
|
||||
|
||||
if (!hasGetInitialPropsRun && err) {
|
||||
// getInitialProps is not called in case of
|
||||
// https://github.com/vercel/next.js/issues/8592. As a workaround, we pass
|
||||
// err via _app.tsx so it can be captured
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const e = getErrorFromUnknown(err);
|
||||
// can be captured here
|
||||
// e.g. Sentry.captureException(e);
|
||||
}
|
||||
return <ErrorPage statusCode={statusCode} error={err} message={message} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Partially adapted from the example in
|
||||
* https://github.com/vercel/next.js/tree/canary/examples/with-sentry
|
||||
*/
|
||||
CustomError.getInitialProps = async ({ res, err, asPath }: AugmentedNextPageContext) => {
|
||||
const errorInitialProps = (await NextError.getInitialProps({
|
||||
res,
|
||||
err,
|
||||
} as NextPageContext)) as CustomErrorProps;
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592, mark when
|
||||
// getInitialProps has run
|
||||
errorInitialProps.hasGetInitialPropsRun = true;
|
||||
|
||||
// If a HttpError message, let's override defaults
|
||||
if (err instanceof HttpError) {
|
||||
errorInitialProps.statusCode = err.statusCode;
|
||||
errorInitialProps.title = err.name;
|
||||
errorInitialProps.message = err.message;
|
||||
errorInitialProps.err = err;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
// Running on the server, the response object is available.
|
||||
//
|
||||
// Next.js will pass an err on the server if a page's `getInitialProps`
|
||||
// threw or returned a Promise that rejected
|
||||
|
||||
// Overrides http status code if present in errorInitialProps
|
||||
res.statusCode = errorInitialProps.statusCode;
|
||||
|
||||
log.debug(`server side logged this: ${err?.toString() ?? JSON.stringify(err)}`);
|
||||
log.info("return props, ", errorInitialProps);
|
||||
|
||||
return errorInitialProps;
|
||||
} else {
|
||||
// Running on the client (browser).
|
||||
//
|
||||
// Next.js will provide an err if:
|
||||
//
|
||||
// - a page's `getInitialProps` threw or returned a Promise that rejected
|
||||
// - an exception was thrown somewhere in the React lifecycle (render,
|
||||
// componentDidMount, etc) that was caught by Next.js's React Error
|
||||
// Boundary. Read more about what types of exceptions are caught by Error
|
||||
// Boundaries: https://reactjs.org/docs/error-boundaries.html
|
||||
if (err) {
|
||||
log.info("client side logged this", err);
|
||||
return errorInitialProps;
|
||||
}
|
||||
}
|
||||
|
||||
// If this point is reached, getInitialProps was called without any
|
||||
// information about what the error might be. This is unexpected and may
|
||||
// indicate a bug introduced in Next.js
|
||||
new Error(`_error.tsx getInitialProps missing data at path: ${asPath}`);
|
||||
|
||||
return errorInitialProps;
|
||||
};
|
||||
|
||||
export default CustomError;
|
|
@ -10,6 +10,7 @@ import { getSession } from "@lib/auth";
|
|||
import { Scheduler } from "@components/ui/Scheduler";
|
||||
import { Disclosure, RadioGroup } from "@headlessui/react";
|
||||
import { PhoneIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import {
|
||||
LocationMarkerIcon,
|
||||
LinkIcon,
|
||||
|
@ -82,8 +83,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
await router.push("/event-types");
|
||||
showToast(`${eventType.title} event type updated successfully`, "success");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message, "error");
|
||||
onError: (err: HttpError) => {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -92,8 +94,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
await router.push("/event-types");
|
||||
showToast("Event type deleted successfully", "success");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message, "error");
|
||||
onError: (err: HttpError) => {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ import { ONBOARDING_INTRODUCED_AT } from "@lib/getting-started";
|
|||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
const EventTypesPage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { user, types } = props;
|
||||
|
@ -39,8 +40,9 @@ const EventTypesPage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
await router.push("/event-types/" + eventType.slug);
|
||||
showToast(`${eventType.title} event type created successfully`, "success");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message, "error");
|
||||
onError: (err: HttpError) => {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
const modalOpen = useToggleQuery("new");
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { NextPage } from "next";
|
||||
import { ErrorPage } from "@components/error/error-page";
|
||||
import React from "react";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
const PreviewErrorPage: NextPage = () => {
|
||||
const statusCode = 403;
|
||||
const message = `this was an http error ${statusCode}`;
|
||||
const previousError = new Error("A test error");
|
||||
const error = new HttpError({
|
||||
statusCode,
|
||||
message,
|
||||
url: "http://some.invalid.url",
|
||||
cause: previousError,
|
||||
});
|
||||
return <ErrorPage displayDebug={true} statusCode={statusCode} error={error} message={message} />;
|
||||
};
|
||||
|
||||
export default PreviewErrorPage;
|
|
@ -0,0 +1,25 @@
|
|||
import React from "react";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
const TestAsyncErrorRoute: React.FC = () => {
|
||||
const { error, isLoading } = useQuery(["error-promise"], async () => {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "A http error occurred on the client side in test-async-error.tsx.",
|
||||
url: "http://awebsite.that.does.not.exist",
|
||||
});
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <>Loading...</>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log("An error occurred", error);
|
||||
throw error;
|
||||
}
|
||||
return <>If you see this message, there is really something wrong ;)</>;
|
||||
};
|
||||
|
||||
export default TestAsyncErrorRoute;
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
type Props = {
|
||||
hasRunOnServer: boolean;
|
||||
};
|
||||
|
||||
const TestErrorRoute: React.FC<Props> = (props) => {
|
||||
if (!props.hasRunOnServer) {
|
||||
throw new HttpError({ statusCode: 400, message: "test-error.tsx" });
|
||||
}
|
||||
return <>If you see this message, there is really something wrong ;)</>;
|
||||
};
|
||||
|
||||
// Having a page that always throws error is very hard with nextjs
|
||||
// because it will try to pre-render the page at build-time... and
|
||||
// complain: 'you need to fix this'. So here because we want to always
|
||||
// throw an error for monitoring, let's force server side generation
|
||||
// all the time (the page won't be pre-generated, all cool).
|
||||
export async function getServerSideProps() {
|
||||
return {
|
||||
props: {
|
||||
hasRunOnServer: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default TestErrorRoute;
|
|
@ -1,11 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@lib/*": ["lib/*"]
|
||||
"@components/*": [
|
||||
"components/*"
|
||||
],
|
||||
"@lib/*": [
|
||||
"lib/*"
|
||||
]
|
||||
},
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
@ -19,6 +27,13 @@
|
|||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "lib/*.js"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"lib/*.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue