Skip to main content

エラーハンドリング — 正常系・準正常系・異常系

エラー処理を「例外を try-catch で囲むこと」だと捉えると、すべての失敗が同じ扱いになります。その結果、ユーザーの入力ミスがサーバー障害として通知され、本当の障害が大量のノイズに埋もれます。

有効なのは、起こりうる結果を 3 つに分類してから扱い方を決めることです。

この章で学ぶこと

  • 正常系・準正常系・異常系の区別と、その境界
  • 想定内の失敗と想定外の失敗で扱いを変える理由
  • 例外と戻り値の使い分け
  • どの層でエラーを捕まえるか
  • ユーザーとログに何を伝えるか
前提知識

HTTP のステータスコードの分類を使います。

この章で扱わないこと

例外クラスの階層設計、HTTP ステータスへの対応付け、統一エラーレスポンスの形式は既存のガイドが扱います。

3 つの分類

分類定義起きたときの扱い
正常系想定どおりに成功したそのまま結果を返す
準正常系想定内の失敗。仕様として起こりうるユーザーに理由を伝えて次の行動を促す
異常系想定外の失敗。仕様として起きてはならない検知して記録する。ユーザーには詳細を出さない

境界は「仕様として想定しているか」です。エラーかどうかではありません。

準正常系は失敗ではあるが、起きても不思議ではない結果です。設計の時点で「こうなったらこう返す」と決めてあります。異常系は決めていなかったこと、あるいは決めようがないことです。

具体例

会員登録の処理で 3 つを並べます。

分類
正常系入力が妥当で、登録が完了した
準正常系メールアドレスが既に登録されていて、登録できなかった
異常系データベースへの接続が切れて、処理を完了できなかった

もう少し例を挙げます。

場面正常系準正常系異常系
ログイン認証に成功したパスワードが違う認証サーバーが応答しない
決済決済が完了した残高不足で決済できない決済 API がタイムアウトした
ファイル取得ファイルを返した指定された ID のファイルが無いストレージがマウントされていない
検索3 件見つかった0 件だった検索インデックスが壊れている

検索の 0 件は準正常系ですらなく、正常系として扱うのが自然な場合もあります。「見つからなかった」が仕様上ふつうに起こる結果で、呼び出し側が空の一覧として扱えるなら、失敗ではありません。分類は絶対的なものではなく、その処理の仕様がどう定めているかで決まります。

なぜ分けるのか

3 つを混ぜると、次の 3 つが同時に壊れます。

混ぜると何が起きるか
ユーザーへの伝え方入力ミスに「システムエラーが発生しました」と出る。直し方が分からない
監視入力ミスがアラートを鳴らす。慣れて本物の障害を見逃す
ログ準正常系がエラーログを埋め尽くし、異常系が埋もれる

分けたあとの扱いは次のようになります。

準正常系異常系
ログレベルinfo または warnerror
アラート鳴らさない (件数の傾向は見る)鳴らす
ユーザーへの表示具体的な理由と次の行動一般的なメッセージ + 問い合わせ用の識別子
HTTP ステータス4xx5xx
再現性同じ入力なら必ず同じ結果環境や時刻に依存する

準正常系で 5xx を返さないのは重要です。5xx は「こちらの問題です」という宣言で、クライアントは再試行してよいと解釈します。入力ミスで再試行されても結果は変わりません。HTTP のステータスコードの分類が判断の土台になります。

例外か、戻り値か

準正常系をどう表現するかには 2 つの方式があります。

// 方式 A: 例外を投げる
function register(email: string): User {
if (await exists(email)) {
throw new EmailAlreadyUsedError(email);
}
return save(email);
}

// 方式 B: 結果型で返す
type Result<T, E> = {ok: true; value: T} | {ok: false; error: E};

function register(email: string): Result<User, 'email_already_used'> {
if (await exists(email)) {
return {ok: false, error: 'email_already_used'};
}
return {ok: true, value: save(email)};
}
例外結果型
呼び出し側の扱い書かなくてもコンパイルは通る型が処理を強制する
読みやすさ正常系の流れがすっきりする分岐が増える
深い階層からの脱出得意各段で伝播を書く必要がある
向く用途異常系、深い階層からの中断準正常系

**準正常系は結果型のほうが向いています。**想定内の失敗なら呼び出し側が必ず対応すべきで、型で強制できると漏れません。例外だと、握りつぶしたり書き忘れたりしても気づけません。

ただし言語やフレームワークの流儀があります。例外が前提の環境で結果型だけを使うと、ライブラリとの接続で変換が増えます。方式を混在させず、プロジェクト内で統一するほうが効果が大きくなります。

**異常系は例外が適切です。**想定していないので呼び出し側に対処のしようがなく、深い階層から一気に脱出して上位でまとめて記録するのが合っています。

どこで捕まえるか

発生した場所で捕まえないのが原則です。

// 悪い例: その場で握りつぶす
try {
await sendEmail(user);
} catch (e) {
console.log(e); // 呼び出し側は失敗を知らないまま進む
}

エラーは、**対処できる層まで伝播させます。**低い層は「何が起きたか」を知っていますが、「どうすべきか」は知りません。判断できるのは上位です。

非同期の処理では、伝播する経路が書いた順と一致しません。await を付け忘れた呼び出しの失敗は、その try を抜けたあとに起きるので捕まりません (イベントループ)。

各層の責務は次のように分かれます。

やること
インフラ層技術的な失敗を検出して投げる。握りつぶさない
ドメイン・ユースケース層業務上の意味に変換する。準正常系はここで判定する
プレゼンテーション層HTTP ステータスとメッセージへ変換する。ここで一元的に捕まえる

アプリケーションの入口に共通のハンドラを 1 つ置くのが基本形です。個々の処理で try-catch を書き散らすと、扱いが不統一になり漏れます。

例外的に、その場で捕まえてよい場面もあります。

  • 失敗しても処理を続けてよいもの (通知の送信失敗など)。ただし記録は必ず残します
  • リトライする場合。ただし回数と間隔を決めます (リトライと冪等性)
  • 別の手段へ切り替える場合 (キャッシュが読めなければ元データを取りに行く、など)

何を伝えるか

伝える相手によって、出すべき情報が正反対になります。

ユーザーログ
準正常系理由と次の行動を具体的に何が起きたかを簡潔に
異常系一般的な表現 + 識別子原因究明に必要なすべて
// 準正常系: 何をすればよいかが分かる
"このメールアドレスは既に登録されています。ログインするか、別のアドレスをお使いください。"

// 異常系: 内部の事情は出さない。追跡できる識別子は出す
"処理を完了できませんでした。しばらくしてからお試しください。(エラー ID: a3f8c2)"

異常系でスタックトレースや SQL をユーザーに見せてはいけません。テーブル名やファイルパスは攻撃者にとって有用な情報です。代わりに追跡用の識別子を出しておくと、問い合わせを受けたときにログと突き合わせられます。

ログ側では逆に、原因究明に必要な情報をすべて残します。ただし**パスワードやトークンなどの秘匿情報は必ずマスクします。**ログは長く残り、閲覧できる人も多いためです。

記述で答えるときの骨子

「正常系・準正常系・異常系それぞれの具体例を挙げてください」と問われたときの組み立て方です。

  1. 分類の基準を先に述べる — 「仕様として想定しているかどうか」で分かれる
  2. 1 つの処理で 3 つを並べる — バラバラの処理から例を引くより、同じ処理の 3 通りの結果として示すほうが対比が明確になる
  3. 扱いの違いに触れる — 準正常系はユーザーに理由を伝え、異常系は記録して一般的なメッセージを返す

同じ処理で揃えるのが要点です。「正常系はログイン成功、準正常系はファイルが無い、異常系は DB 障害」のように処理がばらつくと、分類の基準が伝わりません。

解答例です。

会員登録処理を例にします。正常系は、入力が妥当で登録が完了した場合です。準正常系は、メールアドレスが既に登録されていて登録できなかった場合で、仕様として想定している失敗なので、理由をユーザーに伝えて別のアドレスを促します。異常系は、データベースへの接続が切れて処理を完了できなかった場合で、想定外の失敗なのでログに記録して監視へ通知し、ユーザーには一般的なメッセージを返します。

よくある誤解

「エラーはすべて異常系」 — 想定内の失敗は準正常系です。入力ミスや残高不足は仕様として起こりうる結果で、障害ではありません。

「準正常系も 500 で返してよい」5xx は「サーバー側の問題」の宣言です。クライアントは再試行してよいと解釈します。準正常系は 4xx を返します。

「エラーは発生した場所で捕まえる」 — 対処できる層まで伝播させます。低い層は何が起きたかを知っていても、どうすべきかは知りません。

「ユーザーには詳しい情報を出すほうが親切」 — 準正常系はそのとおりですが、異常系で内部の詳細を出すのは危険です。追跡用の識別子だけを出します。

「例外を使えばエラー処理は十分」 — 準正常系は結果型のほうが漏れにくくなります。型で対応を強制できるためです。

確認問題

問 1. ログイン処理について、正常系・準正常系・異常系の具体例を 1 つずつ挙げてください。

答え:

分類扱い
正常系正しいメールアドレスとパスワードで認証に成功したセッションを発行して画面へ遷移する
準正常系パスワードが違う401 を返し「メールアドレスまたはパスワードが正しくありません」と表示する
異常系認証情報を保持しているデータベースへ接続できない500 を返し、エラーを記録して監視へ通知する

準正常系のメッセージで「メールアドレスまたはパスワード」とぼかしているのは意図的です。「パスワードが違います」と返すと、そのメールアドレスが登録済みであることを攻撃者に教えてしまいます。準正常系でも、伝える情報量にはセキュリティ上の判断が入ります。

「アカウントがロックされている」も準正常系です。仕様として定めた失敗で、ユーザーには解除の方法を案内します。

問 2. 次のコードの問題点を指摘してください。
async function createOrder(input: OrderInput) {
try {
const user = await userRepo.find(input.userId);
const order = await orderRepo.save(new Order(user, input.items));
await sendConfirmationEmail(user, order);
return order;
} catch (e) {
console.log(e);
return null;
}
}

答え: 3 つの問題があります。

1. 3 種類の失敗を同じ扱いにしている

userRepo.find の「ユーザーが見つからない」(準正常系)、orderRepo.save の「DB 障害」(異常系)、sendConfirmationEmail の「メール送信失敗」(続行可能) が、すべて同じ catch に落ちます。呼び出し側は null を受け取るだけで、何が起きたか区別できません。

2. メール送信の失敗で注文まで無かったことになる

注文は保存できているのに null が返るので、呼び出し側は失敗したと判断します。実際にはデータが残っているため、ユーザーが再試行すると二重注文になります。続行してよい失敗は、その場で捕まえて記録し、処理は進めます。

3. null を返して情報を捨てている

呼び出し側は理由を知る手段がありません。ログにも console.log で出しているだけで、レベル分けも文脈も付いていません。

改善の方向:

async function createOrder(input: OrderInput): Promise<Result<Order, OrderError>> {
const user = await userRepo.find(input.userId);
if (!user) return {ok: false, error: 'user_not_found'}; // 準正常系

// DB 障害は投げたまま上位へ伝播させる (異常系)
const order = await orderRepo.save(new Order(user, input.items));

// 続行してよい失敗はここで捕まえるが、記録は残す
try {
await sendConfirmationEmail(user, order);
} catch (e) {
logger.warn('確認メールの送信に失敗', {orderId: order.id, error: e});
}

return {ok: true, value: order};
}
問 3. 「500 エラーが 1 日 300 件出ている」と報告を受けました。調べると全部「入力値が不正」でした。何が問題ですか。

答え: 準正常系を異常系として扱っているため、監視が機能していません。

入力値の不正は仕様として想定している失敗なので準正常系です。返すべきは 400422 で、500 ではありません。

これが起こす害は 3 つあります。

内容
監視が死ぬ300 件のノイズに慣れて、本物の障害の 1 件を見逃す
ユーザーが直せない「システムエラー」と出るので、入力を直せば通ることが分からない
クライアントが再試行する5xx は再試行してよいという合図なので、同じ失敗を繰り返す

対処の順序は次のとおりです。

  1. バリデーション失敗を 4xx へ変える400 (形式が不正) と 422 (形式は正しいが業務ルールに反する) を使い分ける
  2. ログレベルを下げるerror から infowarn
  3. アラートの条件を見直す5xx の件数で鳴らす。4xx は傾向を見るダッシュボードに置く
  4. 件数の急増は別途見る4xx が急に増えたら、UI の不具合や攻撃の可能性があります

4 番目も大事です。**準正常系だから無視してよいわけではありません。**アラートを鳴らさないだけで、傾向は観測します。

まとめ

  • 結果は正常系・準正常系・異常系の 3 つに分かれます。境界は「仕様として想定しているか」です
  • 準正常系は想定内の失敗 (入力ミス、残高不足)、異常系は想定外の失敗 (DB 障害、タイムアウト) です
  • 混ぜると、ユーザーへの伝え方・監視・ログの 3 つが同時に壊れます
  • 準正常系は 4xx、異常系は 5xx5xx は再試行してよいという合図になります
  • 準正常系は結果型、異常系は例外が向いています
  • **エラーは発生場所ではなく、対処できる層まで伝播させます。**入口に共通のハンドラを置きます
  • ユーザーには準正常系の理由を具体的に、異常系は一般的な表現と追跡用の識別子を返します
  • ログには原因究明に必要な情報を残し、秘匿情報はマスクします
関連リファレンス

次に読む