Skip to content

Commit 636414f

Browse files
refactor(frontend): application errors
1 parent 00ad327 commit 636414f

17 files changed

+169
-82
lines changed

frontend/app/.server/utils/auth-utils.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { redirect } from 'react-router';
33
import type { SessionData } from 'express-session';
44

55
import { LogFactory } from '~/.server/logging';
6-
import { CodedError } from '~/errors/coded-error';
6+
import { ApplicationError } from '~/errors/application-error';
77
import { ErrorCodes } from '~/errors/error-codes';
88

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

4545
if (missingRoles.length > 0) {
46-
throw new CodedError(
46+
throw new ApplicationError(
4747
`User does not have the following required roles: [${missingRoles.join(', ')}]`,
4848
ErrorCodes.ACCESS_FORBIDDEN,
49-
{ statusCode: 403 },
49+
{ httpStatusCode: 403 },
5050
);
5151
}
5252
}

frontend/app/.server/utils/instrumentation-utils.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Span } from '@opentelemetry/api';
22
import { SpanStatusCode, trace } from '@opentelemetry/api';
33

4-
import { isCodedError } from '~/errors/coded-error';
4+
import { isApplicationError } from '~/errors/application-error';
55

66
export const DEFAULT_TRACER_NAME = 'future-sir';
77

@@ -54,13 +54,13 @@ export async function withSpan<T>(
5454
}
5555

5656
function getErrorCode(error: unknown): string | undefined {
57-
if (isCodedError(error)) {
57+
if (isApplicationError(error)) {
5858
return error.errorCode;
5959
}
6060
}
6161

6262
function getCorrelationId(error: unknown): string | undefined {
63-
if (isCodedError(error)) {
63+
if (isApplicationError(error)) {
6464
return error.correlationId;
6565
}
6666
}
@@ -70,21 +70,21 @@ function getMessage(error: unknown): string | undefined {
7070
return error.message;
7171
}
7272

73-
if (isCodedError(error)) {
73+
if (isApplicationError(error)) {
7474
return error.msg;
7575
}
7676
}
7777

7878
function getName(error: unknown): string {
79-
if (isError(error) || isCodedError(error)) {
79+
if (isError(error) || isApplicationError(error)) {
8080
return error.name;
8181
}
8282

8383
return String(error);
8484
}
8585

8686
function getStack(error: unknown): string | undefined {
87-
if (isError(error) || isCodedError(error)) {
87+
if (isError(error) || isApplicationError(error)) {
8888
return error.stack;
8989
}
9090
}

frontend/app/components/app-link.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ComponentProps } from 'react';
33
import type { Params, Path } from 'react-router';
44
import { generatePath, Link } from 'react-router';
55

6-
import { CodedError } from '~/errors/coded-error';
6+
import { ApplicationError } from '~/errors/application-error';
77
import { ErrorCodes } from '~/errors/error-codes';
88
import { useLanguage } from '~/hooks/use-language';
99
import type { I18nRouteFile } from '~/i18n-routes';
@@ -43,7 +43,7 @@ export function AppLink({ children, hash, lang, params, file, search, to, ...pro
4343
const targetLanguage = lang ?? currentLanguage;
4444

4545
if (targetLanguage === undefined) {
46-
throw new CodedError(
46+
throw new ApplicationError(
4747
'The `lang` parameter was not provided, and the current language could not be determined from the request',
4848
ErrorCodes.MISSING_LANG_PARAM,
4949
);

frontend/app/components/bilingual-error-boundary.tsx

+9-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Route } from '../+types/root';
66

77
import { AppLink } from '~/components/app-link';
88
import { PageTitle } from '~/components/page-title';
9-
import { isCodedError } from '~/errors/coded-error';
9+
import { isApplicationError } from '~/errors/application-error';
1010

1111
/**
1212
* A bilingual error boundary that renders appropriate error messages in both languages.
@@ -53,11 +53,13 @@ export function BilingualErrorBoundary({ actionData, error, loaderData, params }
5353
<PageTitle className="my-8">
5454
<span>{en('gcweb:server-error.page-title')}</span>
5555
<small className="block text-2xl font-normal text-neutral-500">
56-
{en('gcweb:server-error.page-subtitle', { statusCode: isCodedError(error) ? error.statusCode : 500 })}
56+
{en('gcweb:server-error.page-subtitle', {
57+
statusCode: isApplicationError(error) ? error.httpStatusCode : 500,
58+
})}
5759
</small>
5860
</PageTitle>
5961
<p className="mb-8 text-lg text-gray-500">{en('gcweb:server-error.page-message')}</p>
60-
{isCodedError(error) && (
62+
{isApplicationError(error) && (
6163
<ul className="list-disc pl-10 text-gray-800">
6264
<li>
6365
<Trans
@@ -82,11 +84,13 @@ export function BilingualErrorBoundary({ actionData, error, loaderData, params }
8284
<PageTitle className="my-8">
8385
<span>{fr('gcweb:server-error.page-title')}</span>
8486
<small className="block text-2xl font-normal text-neutral-500">
85-
{fr('gcweb:server-error.page-subtitle', { statusCode: isCodedError(error) ? error.statusCode : 500 })}
87+
{fr('gcweb:server-error.page-subtitle', {
88+
statusCode: isApplicationError(error) ? error.httpStatusCode : 500,
89+
})}
8690
</small>
8791
</PageTitle>
8892
<p className="mb-8 text-lg text-gray-500">{fr('gcweb:server-error.page-message')}</p>
89-
{isCodedError(error) && (
93+
{isApplicationError(error) && (
9094
<ul className="list-disc pl-10 text-gray-800">
9195
<li>
9296
<Trans

frontend/app/components/unilingual-error-boundary.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Route } from '../+types/root';
66

77
import { AppLink } from '~/components/app-link';
88
import { PageTitle } from '~/components/page-title';
9-
import { isCodedError } from '~/errors/coded-error';
9+
import { isApplicationError } from '~/errors/application-error';
1010
import { useLanguage } from '~/hooks/use-language';
1111

1212
/**
@@ -51,11 +51,11 @@ export function UnilingualErrorBoundary({ actionData, error, loaderData, params
5151
<PageTitle className="my-8">
5252
<span>{t('gcweb:server-error.page-title')}</span>
5353
<small className="block text-2xl font-normal text-neutral-500">
54-
{t('gcweb:server-error.page-subtitle', { statusCode: isCodedError(error) ? error.statusCode : 500 })}
54+
{t('gcweb:server-error.page-subtitle', { statusCode: isApplicationError(error) ? error.httpStatusCode : 500 })}
5555
</small>
5656
</PageTitle>
5757
<p className="mb-8 text-lg text-gray-500">{t('gcweb:server-error.page-message')}</p>
58-
{isCodedError(error) && (
58+
{isApplicationError(error) && (
5959
<ul className="list-disc pl-10 text-gray-800">
6060
<li>
6161
<Trans

frontend/app/errors/coded-error.ts frontend/app/errors/application-error.ts

+11-18
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,50 @@
11
import type { ErrorCode } from '~/errors/error-codes';
22
import { ErrorCodes } from '~/errors/error-codes';
3+
import type { HttpStatusCode } from '~/errors/http-status-codes';
34
import { randomString } from '~/utils/string-utils';
45

5-
// prettier-ignore
6-
export type HttpStatusCode =
7-
| 100 | 101 | 102 | 103
8-
| 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226
9-
| 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308
10-
| 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
11-
| 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511;
12-
13-
export type CodedErrorOpts = {
6+
export type ApplicationErrorOptions = {
147
correlationId?: string;
15-
statusCode?: HttpStatusCode;
8+
httpStatusCode?: HttpStatusCode;
169
};
1710

1811
/**
1912
* A generic, top-level error that all application errors should extend.
2013
* This class *does not* extend Error because React Router will sanitize all Errors when sending them to the client.
2114
*/
22-
export class CodedError {
23-
public readonly name = 'CodedError';
15+
export class ApplicationError {
16+
public readonly name = 'ApplicationError';
2417

2518
public readonly errorCode: ErrorCode;
2619
public readonly correlationId: string;
2720
public readonly stack?: string;
28-
public readonly statusCode: HttpStatusCode;
21+
public readonly httpStatusCode: HttpStatusCode;
2922

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

36-
public constructor(msg: string, errorCode: ErrorCode = ErrorCodes.UNCAUGHT_ERROR, opts?: CodedErrorOpts) {
29+
public constructor(msg: string, errorCode: ErrorCode = ErrorCodes.UNCAUGHT_ERROR, opts?: ApplicationErrorOptions) {
3730
this.errorCode = errorCode;
3831
this.msg = msg;
3932

4033
this.correlationId = opts?.correlationId ?? generateCorrelationId();
41-
this.statusCode = opts?.statusCode ?? 500;
34+
this.httpStatusCode = opts?.httpStatusCode ?? 500;
4235

4336
Error.captureStackTrace(this, this.constructor);
4437
}
4538
}
4639

4740
/**
48-
* Type guard to check if an error is a CodedError.
41+
* Type guard to check if an error is a ApplicationError.
4942
*
5043
* Note: this function does not use `instanceof` because the type
5144
* information is lost when shipped to the client
5245
*/
53-
export function isCodedError(error: unknown): error is CodedError {
54-
return error instanceof Object && 'name' in error && error.name === 'CodedError';
46+
export function isApplicationError(error: unknown): error is ApplicationError {
47+
return error instanceof Object && 'name' in error && error.name === 'ApplicationError';
5548
}
5649

5750
/**
+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* All useful HTTP status codes
3+
* see: https://httpwg.org/specs/rfc9110.html
4+
*/
5+
export const HttpStatusCodes = {
6+
//
7+
// informational responses
8+
//
9+
CONTINUE: 100,
10+
SWITCHING_PROTOCOLS: 101,
11+
PROCESSING: 102,
12+
EARLY_HINTS: 103,
13+
14+
//
15+
// successful responses
16+
//
17+
OK: 200,
18+
CREATED: 201,
19+
ACCEPTED: 202,
20+
NON_AUTHORITATIVE_INFORMATION: 203,
21+
NO_CONTENT: 204,
22+
RESET_CONTENT: 205,
23+
PARTIAL_CONTENT: 206,
24+
MULTI_STATUS: 207,
25+
ALREADY_REPORTED: 208,
26+
IM_USED: 226,
27+
28+
//
29+
// redirection responses
30+
//
31+
MULTIPLE_CHOICES: 300,
32+
MOVED_PERMANENTLY: 301,
33+
FOUND: 302,
34+
SEE_OTHER: 303,
35+
NOT_MODIFIED: 304,
36+
USE_PROXY: 305,
37+
UNUSED: 306,
38+
TEMPORARY_REDIRECT: 307,
39+
PERMANENT_REDIRECT: 308,
40+
41+
//
42+
// client error responses
43+
//
44+
BAD_REQUEST: 400,
45+
UNAUTHORIZED: 401,
46+
PAYMENT_REQUIRED: 402,
47+
FORBIDDEN: 403,
48+
NOT_FOUND: 404,
49+
METHOD_NOT_ALLOWED: 405,
50+
NOT_ACCEPTABLE: 406,
51+
PROXY_AUTHENTICATION_REQUIRED: 407,
52+
REQUEST_TIMEOUT: 408,
53+
CONFLICT: 409,
54+
GONE: 410,
55+
LENGTH_REQUIRED: 411,
56+
PRECONDITION_FAILED: 412,
57+
PAYLOAD_TOO_LARGE: 413,
58+
URI_TOO_LONG: 414,
59+
UNSUPPORTED_MEDIA_TYPE: 415,
60+
RANGE_NOT_SATISFIABLE: 416,
61+
EXPECTATION_FAILED: 417,
62+
IM_A_TEAPOT: 418,
63+
MISDIRECTED_REQUEST: 421,
64+
UNPROCESSABLE_ENTITY: 422,
65+
LOCKED: 423,
66+
FAILED_DEPENDENCY: 424,
67+
TOO_EARLY: 425,
68+
UPGRADE_REQUIRED: 426,
69+
PRECONDITION_REQUIRED: 428,
70+
TOO_MANY_REQUESTS: 429,
71+
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
72+
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
73+
74+
//
75+
// server error responses
76+
//
77+
INTERNAL_SERVER_ERROR: 500,
78+
NOT_IMPLEMENTED: 501,
79+
BAD_GATEWAY: 502,
80+
SERVICE_UNAVAILABLE: 503,
81+
GATEWAY_TIMEOUT: 504,
82+
HTTP_VERSION_NOT_SUPPORTED: 505,
83+
VARIANT_ALSO_NEGOTIATES: 506,
84+
INSUFFICIENT_STORAGE: 507,
85+
LOOP_DETECTED: 508,
86+
NOT_EXTENDED: 510,
87+
NETWORK_AUTHENTICATION_REQUIRED: 511,
88+
} as const;
89+
90+
export type HttpStatusCode = (typeof HttpStatusCodes)[keyof typeof HttpStatusCodes];

frontend/app/i18n-config.server.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { initReactI18next } from 'react-i18next';
44

55
import { serverEnvironment } from '~/.server/environment';
66
import { i18nResources } from '~/.server/locales';
7-
import { CodedError } from '~/errors/coded-error';
7+
import { ApplicationError } from '~/errors/application-error';
88
import { ErrorCodes } from '~/errors/error-codes';
99
import { getLanguage } from '~/utils/i18n-utils';
1010

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

2828
if (language === undefined) {
29-
throw new CodedError('No language found in request', ErrorCodes.NO_LANGUAGE_FOUND);
29+
throw new ApplicationError('No language found in request', ErrorCodes.NO_LANGUAGE_FOUND);
3030
}
3131

3232
const i18n = await initI18next(language);

frontend/app/routes/auth/callback.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { AuthenticationStrategy } from '~/.server/auth/auth-strategies';
99
import { AzureADAuthenticationStrategy, LocalAuthenticationStrategy } from '~/.server/auth/auth-strategies';
1010
import { serverEnvironment } from '~/.server/environment';
1111
import { withSpan } from '~/.server/utils/instrumentation-utils';
12-
import { CodedError } from '~/errors/coded-error';
12+
import { ApplicationError } from '~/errors/application-error';
1313
import { ErrorCodes } from '~/errors/error-codes';
1414

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

3636
if (!AZUREAD_ISSUER_URL || !AZUREAD_CLIENT_ID || !AZUREAD_CLIENT_SECRET) {
37-
throw new CodedError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
37+
throw new ApplicationError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
3838
}
3939

4040
const authStrategy = new AzureADAuthenticationStrategy(

frontend/app/routes/auth/login.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { Route } from './+types/login';
99
import type { AuthenticationStrategy } from '~/.server/auth/auth-strategies';
1010
import { AzureADAuthenticationStrategy, LocalAuthenticationStrategy } from '~/.server/auth/auth-strategies';
1111
import { serverEnvironment } from '~/.server/environment';
12-
import { CodedError } from '~/errors/coded-error';
12+
import { ApplicationError } from '~/errors/application-error';
1313
import { ErrorCodes } from '~/errors/error-codes';
1414

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

4343
if (!AZUREAD_ISSUER_URL || !AZUREAD_CLIENT_ID || !AZUREAD_CLIENT_SECRET) {
44-
throw new CodedError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
44+
throw new ApplicationError('The Azure OIDC settings are misconfigured', ErrorCodes.MISCONFIGURED_PROVIDER);
4545
}
4646

4747
const authStrategy = new AzureADAuthenticationStrategy(

frontend/app/routes/dev/error.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { CodedError } from '~/errors/coded-error';
1+
import { ApplicationError } from '~/errors/application-error';
22
import { ErrorCodes } from '~/errors/error-codes';
33

44
/**
55
* An error route that can be used to test error boundaries.
66
*/
77
export default function Error() {
8-
throw new CodedError('This is a test error', ErrorCodes.TEST_ERROR_CODE);
8+
throw new ApplicationError('This is a test error', ErrorCodes.TEST_ERROR_CODE);
99
}

0 commit comments

Comments
 (0)