Skip to content

Commit

Permalink
refactor(frontend): application errors
Browse files Browse the repository at this point in the history
  • Loading branch information
gregory-j-baker committed Dec 22, 2024
1 parent 00ad327 commit f87fdef
Show file tree
Hide file tree
Showing 17 changed files with 169 additions and 82 deletions.
8 changes: 4 additions & 4 deletions frontend/app/.server/utils/auth-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { redirect } from 'react-router';
import type { SessionData } from 'express-session';

import { LogFactory } from '~/.server/logging';
import { CodedError } from '~/errors/coded-error';
import { AppError } from '~/errors/app-error';
import { ErrorCodes } from '~/errors/error-codes';

const log = LogFactory.getLogger(import.meta.url);
Expand All @@ -26,7 +26,7 @@ export function hasRole(session: SessionData, role: Role) {
/**
* Requires that the user posses all of the specified roles.
* Will redirect to the login page if the user is not authenticated.
* @throws {CodedError} If the user does not have the required roles.
* @throws {AppError} If the user does not have the required roles.
*/
export function requireAuth(
session: SessionData,
Expand All @@ -43,10 +43,10 @@ export function requireAuth(
const missingRoles = roles.filter((role) => !hasRole(session, role));

if (missingRoles.length > 0) {
throw new CodedError(
throw new AppError(
`User does not have the following required roles: [${missingRoles.join(', ')}]`,
ErrorCodes.ACCESS_FORBIDDEN,
{ statusCode: 403 },
{ httpStatusCode: 403 },
);
}
}
12 changes: 6 additions & 6 deletions frontend/app/.server/utils/instrumentation-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Span } from '@opentelemetry/api';
import { SpanStatusCode, trace } from '@opentelemetry/api';

import { isCodedError } from '~/errors/coded-error';
import { isAppError } from '~/errors/app-error';

export const DEFAULT_TRACER_NAME = 'future-sir';

Expand Down Expand Up @@ -54,13 +54,13 @@ export async function withSpan<T>(
}

function getErrorCode(error: unknown): string | undefined {
if (isCodedError(error)) {
if (isAppError(error)) {
return error.errorCode;
}
}

function getCorrelationId(error: unknown): string | undefined {
if (isCodedError(error)) {
if (isAppError(error)) {
return error.correlationId;
}
}
Expand All @@ -70,21 +70,21 @@ function getMessage(error: unknown): string | undefined {
return error.message;
}

if (isCodedError(error)) {
if (isAppError(error)) {
return error.msg;
}
}

function getName(error: unknown): string {
if (isError(error) || isCodedError(error)) {
if (isError(error) || isAppError(error)) {
return error.name;
}

return String(error);
}

function getStack(error: unknown): string | undefined {
if (isError(error) || isCodedError(error)) {
if (isError(error) || isAppError(error)) {
return error.stack;
}
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/components/app-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react';
import type { Params, Path } from 'react-router';
import { generatePath, Link } from 'react-router';

import { CodedError } from '~/errors/coded-error';
import { AppError } from '~/errors/app-error';
import { ErrorCodes } from '~/errors/error-codes';
import { useLanguage } from '~/hooks/use-language';
import type { I18nRouteFile } from '~/i18n-routes';
Expand Down Expand Up @@ -43,7 +43,7 @@ export function AppLink({ children, hash, lang, params, file, search, to, ...pro
const targetLanguage = lang ?? currentLanguage;

if (targetLanguage === undefined) {
throw new CodedError(
throw new AppError(
'The `lang` parameter was not provided, and the current language could not be determined from the request',
ErrorCodes.MISSING_LANG_PARAM,
);
Expand Down
14 changes: 9 additions & 5 deletions frontend/app/components/bilingual-error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Route } from '../+types/root';

import { AppLink } from '~/components/app-link';
import { PageTitle } from '~/components/page-title';
import { isCodedError } from '~/errors/coded-error';
import { isAppError } from '~/errors/app-error';

/**
* A bilingual error boundary that renders appropriate error messages in both languages.
Expand Down Expand Up @@ -53,11 +53,13 @@ export function BilingualErrorBoundary({ actionData, error, loaderData, params }
<PageTitle className="my-8">
<span>{en('gcweb:server-error.page-title')}</span>
<small className="block text-2xl font-normal text-neutral-500">
{en('gcweb:server-error.page-subtitle', { statusCode: isCodedError(error) ? error.statusCode : 500 })}
{en('gcweb:server-error.page-subtitle', {
statusCode: isAppError(error) ? error.httpStatusCode : 500,
})}
</small>
</PageTitle>
<p className="mb-8 text-lg text-gray-500">{en('gcweb:server-error.page-message')}</p>
{isCodedError(error) && (
{isAppError(error) && (
<ul className="list-disc pl-10 text-gray-800">
<li>
<Trans
Expand All @@ -82,11 +84,13 @@ export function BilingualErrorBoundary({ actionData, error, loaderData, params }
<PageTitle className="my-8">
<span>{fr('gcweb:server-error.page-title')}</span>
<small className="block text-2xl font-normal text-neutral-500">
{fr('gcweb:server-error.page-subtitle', { statusCode: isCodedError(error) ? error.statusCode : 500 })}
{fr('gcweb:server-error.page-subtitle', {
statusCode: isAppError(error) ? error.httpStatusCode : 500,
})}
</small>
</PageTitle>
<p className="mb-8 text-lg text-gray-500">{fr('gcweb:server-error.page-message')}</p>
{isCodedError(error) && (
{isAppError(error) && (
<ul className="list-disc pl-10 text-gray-800">
<li>
<Trans
Expand Down
6 changes: 3 additions & 3 deletions frontend/app/components/unilingual-error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Route } from '../+types/root';

import { AppLink } from '~/components/app-link';
import { PageTitle } from '~/components/page-title';
import { isCodedError } from '~/errors/coded-error';
import { isAppError } from '~/errors/app-error';
import { useLanguage } from '~/hooks/use-language';

/**
Expand Down Expand Up @@ -51,11 +51,11 @@ export function UnilingualErrorBoundary({ actionData, error, loaderData, params
<PageTitle className="my-8">
<span>{t('gcweb:server-error.page-title')}</span>
<small className="block text-2xl font-normal text-neutral-500">
{t('gcweb:server-error.page-subtitle', { statusCode: isCodedError(error) ? error.statusCode : 500 })}
{t('gcweb:server-error.page-subtitle', { statusCode: isAppError(error) ? error.httpStatusCode : 500 })}
</small>
</PageTitle>
<p className="mb-8 text-lg text-gray-500">{t('gcweb:server-error.page-message')}</p>
{isCodedError(error) && (
{isAppError(error) && (
<ul className="list-disc pl-10 text-gray-800">
<li>
<Trans
Expand Down
Original file line number Diff line number Diff line change
@@ -1,57 +1,50 @@
import type { ErrorCode } from '~/errors/error-codes';
import { ErrorCodes } from '~/errors/error-codes';
import type { HttpStatusCode } from '~/errors/http-status-codes';
import { randomString } from '~/utils/string-utils';

// prettier-ignore
export type HttpStatusCode =
| 100 | 101 | 102 | 103
| 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226
| 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308
| 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451
| 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511;

export type CodedErrorOpts = {
type AppErrorOptions = {
correlationId?: string;
statusCode?: HttpStatusCode;
httpStatusCode?: HttpStatusCode;
};

/**
* A generic, top-level error that all application errors should extend.
* This class *does not* extend Error because React Router will sanitize all Errors when sending them to the client.
*/
export class CodedError {
public readonly name = 'CodedError';
export class AppError {
public readonly name = 'AppError';

public readonly errorCode: ErrorCode;
public readonly correlationId: string;
public readonly stack?: string;
public readonly statusCode: HttpStatusCode;
public readonly httpStatusCode: HttpStatusCode;

// note: this is intentionally named `msg` instead
// of `message` to workaround an issue with winston
// always logging this as the log message when a
// message is supplied to `log.error(message, error)`
public readonly msg: string;

public constructor(msg: string, errorCode: ErrorCode = ErrorCodes.UNCAUGHT_ERROR, opts?: CodedErrorOpts) {
public constructor(msg: string, errorCode: ErrorCode = ErrorCodes.UNCAUGHT_ERROR, opts?: AppErrorOptions) {
this.errorCode = errorCode;
this.msg = msg;

this.correlationId = opts?.correlationId ?? generateCorrelationId();
this.statusCode = opts?.statusCode ?? 500;
this.httpStatusCode = opts?.httpStatusCode ?? 500;

Error.captureStackTrace(this, this.constructor);
}
}

/**
* Type guard to check if an error is a CodedError.
* Type guard to check if an error is a AppError.
*
* Note: this function does not use `instanceof` because the type
* information is lost when shipped to the client
*/
export function isCodedError(error: unknown): error is CodedError {
return error instanceof Object && 'name' in error && error.name === 'CodedError';
export function isAppError(error: unknown): error is AppError {
return error instanceof Object && 'name' in error && error.name === 'AppError';
}

/**
Expand Down
90 changes: 90 additions & 0 deletions frontend/app/errors/http-status-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* All useful HTTP status codes
* see: https://httpwg.org/specs/rfc9110.html
*/
export const HttpStatusCodes = {
//
// informational responses
//
CONTINUE: 100,
SWITCHING_PROTOCOLS: 101,
PROCESSING: 102,
EARLY_HINTS: 103,

//
// successful responses
//
OK: 200,
CREATED: 201,
ACCEPTED: 202,
NON_AUTHORITATIVE_INFORMATION: 203,
NO_CONTENT: 204,
RESET_CONTENT: 205,
PARTIAL_CONTENT: 206,
MULTI_STATUS: 207,
ALREADY_REPORTED: 208,
IM_USED: 226,

//
// redirection responses
//
MULTIPLE_CHOICES: 300,
MOVED_PERMANENTLY: 301,
FOUND: 302,
SEE_OTHER: 303,
NOT_MODIFIED: 304,
USE_PROXY: 305,
UNUSED: 306,
TEMPORARY_REDIRECT: 307,
PERMANENT_REDIRECT: 308,

//
// client error responses
//
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
NOT_ACCEPTABLE: 406,
PROXY_AUTHENTICATION_REQUIRED: 407,
REQUEST_TIMEOUT: 408,
CONFLICT: 409,
GONE: 410,
LENGTH_REQUIRED: 411,
PRECONDITION_FAILED: 412,
PAYLOAD_TOO_LARGE: 413,
URI_TOO_LONG: 414,
UNSUPPORTED_MEDIA_TYPE: 415,
RANGE_NOT_SATISFIABLE: 416,
EXPECTATION_FAILED: 417,
IM_A_TEAPOT: 418,
MISDIRECTED_REQUEST: 421,
UNPROCESSABLE_ENTITY: 422,
LOCKED: 423,
FAILED_DEPENDENCY: 424,
TOO_EARLY: 425,
UPGRADE_REQUIRED: 426,
PRECONDITION_REQUIRED: 428,
TOO_MANY_REQUESTS: 429,
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
UNAVAILABLE_FOR_LEGAL_REASONS: 451,

//
// server error responses
//
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
HTTP_VERSION_NOT_SUPPORTED: 505,
VARIANT_ALSO_NEGOTIATES: 506,
INSUFFICIENT_STORAGE: 507,
LOOP_DETECTED: 508,
NOT_EXTENDED: 510,
NETWORK_AUTHENTICATION_REQUIRED: 511,
} as const;

export type HttpStatusCode = (typeof HttpStatusCodes)[keyof typeof HttpStatusCodes];
4 changes: 2 additions & 2 deletions frontend/app/i18n-config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { initReactI18next } from 'react-i18next';

import { serverEnvironment } from '~/.server/environment';
import { i18nResources } from '~/.server/locales';
import { CodedError } from '~/errors/coded-error';
import { AppError } from '~/errors/app-error';
import { ErrorCodes } from '~/errors/error-codes';
import { getLanguage } from '~/utils/i18n-utils';

Expand All @@ -26,7 +26,7 @@ export async function getFixedT<NS extends Namespace>(
: languageOrRequest;

if (language === undefined) {
throw new CodedError('No language found in request', ErrorCodes.NO_LANGUAGE_FOUND);
throw new AppError('No language found in request', ErrorCodes.NO_LANGUAGE_FOUND);
}

const i18n = await initI18next(language);
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/routes/auth/callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { AuthenticationStrategy } from '~/.server/auth/auth-strategies';
import { AzureADAuthenticationStrategy, LocalAuthenticationStrategy } from '~/.server/auth/auth-strategies';
import { serverEnvironment } from '~/.server/environment';
import { withSpan } from '~/.server/utils/instrumentation-utils';
import { CodedError } from '~/errors/coded-error';
import { AppError } from '~/errors/app-error';
import { ErrorCodes } from '~/errors/error-codes';

/**
Expand All @@ -34,7 +34,7 @@ export async function loader({ context, params, request }: Route.LoaderArgs) {
const AZUREAD_CLIENT_SECRET = Redacted.value(serverEnvironment.AZUREAD_CLIENT_SECRET);

if (!AZUREAD_ISSUER_URL || !AZUREAD_CLIENT_ID || !AZUREAD_CLIENT_SECRET) {
throw new CodedError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
throw new AppError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
}

const authStrategy = new AzureADAuthenticationStrategy(
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/routes/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { Route } from './+types/login';
import type { AuthenticationStrategy } from '~/.server/auth/auth-strategies';
import { AzureADAuthenticationStrategy, LocalAuthenticationStrategy } from '~/.server/auth/auth-strategies';
import { serverEnvironment } from '~/.server/environment';
import { CodedError } from '~/errors/coded-error';
import { AppError } from '~/errors/app-error';
import { ErrorCodes } from '~/errors/error-codes';

/**
Expand Down Expand Up @@ -41,7 +41,7 @@ export async function loader({ context, params, request }: Route.LoaderArgs) {
const AZUREAD_CLIENT_SECRET = Redacted.value(serverEnvironment.AZUREAD_CLIENT_SECRET);

if (!AZUREAD_ISSUER_URL || !AZUREAD_CLIENT_ID || !AZUREAD_CLIENT_SECRET) {
throw new CodedError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
throw new AppError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
}

const authStrategy = new AzureADAuthenticationStrategy(
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/routes/dev/error.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { CodedError } from '~/errors/coded-error';
import { AppError } from '~/errors/app-error';
import { ErrorCodes } from '~/errors/error-codes';

/**
* An error route that can be used to test error boundaries.
*/
export default function Error() {
throw new CodedError('This is a test error', ErrorCodes.TEST_ERROR_CODE);
throw new AppError('This is a test error', ErrorCodes.TEST_ERROR_CODE);
}
Loading

0 comments on commit f87fdef

Please sign in to comment.