✍️

Cloudflare Workers + Honoのエラーハンドリング設計

に公開

概要

HonoでAPIを実装していると、「エラーをどこでキャッチして、どう返すか」の設計に迷うことがある。

この記事では、NestJSのグローバルフィルターに相当する仕組みをHonoで実装し、カスタムエラークラスの継承・正規化・ログ出力・クライアントレスポンスまでを一貫して設計するパターンを紹介する。

ログ出力については過去の記事があるのでそのロガーを使う想定です。
https://zenn.dev/rsugi/articles/86ce22b8c045e9

対象読者

  • HonoでAPIを実装しているが、エラーハンドリングの設計に迷っている方
  • カスタムエラークラスの使い分けがわからない方
  • エラーログの粒度やレスポンス設計に一貫性を感じられていない方

いいね!してね

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

Xで情報発信しているのでフォローお願いします!rsugi8

それでは以下が本編です。

結論

3行でまとめると、次のとおり。

  1. エラー種別ごとにカスタムエラークラスを定義し、業務ロジックから throw する
  2. Honoの onError をグローバルフィルターとして使い、すべてのエラーを一元キャッチ・正規化する
  3. エラー種別ごとにログレベルを分け、クライアントへ統一されたレスポンスを返す

処理の流れを図にすると以下のようになる。

1. 業務ロジック / コントローラー でthrowする

  ├─ throw MyAppInvalidRequestError()        ← HTTPリクエスト不正(myAppZValidator)
  ├─ throw MyAppBadUserInputError(msgs)      ← ビジネスロジック不整合(明示的 throw
  ├─ throw MyAppNotFoundError("...")         ← その他 4xx
  ├─ throw MyAppForbiddenError()
  └─ throw MyAppConflictError("...") など

2. catchして正規化する
Hono .onError(errorHandler)               ← グローバルフィルター
  apps/worker/middleware/error-handler.ts

3. エラー種別ごとにログレベルを分け、クライアントへ統一されたレスポンスを返す
resolveWorkerError(err)                   ← エラー種別を判定・正規化
  apps/worker/middleware/resolve-worker-error.ts

logResolvedWorkerError(resolved)          ← ロギング

c.json(...) でクライアントへレスポンス返却

説明すること

  • カスタムエラークラスの設計と継承ツリー
  • グローバルフィルター(onError)によるキャッチと正規化
  • ログ出力とクライアントへのレスポンス返却
  • リポジトリ層のエラーハンドリング方針(外部API vs DB)
  • 具体例:Firebase Admin SDKを使った新規アカウント作成

カスタムエラークラスの設計と継承ツリー

継承ツリー

まず、アプリケーション全体で使うカスタムエラークラスを定義する。
基本的に関数型を使うことが多いが、ここでは継承を使うことでHTTP Statusコードで派生させたクラスを定義しておく。

基底クラス MyAppError を継承することで、グローバルフィルター側で instanceof MyAppError の1つのチェックだけですべてのサブクラスをキャッチできるからである(後述)。

例えば、ユースケース層で在庫チェックしたのち在庫不足エラーになった場合は、即時に throw XXXError をして処理を終了させたい。MyAppUnprocessableEntityError を継承して、在庫不足専用のエラークラスを追加するといった拡張も容易にできる。

Error
  └── MyAppError                          (500) デフォルト
        ├── MyAppBadRequestError          (400) 不正なリクエスト
        │     ├── MyAppInvalidRequestError   ← HTTPリクエストパラメータのバリデーションエラー(Zodスキーマ違反)
        │     └── MyAppBadUserInputError     ← ビジネスロジック不整合エラー
        ├── MyAppAuthenticationError      (401) 認証エラー
        ├── MyAppForbiddenError           (403) 禁止
        ├── MyAppNotFoundError            (404) リソースが見つからない
        ├── MyAppConflictError            (409) コンフリクト
        └── MyAppUnprocessableEntityError (422)

カスタムエラークラスの例

サンプル2の定義をsharedフォルダに配置することで、クライアント⇄サーバー間で型を共有できるようになる。

サンプルコード1
// apps/worker/errors/my-app-error.ts

// 基底クラス(500 デフォルト)
export class MyAppError extends Error {
  readonly errorCode: MyAppErrorCode;
  readonly statusCode: number;
  readonly options?: { validationErrorMessages?: ValidationErrorMessage[] };

  constructor(
    message: string,
    errorCode: MyAppErrorCode = MY_APP_ERROR_CODE.COMMON.INTERNAL_SERVER_ERROR,
    statusCode: number = MY_APP_HTTP_STATUS.INTERNAL_SERVER_ERROR,
    options?: { validationErrorMessages?: ValidationErrorMessage[] },
  ) {
    super(message);
    this.name = this.constructor.name;
    this.errorCode = errorCode;
    this.statusCode = statusCode;
    this.options = options?.validationErrorMessages?.length
      ? { validationErrorMessages: options.validationErrorMessages }
      : undefined;
  }
}

// 400 系
export class MyAppBadRequestError extends MyAppError {
  constructor(message: string, errorCode: MyAppErrorCode, options?: { validationErrorMessages?: ValidationErrorMessage[] }) {
    super(message, errorCode, MY_APP_HTTP_STATUS.BAD_REQUEST, options);
  }
}

/** HTTPリクエストパラメータ不正(myAppZValidator)。ログスキップ・options なし */
export class MyAppInvalidRequestError extends MyAppBadRequestError {
  constructor(message = "リクエストが不正です") {
    super(message, MY_APP_ERROR_CODE.COMMON.BAD_REQUEST);
  }
}

/** ビジネスロジック不整合。validationErrorMessages をフロントのフォームに返す */
export class MyAppBadUserInputError extends MyAppBadRequestError {
  constructor(message = "バリデーションエラー", validationErrorMessages?: ValidationErrorMessage[]) {
    const options = validationErrorMessages?.length ? { validationErrorMessages } : undefined;
    super(message, MY_APP_ERROR_CODE.COMMON.BAD_REQUEST, options);
  }
}

// 401
export class MyAppAuthenticationError extends MyAppError {
  constructor(message = "認証が必要です", errorCode: MyAppErrorCode = MY_APP_ERROR_CODE.COMMON.UNAUTHORIZED) {
    super(message, errorCode, MY_APP_HTTP_STATUS.UNAUTHORIZED);
  }
}

// 403
export class MyAppForbiddenError extends MyAppError {
  constructor(message = "この操作を行う権限がありません", errorCode: MyAppErrorCode = MY_APP_ERROR_CODE.COMMON.FORBIDDEN) {
    super(message, errorCode, MY_APP_HTTP_STATUS.FORBIDDEN);
  }
}

// 404
export class MyAppNotFoundError extends MyAppError {
  constructor(message: string, errorCode: MyAppErrorCode = MY_APP_ERROR_CODE.COMMON.NOT_FOUND) {
    super(message, errorCode, MY_APP_HTTP_STATUS.NOT_FOUND);
  }
}

// 409
export class MyAppConflictError extends MyAppError {
  constructor(message: string, errorCode: MyAppErrorCode = MY_APP_ERROR_CODE.COMMON.CONFLICT) {
    super(message, errorCode, MY_APP_HTTP_STATUS.CONFLICT);
  }
}

// 422
export class MyAppUnprocessableEntityError extends MyAppError {
  constructor(message: string, errorCode: MyAppErrorCode = MY_APP_ERROR_CODE.COMMON.UNPROCESSABLE_ENTITY) {
    super(message, errorCode, MY_APP_HTTP_STATUS.UNPROCESSABLE_ENTITY);
  }
}
サンプルコード2
/**
 * エラーコードのツリー型定義。
 * `as const satisfies` で実装との整合性をコンパイル時に保証するために使う。
 * 新しいエラーコードを追加する際はここにも型を追記する。
 */
type MyAppErrorCodeTree = {
  readonly COMMON: {
    readonly UNAUTHORIZED: "UNAUTHORIZED";
    readonly FORBIDDEN: "FORBIDDEN";
    readonly NOT_FOUND: "NOT_FOUND";
    readonly BAD_REQUEST: "BAD_REQUEST";
    readonly CONFLICT: "CONFLICT";
    readonly INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR";
    readonly UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY";
    readonly TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS";
    readonly BAD_GATEWAY: "BAD_GATEWAY";
    readonly SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE";
    readonly HTTP_ERROR: "HTTP_ERROR";
  };
  // 業務ドメインごとにセクションを追加していく
  readonly INVENTORY: {
    readonly STOCK_SHORTAGE: "INVENTORY_STOCK_SHORTAGE";
  };
};

/**
 * アプリ全体で使うエラーコードの定数。
 * `as const satisfies MyAppErrorCodeTree` により、
 * 型定義との不整合があればコンパイルエラーで検出できる。
 *
 * 使い方: MY_APP_ERROR_CODE.COMMON.NOT_FOUND
 */
export const MY_APP_ERROR_CODE = {
  COMMON: {
    UNAUTHORIZED: "UNAUTHORIZED",
    FORBIDDEN: "FORBIDDEN",
    NOT_FOUND: "NOT_FOUND",
    BAD_REQUEST: "BAD_REQUEST",
    CONFLICT: "CONFLICT",
    INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
    UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY",
    TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS",
    BAD_GATEWAY: "BAD_GATEWAY",
    SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
    HTTP_ERROR: "HTTP_ERROR",
  },
  // 業務ドメイン固有のエラーコード
  // プレフィックス(INVENTORY_)で名前空間を分けることで、COMMON との衝突を防ぐ
  INVENTORY: {
    STOCK_SHORTAGE: "INVENTORY_STOCK_SHORTAGE",
  },
} as const satisfies MyAppErrorCodeTree;

/**
 * HTTP ステータスコードの定数。
 * ルートハンドラやエラークラスでマジックナンバーを直書きしないようにする。
 *
 * 使い方: MY_APP_HTTP_STATUS.NOT_FOUND → 404
 */
export const MY_APP_HTTP_STATUS = {
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  CONFLICT: 409,
  UNPROCESSABLE_ENTITY: 422,
  TOO_MANY_REQUESTS: 429,
  INTERNAL_SERVER_ERROR: 500,
  BAD_GATEWAY: 502,
  SERVICE_UNAVAILABLE: 503,
} as const;

/**
 * オブジェクトのすべての末端(リーフ)の値の型を Union として取り出すユーティリティ型。
 *
 * 例:
 *   LeafValues<{ A: { B: "foo"; C: "bar" } }>
 *   → "foo" | "bar"
 *
 * これにより MY_APP_ERROR_CODE に新しいコードを追加するだけで
 * MyAppErrorCode 型に自動的に追加される。
 */
type LeafValues<T> = T extends string
  ? T
  : T extends object
    ? { [K in keyof T]: LeafValues<T[K]> }[keyof T]
    : never;

/**
 * アプリ内で使えるすべてのエラーコードの Union 型。
 * MY_APP_ERROR_CODE の末端文字列リテラルから自動生成されるため、
 * コードを追加するだけで型が拡張される。
 *
 * 例: "NOT_FOUND" | "BAD_REQUEST" | "INVENTORY_STOCK_SHORTAGE" | ...
 */
export type MyAppErrorCode = LeafValues<typeof MY_APP_ERROR_CODE>;

/**
 * フォームフィールドへのエラーマッピング型。
 * CmsBadUserInputError(ビジネスロジック不整合)のレスポンスに含まれ、
 * フロントエンドが React Hook Form の setError に渡す用途で使う。
 *
 * 例:
 *   { param: "email", messages: ["ALREADY_EXISTS"] }
 *   → フロントで "このメールアドレスはすでに使用されています" に変換して表示
 */
export type ValidationErrorMessage = {
  param: string;    // フォームのフィールド名(RHF の name と一致させる)
  messages: string[]; // 理由コード(人間向け文言への変換はフロント側で行う)
};

/**
 * クライアントへ返すエラーレスポンスの型。
 * すべてのエラーレスポンスはこの形式に統一される。
 *
 * - message    : ユーザー向けメッセージ(サーバー側の詳細は含めない)
 * - errorCode  : フロントがエラー種別を判定するための機械可読なコード
 * - options    : バリデーションエラー時のみ含まれるフィールドごとのエラー詳細
 * - requestId  : ミドルウェアが設定した場合のみ同梱(トレース用)
 */
export interface MyAppErrorResponse {
  message: string;
  errorCode: MyAppErrorCode;
  options?: {
    validationErrorMessages?: ValidationErrorMessage[];
  };
  requestId?: string;
}

InvalidRequestError vs BadUserInputError

どちらも400系だが、発生タイミングと用途が異なるものがあり注意が必要。

MyAppInvalidRequestError MyAppBadUserInputError
発生タイミング HTTPリクエスト受付時(型・形式チェック) ビジネスロジック実行中
email フィールド自体が送られていない email は正しい形式だがDBに重複がある
validationErrorMessages なし [{ param: "email", messages: ["ALREADY_EXISTS"] }]
フォームへの反映 不要(そもそも不正なリクエスト) setError("email", ...) でフィールドに表示
ログ スキップ スキップ

MyAppInvalidRequestErrorZodスキーマ違反など「ユーザーが不正なリクエストを送った」という想定。システム側の異常ではない。このエラーをログ出力していたら、ログの量があっという間に増えてしまう。監視ノイズを減らしたいのでログ出力をスキップする。

MyAppBadUserInputErrorビジネスルール違反(重複・整合性エラーなど) で、フロントのフォームに validationErrorMessages を返してフィールドエラーとして表示する用途に使う。ログ出力をスキップする(してもいいかもしれない)。

グローバルフィルター(onError)によるキャッチと正規化

onErrorへの登録

NestJSにはグローバルフィルターという仕組みがあり、アプリ全体の未捕捉エラー(try-catchで囲って例外を捕まえていない)を一か所で処理できる。Honoでは app.onError が同じ役割を果たす。

error-handler.ts に登録した onError がすべてのルートからの未捕捉エラーを一元キャッチし、正規化・ログ出力・レスポンス返却までを担う。

// apps/worker/middleware/error-handler.ts

export const errorHandler: ErrorHandler<HonoEnv> = async (err, c) => {
  const env = c.env?.ENV ?? "local";
  const isDevOrLocal = env === "local" || env === "dev";
  const path = c.req.path;
  const method = c.req.method;
  const requestId = c.get("requestId");

  // エラーを正規化(kind: "myApp" | "http" | "unknown" に分類)
  const resolved = await resolveWorkerError(err, isDevOrLocal);

  // ロギング(kind・statusCode によってレベルを決定)
  logResolvedWorkerError(resolved, {
    logger: resolveLogger(c),  // c.var.logger(Pino)
    requestId,
    path,
    method,
    isDevOrLocal,
  });

  // クライアントへレスポンス返却
  return c.json(
    { message: resolved.clientMessage, errorCode: resolved.errorCode, ... },
    resolved.statusCode,
  );
};

エラーの正規化(resolveWorkerError)

resolveWorkerError はエラーを3種類に正規化する。

kind 対象
myApp MyAppError およびそのサブクラス
http Hono の HTTPException
unknown その他すべて(予期しないエラー)
// apps/worker/middleware/resolve-worker-error.ts

export async function resolveWorkerError(
  err: unknown,
  isDevOrLocal: boolean,
): Promise<ResolvedWorkerError> {

  // 1. MyAppError サブクラス → kind: "myApp"
  if (err instanceof MyAppError) { // 継承を使っているため基底クラスでマッチできる
    return {
      kind: "myApp",
      statusCode: err.statusCode,
      errorCode: err.errorCode,
      clientMessage: err.message,
      options: err.options,       // validationErrorMessages など
      stack: err.stack,
    };
  }

  // 2. Hono の HTTPException → kind: "http"
  // 基本的に1が返るように実装するためこのエラーは発生しなそう。念のため残している。
  if (err instanceof HTTPException) {
    return {
      kind: "http",
      statusCode: err.status,
      errorCode: getErrorCodeFromStatus(err.status),
      clientMessage: getSecureErrorMessage(err.status, ...),
      ...
    };
  }

  // 3. その他予期せぬエラーすべて → kind: "unknown"(500 固定)
  // このエラーになったらアラートが来るのでやばい
  return {
    kind: "unknown",
    statusCode: 500,
    errorCode: MY_APP_ERROR_CODE.COMMON.INTERNAL_SERVER_ERROR,
    clientMessage: isDevOrLocal ? err.message : "しばらくしてから再度お試しください",
    ...
  };
}

ログ出力とクライアントへのレスポンス返却

ログレベルの決定ロジック

エラーの kindstatusCode の組み合わせでログレベルを決定する。

エラー kind ログ レスポンス options
MyAppInvalidRequestError myApp スキップ なし
MyAppBadUserInputError myApp スキップ validationErrorMessages あり
その他 MyAppError 4xx myApp warn なし
MyAppError 5xx myApp error なし
HTTPException http warn / error なし
その他すべて unknown errorhandler: "UnhandledError" なし
// apps/worker/middleware/resolve-worker-error.ts(抜粋)

export function logResolvedWorkerError(resolved, ctx): void {
  const { logger, requestId, path, method, isDevOrLocal } = ctx;

  if (resolved.kind === "myApp") {
    // MyAppInvalidRequestError(Zodスキーマ違反)はログスキップ
    // MyAppBadUserInputError(ビジネスロジック不整合)はログスキップ
    if (resolved.isInvalidRequest || resolved.isBadUserInput) return;

    const payload = {
      handler: "MyAppError",
      statusCode: resolved.statusCode,
      errorCode: resolved.errorCode,
      detail: resolved.logMessage,
      ...(isDevOrLocal && resolved.stack ? { stack: resolved.stack } : {}),
    };
    // ざっくりと5xx → error、4xx → warn
    if (resolved.statusCode >= 500) {
      logger.error(payload);
    } else {
      logger.warn(payload);
    }
    return;
  }

  // 予期せぬエラー系
  if (resolved.kind === "unknown") {
    // 予期しないエラーは常に error
    logger.error({
      handler: "UnhandledError",
      statusCode: 500,
      name: resolved.errorName,
      detail: resolved.logMessage,
      ...(isDevOrLocal && resolved.stack ? { stack: resolved.stack } : {}),
    });
    return;
  }

  // HTTPException(kind: "http"): 5xx → error、4xx → warn
  if (resolved.statusCode >= 500) {
    logger.error({ handler: "HTTPException", statusCode: resolved.statusCode, ... });
  } else if (resolved.statusCode >= 400) {
    logger.warn({ handler: "HTTPException", statusCode: resolved.statusCode, ... });
  }
}

c.var.logger は Pino ベースのロガー。未設定時は createFallbackAppLoggerconsole へフォールバックする。

クライアントへのレスポンス形式

MyAppInvalidRequestErrorMyAppBadUserInputError はどちらも400を返すが、レスポンスボディが異なる。

// MyAppInvalidRequestError(Zodスキーマ違反)
{
  message: "リクエストが不正です",
  errorCode: "BAD_REQUEST" // 具体的な日本語のエラー文言にマッピングしてもいい
}

// MyAppBadUserInputError(ビジネスロジック不整合)
{
  message: "入力内容に誤りがあります",
  errorCode: "COMMON.BAD_REQUEST",
  options: {
    validationErrorMessages: [
      {
        param: "email",
        messages: ["ALREADY_EXISTS"] // 具体的な日本語のエラー文言にマッピングしてもいい
      }
    ]
  }
}

リポジトリ層のエラーハンドリング方針(外部API vs DB)

コントローラー・サービス層でのエラーハンドリングは onError に委譲するとして、リポジトリ層には独自の判断が必要になる。呼び出し先が「外部API」か「DB」かによって方針を分けている。

外部APIはResult型で返す

外部APIの失敗は「起こりうる正常な異常」であり、ユースケース層が「エラーを伝播するか / フォールバックするか」を選択できるように、Result型で返す。

// リポジトリ層
try {
  // 外部API呼び出し
  return { ok: true, value: data }
} catch (e) {
  // 外部APIエラー → MyAppXxxError に変換
  // cause に元のエラーを渡しておくとスタックトレースが追いやすい
  return { ok: false, error: new MyAppBadGatewayError("...", { cause: e }) } // MyAppBadGatewayErrorはカスタムエラーでの定義省略しています
}

// ユースケース層
const result = await repo.callExternalApi(...)
if (!result.ok) {
  // ユースケースによって throw するか、フォールバックするか決める
  throw result.error
}
  • リポジトリ境界で MyAppError に変換することで、上位層が外部APIの詳細を知らなくて済む
  • ユースケースが「エラーを伝播するか / フォールバックするか」を選択できる

DB操作はtry-catchのみ

DBエラーはUNIQUE制約違反・トランザクション失敗など既知のケースは catch 内で if マッチしてカスタムエラーに変換する。それ以外は想定外の障害として throw e でグローバルハンドラに委ねる。Result型を混ぜると呼び出し側の複雑度が上がるため、DB操作では使わない。

// リポジトリ層
try {
  await db.insert(schema.adminUsers).values({ ... });
} catch (e) {
  // UNIQUE制約違反(同時リクエストなど事前チェックをすり抜けた場合)
  if (isUniqueConstraintError(e)) {
    throw new MyAppConflictError("すでに登録されているメールアドレスです");
  }
  // トランザクション失敗・ロールバック
  if (isTransactionError(e)) {
    throw new MyAppError("トランザクションに失敗しました", MY_APP_ERROR_CODE.COMMON.INTERNAL_SERVER_ERROR, 500);
  }
  // その他の予期しない DB エラーはそのまま上位へ→予期せぬエラー500になる
  throw e;
}

外部APIエラーの変換対応表

外部APIのエラー 変換先
ネットワークタイムアウト MyAppBadGatewayError (502)
外部API 4xx(こちらの呼び出し不正) MyAppError (500) ※内部エラー扱い
外部API 5xx(外部障害) MyAppBadGatewayError (502)
外部APIレスポンスのパース失敗 MyAppError (500)

具体例:Firebase Admin SDKを使った新規アカウント作成

ここまでの設計を組み合わせた具体例として、Firebase Admin SDKを使った新規アカウント作成のフローを示す。

リポジトリ層:Firebase Admin SDKのエラーをResult型に変換する。

// apps/worker/repositories/admin-user.repository.ts

type CreateAdminUserResult =
  | { ok: true; value: AdminUser }
  | { ok: false; error: MyAppError };

async createAdminUser(input: CreateAdminUserInput): Promise<CreateAdminUserResult> {
  try {
    const userRecord = await this.firebaseAuth.createUser({
      email: input.email,
      password: input.password,
    });
    return { ok: true, value: AdminUser.reconstruct(userRecord) };
  } catch (e) {
    // Firebase の "email-already-exists" → ビジネスロジック不整合として 400
    if (isFirebaseError(e) && e.code === "auth/email-already-exists") {
      return {
        ok: false,
        error: new MyAppBadUserInputError("入力内容に誤りがあります", [
          { param: "email", messages: [MY_APP_VALIDATION_REASON.ALREADY_EXISTS] },
        ]),
      };
    }
    // その他の Firebase エラーは外部障害として 502
    // cause に元のエラーを渡しておくとスタックトレースが追いやすい
    return {
      ok: false,
      error: new MyAppBadGatewayError("Firebase との通信に失敗しました", { cause: e }),
    };
  }
}

サービス層:Result型のエラーを参照して再throwする。

// apps/worker/services/admin-user.service.ts

async createAdminUser(input: CreateAdminUserInput): Promise<AdminUser> {
  const result = await this.adminUserRepository.createAdminUser(input);

  if (!result.ok) {
    // ユースケースとして再 throw(グローバルハンドラへ委譲)
    throw result.error;
  }

  return result.value;
}

フロントエンドvalidationErrorMessages を受け取りフォームに反映する。

// pages/admin-users/CreateAdminUserPage.tsx

const result = await parseClientResponse(response); // honoのライブラリでパースしています

if (!result.ok) {
  const validationMessages = result.error.options?.validationErrorMessages;
  if (validationMessages) {
    for (const { param, messages } of validationMessages) {
      form.setError(param as keyof CreateAdminUserInput, {
        message: validationReasonMessages[messages[0]],
        // → "このメールアドレスはすでに使用されています"
      });
    }
  }
  return;
}

ローカルコンテキストを含むログ出力

グローバルハンドラが出力するログはリクエスト情報(pathmethodrequestId)のみを持つ。そのスコープ内にしか存在しないローカル変数(userIdorderId など)をログに残す必要がある場合は、catch 内でローカルログを出力してから re-throw する。

// サービス層の例:認証エラー
try {
  await this.firebaseAuth.verifyIdToken(idToken);
} catch (e) {
  // userId・idToken の先頭数文字などはこのスコープにしか存在しない → ローカルでログ出力
  logger.warn({
    userId,
    reason: "token verification failed",
    hint: idToken.slice(0, 8),
  });
  // グローバルハンドラへ委譲(401 レスポンスを返す)
  throw new MyAppAuthenticationError("認証に失敗しました");
}

グローバルハンドラのログとローカルログの役割分担は次のとおり。

ログ 出力元 目的
ローカルログ(logger.warn サービス層・ユースケース層 スコープ固有の変数(userId など)を保存する
グローバルハンドラのログ logResolvedWorkerError HTTP リクエスト情報(path・method・statusCode)を構造化ログで出力する

なお、ローカルログはあくまでスコープ固有の変数を残すためのもの。グローバルハンドラが出力する情報(statusCodeerrorCode など)を二重に出力する必要はない。また、throw した後はグローバルハンドラが必ずキャッチするため、ローカルログを出力したら必ず re-throw すること(握り潰し禁止)。

まとめ

この記事では、Cloudflare Workers + HonoでのエラーハンドリングをNestJSのグローバルフィルター風に設計するパターンを紹介した。

  • カスタムエラークラスを継承ツリーで整理することで、グローバルフィルターでの判定がシンプルになる
  • onError を一元的なキャッチポイントにすることで、各ルートのコードにエラー処理が散らばらない
  • InvalidRequestErrorBadUserInputError のログスキップなど、エラー種別ごとに意図をもってログレベルを設計することで、監視ノイズを減らせる
  • 外部APIはResult型、DBはtry-catchのみという方針を分けることで、リポジトリ層の責務が明確になる

同じ設計を応用する際は、プロジェクトの規模や外部依存の種類に合わせてエラークラスの粒度を調整してほしい。


この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

Xで情報発信しているのでフォローお願いします!rsugi8

Discussion