フロントエンドでネットワーク周りの共通のエラーハンドリング
モノレポでは複数のフロントエンドが存在します。ネットワーク周りのエラーハンドリングは同じようなカスタムエラーや処理を書くことが多いです。
私はしばしばフロントエンドでエラーハンドリングの処理を書く際に、以下の要件を満たすことができるように設計しています。
- カスタムエラーを使用
- 複数サービスで共通のエラーハンドリングを使用
- 各サービスでエラーメッセージを定義できるようにする
- この際必ず実装しなければいけないものを強制する
- フレームワークに依存しないこと
- Axios,ky,fetchなどでも利用しやすいようにする
これらの項目を満たすように実装します
具体的な実装に関しては以下のリポジトリにあります
カスタムエラーを使用
フロントエンドではカスタムエラーを使用してエラー処理を行っています。
詳細に関しては以下の参考記事でまとまっています。
具体的な実装はこちらです
export class NetworkBaseError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
Object.setPrototypeOf(this, new.target.prototype)
this.name = "NetworkBaseError"
this.statusCode = statusCode
if (Error.captureStackTrace !== undefined && typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, NetworkBaseError)
}
}
}
export class CannotConnectNetworkError extends Error {
constructor(message: string) {
super(message)
Object.setPrototypeOf(this, new.target.prototype)
this.name = "CannotConnectNetworkError"
if (Error.captureStackTrace !== undefined && typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, CannotConnectNetworkError)
}
}
}
export class BadRequestError extends NetworkBaseError {}
export class ForbiddenError extends NetworkBaseError {}
export class AuthenticationError extends NetworkBaseError {}
export class NotFoundError extends NetworkBaseError {}
export class AlreadyExistError extends NetworkBaseError {}
export class RequestTimeoutError extends NetworkBaseError {}
export class InternalServerError extends NetworkBaseError {}
フロントエンドでは以下のようにして判定します
const callAPI = async () => {
try {
await axios.get(baseURL);
} catch(e) {
if (e instanceof BadRequestError) {
// error handling
}
}
}
このように書くのですが、複数箇所に同じようなコードを書きたくないので判定する関数を提供します。
export const isCannotConnectNetworkError = (e: unknown): e is CannotConnectNetworkError =>
e instanceof CannotConnectNetworkError
export const isBadRequestError = (e: unknown): e is BadRequestError => e instanceof BadRequestError
export const isForbiddenError = (e: unknown): e is ForbiddenError => e instanceof ForbiddenError
export const isAuthenticationError = (e: unknown): e is AuthenticationError => e instanceof AuthenticationError
export const isNotFoundError = (e: unknown): e is NotFoundError => e instanceof NotFoundError
export const isAlreadyExistError = (e: unknown): e is AlreadyExistError => e instanceof AlreadyExistError
export const isRequestTimeoutError = (e: unknown): e is RequestTimeoutError => e instanceof RequestTimeoutError
export const isInternalServerError = (e: unknown): e is InternalServerError => e instanceof InternalServerError
ポイントとして返りのinterfaceを e is NetworkError
としています。
このようにすることでifの条件分の中でも型の恩恵を受けることができます
戻りの型を指定しないパターン
export const isCannotConnectNetworkError = (e: unknown) => e instanceof CannotConnectNetworkError
if (isCannotConnectNetworkError(e)) {
// eがCannotConnectNetworkError判定されない
}
戻りの型を指定したパターン
export const isCannotConnectNetworkError = (e: unknown): e is CannotConnectNetworkError =>
e instanceof CannotConnectNetworkError
if (isCannotConnectNetworkError(e)) {
// eがCannotConnectNetworkError判定される!
}
Http Clientで汎用的に使える変換処理
フロントエンドではステータスコードに応じでカスタムエラーを返す処理をしばしば行うので、その処理を提供します。
export const convertToErrorByStatusCode = (statusCode: number, message?: string): Error => {
switch (statusCode) {
case HttpStatusCode.BAD_REQUEST:
return new BadRequestError(statusCode, message || "Bad request")
case HttpStatusCode.UNAUTHORIZED:
return new AuthenticationError(statusCode, message || "Unauthorized")
case HttpStatusCode.FORBIDDEN:
return new ForbiddenError(statusCode, message || "Forbidden")
case HttpStatusCode.NOT_FOUND:
return new NotFoundError(statusCode, message || "Not found")
case HttpStatusCode.CONFLICT:
return new AlreadyExistError(statusCode, message || "Conflict")
case HttpStatusCode.INTERNAL_SERVER_ERROR:
return new InternalServerError(statusCode, message || "Internal server error")
default:
return new UnknownError(message || "Unknown error")
}
}
さらに、ネットワークエラーを受け取りカスタムエラーに変換するabstract classを提供します
export abstract class NetworkErrorInterpreter {
abstract convertToSpecificError(error: unknown): Error | null
protected isValidGenericError(error: unknown): error is GenericError {
const e = error as GenericError
return e && typeof e.statusCode === "number" && typeof e.message === "string" && typeof e.code === "string"
}
}
このクラスは実際に以下のように使用します。
また、SystemNetworkErrorInterpreter
は先程のNetworkErrorInterpreter
を継承しており、convertToSpecificError
の中で好きなHttpClient(axios,ky,fetchなど)のネットワークエラーからカスタムエラーに変換する処理を挟むことができます。
export const openapiFetchErrorInterpreter = (res: unknown): Error | null => {
if (res !== null && typeof res === "object" && "response" in res && (res as any).response instanceof Response) {
const r = res as {
data?: undefined
error?: { code?: number; message?: string }
response: Response
}
return convertToErrorByStatusCode(r.response.status, r.error?.message)
}
return null
}
export class SystemNetworkErrorInterpreter extends NetworkErrorInterpreter {
constructor() {
super()
this.convertToSpecificError = this.convertToSpecificError.bind(this)
this.isValidGenericError = this.isValidGenericError.bind(this)
}
convertToSpecificError(err: unknown): Error | null {
if (this.isValidGenericError(err)) {
return convertToErrorByStatusCode(err.statusCode, err.message)
}
if (err instanceof FirebaseError) {
return FirebaseErrorAdapter.create(err)
}
return openapiFetchErrorInterpreter(err)
}
}
APIエラー処理の共通化
API実行時にネットワークがあるかどうかの判定をしているのですが、その共通のハンドリング処理を提供します。
customErrorChecker
を渡しているのは、上記のSystemNetworkErrorInterpreter
を差し込むためです。NetworkErrorInterpreter
を継承しているものならcustomErrorCheckerに渡すことができます。
export class ApiErrorHandler {
constructor(readonly customErrorChecker: (err: unknown) => Error | null) {}
adapt(e: unknown) {
if (navigator !== undefined && navigator.onLine !== undefined && navigator.onLine === false) {
return new CannotConnectNetworkError("")
}
const customError = this.customErrorChecker(e)
if (customError) {
return customError
}
if (e instanceof Error) {
return e
}
if (typeof e === "string") {
return new UnknownError(e)
}
return new UnknownError("unknown error")
}
}
このApiErrorHandler
をaxiosなどの実行時に呼び出します。以下が実際に読んでいるコード。
try/catchでエラーをApiErrorHandler
のadapt
に渡しています。
export class MeDriver {
constructor(private readonly client: SystemAPIClient, private readonly errorHandler: ApiErrorHandler) {}
async find(): Promise<components["schemas"]["Me"]> {
try {
return await this.client.GET("/api/v1/me")
} catch (e) {
throw this.errorHandler.adapt(e)
}
}
}
エラーメッセージ
最後にエラーメッセージの対応ですが、共通的に利用されるのでそれぞれで別のエラーメッセージを出したいと思います。
const SpecificErrorNameMap = {
CannotConnectNetworkError: CannotConnectNetworkError,
RequestTimeoutError: RequestTimeoutError,
BadRequestError: BadRequestError,
ForbiddenError: ForbiddenError,
AuthenticationError: AuthenticationError,
NotFoundError: NotFoundError,
AlreadyExistError: AlreadyExistError,
InternalServerError: InternalServerError
} as const
export type RequiredErrorHandlers = {
[P in keyof typeof SpecificErrorNameMap]: (err: InstanceType<(typeof SpecificErrorNameMap)[P]>) => string
}
/**
* Provides error messages corresponding to custom errors
*
* example:
* ```ts
const errorHandlers: RequiredErrorHandlers = {
CannotConnectNetworkError: (err) => "Cannot connect network.",
RequestTimeoutError: (err) => "Request timeout.",
BadRequestError: (err) => "Bad request.",
ForbiddenError: (err) => "Forbidden.",
AuthenticationError: (err) => "Authentication error.",
NotFoundError: (err) => "Not found.",
AlreadyExistError: (err) => "Already exist.",
InternalServerError: (err) => "Internal server error."
}
const customErrorMessageHandler = (err: Error) => {
if (err instanceof MyCustomError) {
return 'this is my custom error message'
}
return null
}
const errorMessageProvider = new ErrorHandlingServiceMessageProvider(errorHandlers, customErrorMessageHandler)
* ```
*/
export class ErrorHandlingServiceMessageProvider {
constructor(
readonly errorHandlers: RequiredErrorHandlers,
readonly customErrorMessageHandler: (err: Error) => string | null
) {}
resolve(err: Error): string {
const customErrorMessage = this.customErrorMessageHandler(err)
if (customErrorMessage) {
return customErrorMessage
}
// If the error is not in SpecificErrorNameMap, return "Unknown Error" message.
if (err instanceof Error && !(err.constructor.name in SpecificErrorNameMap)) {
return "Unknown Error"
}
return this.errorHandlers[err.constructor.name as keyof typeof SpecificErrorNameMap](err as any)
}
}
RequiredErrorHandlers
はカスタムエラーであるCannotConnectNetworkError
やRequestTimeoutError
などのSpecificErrorNameMap
で定義したものを全て満たすような型です
これによりエラーメッセージの実装漏れがなくなります。
ErrorHandlingServiceMessageProvider
は先ほどのRequiredErrorHandlers
を受け取ります。
これで起きたエラーをresolveに渡すだけでRequiredErrorHandlers
で定義したエラーメッセージが返ってきます。
またcustomErrorMessageHandlerを受け取るようにしており、カスタムエラーに対してもメッセージを変換できるようにしてあります。
まとめ
こちらの記事の実装の詳細はGitHubリポジトリにあります。
もっと良い実装かあればコメントください🙏
Discussion