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>
This commit is contained in:
Mihai C 2021-09-09 16:51:06 +03:00 committed by GitHub
parent 70f595ec08
commit 903f7729c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 410 additions and 228 deletions

View File

@ -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>
</>
);
};

View File

@ -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,
});
}
}

View File

@ -0,0 +1,2 @@
// Base http Error
export { HttpError } from "./http-error";

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 [
{

View File

@ -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;

View File

@ -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 };
}

112
pages/_error.tsx Normal file
View File

@ -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;

View File

@ -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");
},
});

View File

@ -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");

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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"
]
}

174
yarn.lock

File diff suppressed because it is too large Load Diff