エラー処理の課題と解決策を比較する図。左は「CHAOS」として、正規表現や文字列マッチングで複雑化・分散したエラー判定を示す。右は「UNIFIED」として、AppErrorによる構造化とCIチェックで統一された健全なエラーハンドリングを表す。

はじめに

エラーメッセージの文言を見て、HTTPステータスコードを判定するコードを書いたことはないでしょうか。

「ログインしてください という文字列が含まれていたら401」「それ以外は400か500」というように、メッセージと正規表現のリストを突き合わせる実装です。

一見動いているように見えて、これは静かに壊れやすい設計です。
メッセージの文言を1文字直しただけでステータスコードが変わってしまったり、新しいエラーを追加するたびにパターンリストの更新を忘れたりします。

あるプロジェクトで、実際にこの方式が積み重なって手に負えなくなった箇所をリファクタリングした経験があります。
この記事では、その際に採用した「構造化エラークラスへの一本化」と「揺り戻しを防ぐCIチェック」の組み合わせを紹介します。

こんな人におすすめ

  • API のエラーレスポンス設計に悩んでいる方
  • catch した例外をどうクライアントに返すか、その場しのぎで決めてしまっている方
  • エラーメッセージの文言変更で意図せずステータスコードが変わった経験がある方
  • 大掛かりなESLintルールを書かずに、CIへ軽量な静的チェックを足したい方
  • 認証エラー時のUXまで含めてエラーハンドリングを見直したい方

文字列マッチングによるステータス判定の何が問題か

Before の実装は、だいたい次のような形をしていました。

const USER_FACING_ERROR_PATTERNS = [
  /ログインしてください。?/,
  /このメールアドレスは既に使われています。?/,
  // ...多数のパターンが並ぶ
]

export function toPublicErrorMessage(error: unknown) {
  const message = error instanceof Error ? error.message : String(error)
  const isUserFacing = USER_FACING_ERROR_PATTERNS.some((pattern) => pattern.test(message))
  return isUserFacing ? message : GENERIC_ERROR_MESSAGE
}

export function getErrorStatus(message: string) {
  if (message === 'ログインしてください。') return 401
  // ...メッセージ文言ごとにステータスを個別判定
  return message === GENERIC_ERROR_MESSAGE ? 500 : 400
}

一見シンプルですが、エラーの「種類」「ステータス」「ユーザー向け文言」という3つの情報が、メッセージ文字列という1本の糸だけで結ばれています。

この糸は驚くほど簡単に切れます。

文言をちょっと丁寧にしようとして句読点を変えただけで、=== の完全一致が外れてステータスが変わる。
新しいエラーを追加したのに、パターンリストへの追記を忘れて未分類のまま400扱いになる。
どちらも実際に起こりうる事故です。

解決策: code / status / userMessage を持つ構造化エラーに一本化する

そこで、エラーの種類・HTTPステータス・ユーザー向けメッセージを1つのオブジェクトにまとめた AppError を導入しました。

if (!context.auth) {
  throw new AppError({
    code: 'auth_user_not_found',
    status: 401,
    userMessage: 'ログインしてください。',
  })
}

エラーを送出する側は、素の Error ではなく必ずこの AppError を投げるようにします。
受け取る側のハンドラは、次のように「構造化エラーかどうか」だけを見て分岐します。

export function handleRouteError(error: unknown) {
  if (isAppError(error)) {
    if (error.status >= 500) {
      console.error('Unexpected worker error', error)
    }
    return json({ error: error.userMessage, code: error.code }, { status: error.status })
  }
  console.error('Unexpected worker error', error)
  return json({ error: GENERIC_ERROR_MESSAGE }, { status: 500 })
}

ポイントは、AppError 以外の例外を一律500に丸めていることです。

「メッセージの内容によっては4xxとして通す」という条件分岐を残すと、実装バグや想定外の例外が、意図せず4xx扱いになって外部に詳細が漏れる経路になりかねません。
未分類の例外は問答無用で500に倒す、いわゆる fail closed の原則を徹底したほうが安全です。

揺り戻しを防ぐ: 素の throw new Error を CI で検知する

構造化エラーへの一本化は、リファクタリングした瞬間はきれいでも、翌週には誰かが素の throw new Error(...) を書いてしまいがちです。

これを防ぐために、大掛かりなESLintルールを自作するのではなく、ripgrep でパターン検索するだけの小さなスクリプトをCIに組み込みました。

const args = [
  '-n',
  'throw new Error\\(',
  'worker/routes',
  'worker/services',
  '--glob',
  '!**/*.test.*',
  '--glob',
  '!**/*.spec.*',
]

const result = spawnSync('rg', args, { encoding: 'utf8' })

if (result.status === 0) {
  console.error('Plain `throw new Error(` found under worker/routes or worker/services:')
  console.error(result.stdout.trim())
  process.exit(1)
}

rg が該当箇所を1件でも見つけたら(終了コード0)CIを失敗させる、というだけの単純な仕組みです。

ASTを解析するわけではないので厳密さには限界がありますが、「対象ディレクトリで素の throw new Error( を書かせない」という目的には十分でした。
ルールを固定化するのに、必ずしも重いツールが必要なわけではないと感じたポイントです。

つまづきやすいポイント

  • 未分類の例外を条件付きで4xxにしない: 「特定の文言を含んでいたら4xxとして通す」という実装を残すと、内部情報が漏れる経路になり得ます。fail closed を徹底し、想定外の例外は一律500に倒すべきです
  • 上流API障害を汎用500に混ぜない: 外部決済APIなどの呼び出し失敗は、汎用の500ではなく502相当の専用ステータスと code で表現すると、フロントエンドやアラート側で「自分たちのバグ」と「外部サービス起因の障害」を切り分けやすくなります
  • レスポンスに code を含めないとテストがメッセージ文言に依存し続ける: レスポンスボディに error メッセージだけでなく code フィールドを含めることで、E2E・統合テスト側も文言に依存せず「どのエラー種別が返ってきたか」を検証できるようになります
  • 認証エラー時のユーザー向け表示を忘れがち: セッション切れ(401)を検知してゲスト状態に戻す処理はあっても、画面へのエラーメッセージ表示が抜けていると、ユーザーには「何も言わずに突然ログアウトされた」ように見えてしまいます。エラーハンドリングを直すときは、正常系だけでなく画面表示までセットで見直す必要があります

まとめ

エラーメッセージの文字列マッチングでステータスを決める実装は、一見動いていても、文言変更のたびに壊れる技術的負債になりがちです。

code・status・userMessage を持つ構造化エラーに一本化し、未分類の例外は fail closed で一律500に倒す、という2つの原則がその対策になります。

さらに、素の throw new Error(...) を防ぐCIチェックは、必ずしも大掛かりなツールでなくても、ripgrep 一発のスクリプトで十分に効果が出ることも多いです。

上流API障害の502分離やレスポンスの code フィールド化、認証エラー時のUX確認まで含めて見直すと、エラーハンドリング全体の一貫性がぐっと上がります。