0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

デバッグと原因切り分けをしやすくするエラー設計

0
Posted at

はじめに

外部 API や下流サービスの一時的な障害で、画面全体をエラーにしたくない場面があります。BFF やバックエンドでフォールバックを返せば、利用者は操作を続けられます。

一方で、取得失敗を空配列や初期値へ置き換えると、「データがない」と「データを取得できなかった」を区別できなくなります。見た目の可用性は上がっても、どの層で問題が起きたのかを追えず、障害の検知、調査、復旧判断が難しくなります。

この記事では、フォールバックを使いながら障害を隠さず、デバッグと原因切り分けをしやすくするために、レスポンス、ログ、キャッシュをどう設計するかを整理します。

問題は空配列を返すことではない

一覧 API を考えます。正常に検索し、対象が0件だった場合のレスポンスは次のとおりです。

200 OK

{
  "items": []
}

この [] は「検索を実行した結果、該当データがなかった」という意味です。

ここで、下流 API のタイムアウトやレート制限を同じ [] に変換すると、意味が変わります。

async function listOrders() {
  try {
    return await orderApi.list();
  } catch {
    return { items: [] };
  }
}

この実装では、呼び出し側から次の2つを見分けられません。

  • 検索は成功し、注文が0件だった
  • 検索に失敗し、注文の有無は不明である

フォールバックそのものが問題なのではありません。異なる状態を同じ値に畳み込むことが問題です。

エラーを消すと原因の切り分けが止まる

障害調査では、画面から下流へ向かって事実を確認します。各層の結果が残っていれば、問題の範囲を少しずつ狭められます。

画面では0件
  ↓
BFFは正常な0件として返している
  ↓
業務サービスは下流のタイムアウトを記録している
  ↓
外部サービスの応答遅延を確認する

しかし、業務サービスや BFF が例外を空配列へ変換し、ログにも残さなければ、確認できる事実は「画面が0件」のみです。この状態では、データが存在しないのか、検索条件が誤っているのか、認可で除外されたのか、下流が失敗したのかを区別できません。

障害時に必要なのは、原因を最初から当てることではありません。各層の入力、出力、失敗の有無を確認し、問題がない範囲を確定していくことです。エラー情報を残す設計は、この確認を可能にします。

可用性と正しさは別の要件

障害時の設計では、少なくとも次の要件を分けて考える必要があります。

  • 可用性: 一部の依存先が失敗しても、画面や主要な操作を利用できる
  • 正しさ: 表示したデータが、取得できた事実にもとづいている
  • 観測可能性: 障害が起きたことと、その影響範囲を運用側で把握できる

「HTTP 200 を返す」は可用性のための手段になりえます。しかし、それだけで正しさや観測可能性まで満たせるわけではありません。

特に、利用者の判断に影響する一覧、残高、請求額、在庫などでは、未取得の値を「0件」や「0円」として表示しないことが重要です。

先に状態を分ける

まず、API が表す状態を分けます。取得結果は少なくとも次の3つに分かれます。

状態 意味 利用者への扱いの例
成功・データあり 取得に成功し、データがある データを表示する
成功・0件 取得に成功し、該当データがない 空状態を表示する
取得失敗 データの有無を確認できない 再試行案内や一時的なエラーを表示する

この区別を内部だけに持つか、クライアントへ返すかは、画面の役割と障害時の振る舞いで決めます。

たとえば、補助情報の取得に失敗しても画面本体を表示したい場合は、成功したデータと取得失敗を同じレスポンスに含められます。

{
  "profile": {
    "name": "田中"
  },
  "notifications": null,
  "partial_errors": [
    {
      "target": "notifications",
      "code": "UPSTREAM_TIMEOUT",
      "retryable": true
    }
  ]
}

この形なら、画面はプロフィールを表示しつつ、通知欄には再読み込みの導線を出せます。null を使う場合も、未設定ではなく「今回は取得できなかった」という意味を API 仕様で固定する必要があります。

すべての項目で部分成功を表現する必要はありません。処理を続けると誤った操作につながる場合は、エラーとして処理を中断し、適切なステータスコードを返す方が安全です。

フォールバックを使ってよい条件

フォールバックは、依存先の失敗があっても安全に機能を縮退できる場合に有効です。判断の目安は次のとおりです。

  • 代替値が「実際の値」と誤認されない
  • 利用者がその値をもとに誤った判断や更新をしない
  • 失敗した事実をログ、メトリクス、トレースで追跡できる
  • 復旧後に再取得できる
  • フォールバックの利用が、仕様としてチームに共有されている

たとえば、ランキングの並び順を既定順に戻す、補助的なおすすめ枠を非表示にする、といった縮退は扱いやすい例です。

一方で、権限、残高、注文可能数、請求状態のように業務判断へ直結する値は、安易に代替値へ置き換えない方が安全です。

エラーを分類して扱う

すべての例外を同じように握りつぶすと、再試行すべき障害と、利用者へ通知すべき不正な入力が混ざります。少なくとも次のように分けると、振る舞いを決めやすくなります。

種類 基本的な扱い
一時的な障害 タイムアウト、接続エラー、一時的な 5xx タイムアウト、限定的な再試行、縮退を検討する
利用制限 レート制限、同時実行数の上限 待機や再試行の案内を返す
呼び出し側の不備 バリデーションエラー、権限不足 フォールバックせず原因を返す
恒久的な異常 契約不備、仕様不整合、存在しない連携先 障害として記録し、明示的に失敗させる

再試行を入れる場合も、無制限には行いません。短いタイムアウト、回数上限、指数バックオフ、呼び出し先ごとの遮断を組み合わせないと、障害時に依存先へ負荷を集中させます。

UI、BFF、業務サービスで責務を分ける

各層で同じ例外処理を重ねると、どこで失敗が消えたのか分からなくなります。責務を次のように分けると追跡しやすくなります。

  • 業務サービス: 通信エラーを分類し、業務上安全なフォールバックの可否を決める
  • BFF: 画面を継続表示できるかを判断し、部分成功や画面向けのエラー形式へ変換する
  • UI: 空状態、再試行、問い合わせ案内など、利用者に必要な状態を表示する

BFF が下流の例外を無条件で成功レスポンスへ変換すると、画面は壊れにくくなります。しかし、業務サービスが返した失敗の意味も消えます。成功へ変換する場合でも、画面と監視基盤の両方に失敗を残します。

切り分けできる形で情報をつなぐ

ログを各層で増やすだけでは、1つの利用者操作に対応する記録を追えません。UI から外部サービスまで、同じリクエスト ID またはトレース ID を引き継ぎます。

たとえば、問い合わせを受けたときに次の順で確認できる状態を作ります。

  1. 画面または BFF のログで、対象操作のリクエスト ID を確認する
  2. 同じ ID を使って、BFF がどの業務サービスを呼んだか確認する
  3. 業務サービスで、外部サービスへのリクエスト、応答時間、エラー種別を確認する
  4. フォールバックを使ったか、キャッシュを返したかを確認する

この流れがあれば、画面に表示された結果から調査を始めても、どの境界で期待と実際がずれたかを追えます。

逆に、各層が別々の ID を使う、エラーを成功へ変換した記録がない、キャッシュヒットだけを残して元の取得結果を残さない、といった状態では、調査のたびにコードや DB を広く確認することになります。

障害時の判断を暗黙知にしない

エラーを握りつぶす実装が残る背景には、「この API は補助的な情報だから空でよい」「障害時はこのログを見ればよい」といった判断が、詳しい人の頭の中にだけあることが多いです。

この状態では、新しく参加した人がレスポンスを見ても、正常な0件なのか、障害時の代替値なのかを判断できません。障害対応も、知っている人へ聞くことから始まります。調査に時間がかかるだけでなく、担当者の不在時に復旧判断ができなくなります。

暗黙知を増やさないために、少なくとも次の内容をコードと運用資料に残します。

  • API で返す成功、0件、取得失敗の意味
  • 部分成功にしてよい項目と、必ず処理を失敗させる項目
  • フォールバックの内容と利用条件
  • 障害時に確認するログ、メトリクス、ダッシュボード
  • キャッシュを使う場合の保存条件と無効化方法

すべてを長いドキュメントにまとめる必要はありません。OpenAPI のレスポンス定義、エラーコード、構造化ログの項目、運用手順に分けて、実装と一緒に更新できる形で残すことが大切です。

たとえば、items: [] が正常な0件だけを表す仕様であれば、API 定義と契約テストで固定します。取得失敗時に部分成功を返すなら、partial_errors の対象、エラーコード、再試行可否を定義します。仕様として読める状態にしておけば、実装を変更する人も障害を調査する人も、同じ前提で判断できます。

ログと監視に残す情報

障害時に必要なのは、スタックトレースだけではありません。少なくとも次の情報を構造化ログやトレースへ残します。

  • リクエスト ID とトレース ID
  • 呼び出し先と操作名
  • エラー種別、HTTP ステータス、タイムアウトの有無
  • 再試行回数とフォールバックを使ったかどうか
  • 画面や API に与えた影響

「何を返したか」も重要です。たとえば items: [] を返したなら、それが正常な検索結果なのか、部分成功として返した値なのか、フォールバックなのかを記録します。レスポンス本文を無制限に保存するのではなく、結果区分や件数のように調査に必要な情報を構造化して残します。

個人情報、認証情報、決済情報などをそのままログへ出してはいけません。識別子も必要最小限にし、マスキングやアクセス制御を前提にします。

アラートは「例外が1件出た」だけで鳴らすとノイズになりやすいため、失敗率、フォールバック率、依存先ごとの遅延、利用者影響のある失敗数を組み合わせて設計します。フォールバック率の上昇は、HTTP 200 が増えていても障害の兆候を捉えるための重要な指標です。

失敗結果をキャッシュしない

取得失敗を空配列に変換してキャッシュすると、依存先が復旧した後も誤った空状態を返し続けます。

外部 API のタイムアウト
  ↓
空配列へ変換
  ↓
空配列をキャッシュ
  ↓
復旧後も有効期限まで0件として表示

そのため、取得失敗と正常な0件は、キャッシュに保存する前に必ず区別します。空結果をキャッシュするかどうかも、データが本当に存在しないことを確認できた場合だけ検討します。

キャッシュを使う場合は、次の点も決めておくと安全です。

  • 正常結果、正常な0件、取得失敗を別の状態として扱う
  • 取得失敗の結果は原則キャッシュしない
  • 古いキャッシュを返す場合は、鮮度と利用者への表示を決める
  • 復旧後に再取得できる経路と無効化方法を用意する

テストで確認すること

正常系だけでは、障害を成功に見せていないかを確認できません。少なくとも次のケースをテストします。

  • 正常にデータを取得できる
  • 正常に取得でき、0件である
  • タイムアウト時に、空結果と区別できる応答になる
  • 再試行の上限を超えたときに呼び出しが止まる
  • 部分成功時に、表示可能なデータと失敗情報が両方返る
  • 取得失敗の結果がキャッシュされない
  • フォールバックした事実がログやメトリクスに残る
  • 同じリクエスト ID で、UI、BFF、業務サービスの記録をたどれる

特に「0件」と「取得失敗」のレスポンスが異なることは、API 契約テストで固定しておくと変更時に壊れにくくなります。

まとめ

障害時にシステムを止めない設計は重要です。しかし、取得失敗を成功や0件として扱うと、利用者と運用者の両方が障害に気づけなくなります。

フォールバックを導入するときは、次の点を守ると安全です。

  • 成功、正常な0件、取得失敗を別の状態として表す
  • 業務判断に使う値は、未取得を代替値へ置き換えない
  • フォールバックの利用をログ、メトリクス、トレースに残す
  • 取得失敗をキャッシュしない
  • UI に再試行や一時的な障害を伝える手段を用意する
  • 障害時の振る舞いと確認方法を、個人の経験ではなく仕様と運用手順に残す

可用性を高めることと、障害を見えなくすることは別です。安全に縮退しながら、異常だった事実と判断の根拠を残せる設計にすることが、誰でも復旧しやすいシステムにつながります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?