エラーハンドリング — 正常系・準正常系・異常系
エラー処理を「例外を try-catch で囲むこと」だと捉えると、すべての失敗が同じ扱いになります。その結果、ユーザーの入力ミスがサーバー障害として通知され、本当の障害が大量のノイズに埋もれます。
有効なのは、起こりうる結果を 3 つに分類してから扱い方を決めることです。
この章で学ぶこと
- 正常系・準正常系・異常系の区別と、その境界
- 想定内の失敗と想定外の失敗で扱いを変える理由
- 例外と戻り値の使い分け
- どの層でエラーを捕まえるか
- ユーザーとログに何を伝えるか
HTTP のステータスコードの分類を使います。
この章で扱わないこと
例外クラスの階層設計、HTTP ステータスへの対応付け、統一エラーレスポンスの形式は既存のガイドが扱います。
3 つの分類
| 分類 | 定義 | 起きたときの扱い |
|---|---|---|
| 正常系 | 想定どおりに成功した | そのまま結果を返す |
| 準正常系 | 想定内の失敗。仕様として起こりうる | ユーザーに理由を伝えて次の行動を促す |
| 異常系 | 想定外の失敗。仕様として起きてはならない | 検知して記録する。ユーザーには詳細を出さない |
境界は「仕様として想定しているか」です。エラーかどうかではありません。
準正常系は失敗ではあるが、起きても不思議ではない結果です。設計の時点で「こうなったらこう返す」と決めてあります。異常系は決めていなかったこと、あるいは決めようがないことです。
具体例
会員登録の処理で 3 つを並べます。
| 分類 | 例 |
|---|---|
| 正常系 | 入力が妥当で、登録が完了した |
| 準正常系 | メールアドレスが既に登録されていて、登録できなかった |
| 異常系 | データベースへの接続が切れて、処理を完了できなかった |
もう少し例を挙げます。
| 場面 | 正常系 | 準正常系 | 異常系 |
|---|---|---|---|
| ログイン | 認証に成功した | パスワードが違う | 認証サーバーが応答しない |
| 決済 | 決済が完了した | 残高不足で決済できない | 決済 API がタイムアウトした |
| ファイル取得 | ファイルを返した | 指定された ID のファイルが無い | ストレージがマウントされていない |
| 検索 | 3 件見つかった | 0 件だった | 検索インデックスが壊れている |
検索の 0 件は準正常系ですらなく、正常系として扱うのが自然な場合もあります。「見つからなかった」が仕様上ふつうに起こる結果で、呼び出し側が空の一覧として扱えるなら、失敗ではありません。分類は絶対的なものではなく、その処理の仕様がどう定めているかで決まります。
なぜ分けるのか
3 つを混ぜると、次の 3 つが同時に壊れます。
| 混ぜると | 何が起きるか |
|---|---|
| ユーザーへの伝え方 | 入力ミスに「システムエラーが発生しました」と出る。直し方が分からない |
| 監視 | 入力ミスがアラートを鳴らす。慣れて本物の障害を見逃す |
| ログ | 準正常系がエラーログを埋め尽くし、異常系が埋もれる |
分けたあとの扱いは次のようになります。
| 準正常系 | 異常系 | |
|---|---|---|
| ログレベル | info または warn | error |
| アラート | 鳴らさない (件数の傾向は見る) | 鳴らす |
| ユーザーへの表示 | 具体的な理由と次の行動 | 一般的なメッセージ + 問い合わせ用の識別子 |
| HTTP ステータス | 4xx | 5xx |
| 再現性 | 同じ入力なら必ず同じ結果 | 環境や時刻に依存する |
準正常系で 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 つの処理で 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 件出ている」と報告を受けました。調べると全部「入力値が不正」でした。何が問題ですか。
答え: 準正常系を異常系として扱っているため、監視が機能していません。
入力値の不正は仕様として想定している失敗なので準正常系です。返すべきは 400 か 422 で、500 ではありません。
これが起こす害は 3 つあります。
| 害 | 内容 |
|---|---|
| 監視が死ぬ | 300 件のノイズに慣れて、本物の障害の 1 件を見逃す |
| ユーザーが直せない | 「システムエラー」と出るので、入力を直せば通ることが分からない |
| クライアントが再試行する | 5xx は再試行してよいという合図なので、同じ失敗を繰り返す |
対処の順序は次のとおりです。
- バリデーション失敗を
4xxへ変える —400(形式が不正) と422(形式は正しいが業務ルールに反する) を使い分ける - ログレベルを下げる —
errorからinfoかwarnへ - アラートの条件を見直す —
5xxの件数で鳴らす。4xxは傾向を見るダッシュボードに置く - 件数の急増は別途見る —
4xxが急に増えたら、UI の不具合や攻撃の可能性があります
4 番目も大事です。**準正常系だから無視してよいわけではありません。**アラートを鳴らさないだけで、傾向は観測します。
まとめ
- 結果は正常系・準正常系・異常系の 3 つに分かれます。境界は「仕様として想定しているか」です
- 準正常系は想定内の失敗 (入力ミス、残高不足)、異常系は想定外の失敗 (DB 障害、タイムアウト) です
- 混ぜると、ユーザーへの伝え方・監視・ログの 3 つが同時に壊れます
- 準正常系は
4xx、異常系は5xx。5xxは再試行してよいという合図になります - 準正常系は結果型、異常系は例外が向いています
- **エラーは発生場所ではなく、対処できる層まで伝播させます。**入口に共通のハンドラを置きます
- ユーザーには準正常系の理由を具体的に、異常系は一般的な表現と追跡用の識別子を返します
- ログには原因究明に必要な情報を残し、秘匿情報はマスクします
- Laravel × DDD ガイド — エラーハンドリング — 例外クラスの階層と層ごとの責務
- Laravel API 開発ガイド — エラーレスポンス — 統一エラーレスポンスの形式
次に読む
- リトライと冪等性 — 異常系のうち「再送で復帰できるもの」の設計