🐼

フロントエンドでネットワーク周りの共通のエラーハンドリング

2024/02/09に公開

モノレポでは複数のフロントエンドが存在します。ネットワーク周りのエラーハンドリングは同じようなカスタムエラーや処理を書くことが多いです。

私はしばしばフロントエンドでエラーハンドリングの処理を書く際に、以下の要件を満たすことができるように設計しています。

  • カスタムエラーを使用
  • 複数サービスで共通のエラーハンドリングを使用
  • 各サービスでエラーメッセージを定義できるようにする
    • この際必ず実装しなければいけないものを強制する
  • フレームワークに依存しないこと
    • Axios,ky,fetchなどでも利用しやすいようにする

これらの項目を満たすように実装します

具体的な実装に関しては以下のリポジトリにあります

https://github.com/ryo034/react-go-template/tree/main/packages/typescript/network

カスタムエラーを使用

フロントエンドではカスタムエラーを使用してエラー処理を行っています。
詳細に関しては以下の参考記事でまとまっています。
https://www.wantedly.com/companies/wantedly/post_articles/492456

具体的な実装はこちらです

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でエラーをApiErrorHandleradaptに渡しています。

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はカスタムエラーであるCannotConnectNetworkErrorRequestTimeoutErrorなどのSpecificErrorNameMapで定義したものを全て満たすような型です
これによりエラーメッセージの実装漏れがなくなります。

ErrorHandlingServiceMessageProviderは先ほどのRequiredErrorHandlersを受け取ります。
これで起きたエラーをresolveに渡すだけでRequiredErrorHandlersで定義したエラーメッセージが返ってきます。
またcustomErrorMessageHandlerを受け取るようにしており、カスタムエラーに対してもメッセージを変換できるようにしてあります。

まとめ

こちらの記事の実装の詳細はGitHubリポジトリにあります。
もっと良い実装かあればコメントください🙏

https://github.com/ryo034/react-go-template/tree/main/packages/typescript/network

Discussion