🌊

第5章 RML-2の失敗設計と運用パターン — 例外・Observability・ガバナンス

に公開

RML-2の失敗設計と運用パターン — 例外・Observability・ガバナンス

『The Worlds of Distributed Systems』第5章


「例外は全部キャッチして、ユーザーには
『エラーが発生しましたもう一度お試しください』って出せばいいよね?」

分散システムでこれをやると、世界が燃えます

第1章で、ロールバックを

  • RML-1 — Closed World(閉じた世界)
  • RML-2 — Dialog World(対話の世界)
  • RML-3 — History World(歴史の世界)

という三つの世界で整理しました

この章では、その中でも現場で一番よく踏み抜く RML-2(対話の世界) にフォーカスして、

  • 例外・失敗を「どの世界の出来事か」で分類する
  • それに応じて「どこで握るか・どこまで伝えるか」を決める

という設計パターンをまとめてみます


1. まず整理したい「失敗の世界」

RML の話を「失敗」に引き寄せると、ざっくりこんなイメージになります

World どんな失敗? 影響範囲
RML-1 プロセス内だけで完結する失敗 自分のメモリ・一時ファイル メモリ不足、内部バリデーション失敗
RML-2 他サービス / ユーザーとの対話中の失敗 自サービス + 近隣サービス + 一部ユーザー 下流サービスの一時障害、サーガ途中の失敗
RML-3 すでに「歴史」として扱うべきレベルの失敗 組織や社会と共有される現実 決済の二重引き落とし、誤送信、コンプラ事故

この章で扱うのは RML-2 の世界で起きる失敗 です

RML-2 では、

  • 例外が起きた瞬間に、

    • 「自分の中だけで完結させてよいのか」
    • 「相手との会話(サーガ)として巻き戻すのか」
    • 「これはもう RML-3 にエスカレーションすべきか」

を、ある程度パターンとして決めておく必要があります

ここが曖昧なままだと、

RML-3 レベルの事故を、RML-1 で片付いたことのように見せかけてしまう

という、わりと致命的なすれ違いが起きます

このすれ違いにより、SREは徹夜ドリブンするのです


2. RML-2の三層モデル:Local / Dialog / History-bound

RML-2 の世界で例外を見ていると、ざっくり次の三層に分けると扱いやすくなります

  1. Local Failure(局所的な失敗)
  2. Dialog Failure(対話上の失敗)
  3. History-bound Failure(歴史に送るべき失敗)

2.1 Local Failure — まだ閉じた世界で完結している

  • まだ「外の世界」に何も出ていない段階の失敗
  • 入力検証、内部状態の不整合、バッチ内の一件だけのエラーなど

扱いとしては RML-1 と同じ です

  • ローカルでロールバック(リトライ or 中断)
  • ログに残す
  • 呼び出し元には「普通のエラー」として返す

このレベルの失敗をいきなり「重大インシデント扱い」すると、運用がすぐに疲弊します

2.2 Dialog Failure — 相手との会話の中で発生した失敗

  • 下流サービスへの RPC がタイムアウトした
  • サーガの 3 ステップ目で失敗した
  • イベントを publish しようとしたが broker が落ちていた

こういう失敗は、対話の一部として扱うべきエラーです

  • 「サーガ全体としてどうするか?」
  • 「補償トランザクションを走らせるか?」
  • 「その場のリトライで済ませてよいか?」

といった判断が必要になります

ここを

catch (Exception e) して 500 返して終わり

にしてしまうと、「会話」が途中でちぎれてサーガの整合性が壊れます

2.3 History-bound Failure — もはや歴史行きの失敗

RML-2 のつもりで設計していても、実際には

  • 決済がすでに外部で確定している
  • メールや通知がユーザーに届いてしまっている
  • 法的・契約上、「取り消したこと自体を記録しないといけない」

といったケースがあります

このレベルはRML-3 に送るべき失敗です

  • 「過去を消す」ことはできないので、

    • 返金
    • 訂正
    • お知らせ(ユーザーへの説明)
  • などを「修正イベント」として積み上げる必要があります

RML-2 のコードの中から見えるのは「例外」かもしれませんが、扱いとしてはすでに RML-3 の世界 だ、という意識が重要です


3. 例外のラベル付け:Error → (World, Severity, Action)

では、実際のコードの中ではどう扱うか

ざっくり

例外を「構造化」する

ところから始めるのが安全です

極端に書くと、例外はこんな型で扱うイメージです

type World = "RML1" | "RML2" | "RML3";

type Severity = "info" | "warn" | "error" | "critical";

type ActionHint =
  | "retry-local"        // その場でリトライしてよい
  | "retry-with-backoff" // バックオフ付きのリトライ
  | "start-compensation" // サーガの補償を開始する
  | "escalate-history"   // RML-3 にエスカレーション
  | "abort";             // その場で中断

type StructuredError = {
  world: World;
  severity: Severity;
  action: ActionHint;
  code: string;
  message: string;
  cause?: unknown;
};

もちろん、現場のコードではここまで綺麗な形にはしづらいですが、

  • world(どの世界の失敗か)
  • action(呼び出し元にどうしてほしいか)

だけでも明示しておくと、例外ハンドリングが「世界観ベース」で議論できるようになります


4. RML-2における代表的な例外ハンドリングパターン

ここからは、RML-2 の世界でよく出てくるハンドリングパターンをいくつか見ていきましょう

パターンA:Local Failure はその場で完結させる

まずは一番シンプルなパターン

  • 入力バリデーションエラー
  • 自プロセス内の前提違反(assertion)
  • まだ外部への effect が何も起きていない段階

これは素直に RML-1 として扱ってよい例外です

if (!input.isValid()) {
  throw new StructuredError({
    world: "RML1",
    severity: "warn",
    action: "abort",
    code: "INVALID_INPUT",
    message: "入力が不正です",
  });
}

呼び出し側は

  • ユーザーにバリデーションエラーを返す
  • サーガ全体としては「そもそも開始されなかった」扱いにする

という感じで、対話レベルまで上げなくても大丈夫です


パターンB:Dialog Failure は「サーガの文脈」で扱う

RML-2 の世界らしいのがここです

async function reserveStock(orderId: string): Promise<void> {
  try {
    await stockService.reserve(orderId);
  } catch (e) {
    throw new StructuredError({
      world: "RML2",
      severity: "error",
      action: "start-compensation",
      code: "STOCK_RESERVE_FAILED",
      message: "在庫の確保に失敗しました",
      cause: e,
    });
  }
}

呼び出し側(サーガ実行側)は

  • world === "RML2" && action === "start-compensation" をトリガに
  • それまでのステップの補償を実行する

という感じで「サーガとしてのロールバック」に切り替えます

ここで重要なのは、

「失敗したから 500 にして終わり」ではなく
「サーガの設計段階で、この失敗をどう扱うかを決めておく」

という点です


パターンC:History-bound Failure は「エスカレーション前提」で返す

RML-2 のコードから見えるエラーの中には、どう見ても「歴史行き」のものがあります

  • 決済ゲートウェイからの「このトランザクションはすでに確定済みです」
  • 外部システムからの「キャンセル期限を過ぎています」
  • コンプライアンス系 API からの「この操作は禁止されています」

こういう例外は、無理にサーガの undo() で帳尻を合わせようとすると危険です

async function cancelPayment(paymentId: string): Promise<void> {
  try {
    await paymentGateway.cancel(paymentId);
  } catch (e) {
    if (isAlreadySettledError(e)) {
      throw new StructuredError({
        world: "RML3",
        severity: "critical",
        action: "escalate-history",
        code: "PAYMENT_ALREADY_SETTLED",
        message: "決済がすでに確定済みのため、キャンセルできません",
        cause: e,
      });
    }
    // それ以外は RML-2 の一時的な障害として扱う
    throw new StructuredError({
      world: "RML2",
      severity: "error",
      action: "retry-with-backoff",
      code: "PAYMENT_CANCEL_FAILED",
      message: "決済キャンセルに失敗しました(リトライ可能)",
      cause: e,
    });
  }
}

ここで

  • world === "RML3" の例外は、インシデント管理・Effect Ledger・法務対応の世界へ
  • world === "RML2" の例外は、リトライ or サーガ補償の世界へ

と、明確に分岐させることがポイントです


5. 典型的なアンチパターン

逆に、「これはやめておいた方がいい」というパターンも挙げておきます

5.1 catch-all で全部 500 にする

try {
  // いろいろ
} catch (e) {
  logger.error(e);
  return res.status(500).json({ message: "Internal Server Error" });
}

これをやると、

  • RML-1 の単純ミス
  • RML-2 のサーガ途中の失敗
  • RML-3 にエスカレートすべき事故

が、全部「500」の中に埋もれます

現場で起きること:

  • 一見「ロールバックできた」ように見える
  • でも実際は RML-3 の事故が静かに溜まっていく
  • ある日まとめて発覚して地獄を見る

5.2 「とりあえずリトライ」ループ

  • RML-3 レベルの失敗に対しても、とりあえず自動リトライ
  • 失敗が溜まり続け、外部システムに DoS のような負荷をかける
  • 最悪、同じ誤操作を何度も繰り返す

「リトライで直るかどうか」は 世界観依存です

  • RML-2 の一時障害 → リトライは有効
  • RML-3 の確定済みエラー → リトライではどうにもならない(むしろ悪化)

5.3 例外に世界観が書かれていない

  • エラーメッセージを読んでも、「それ、どの世界の失敗?」がわからない
  • 呼び出し側が「多分これくらいだろう」と勝手に解釈する
  • 結果として、RML-2 のコードが RML-3 レベルの事故を隠してしまう

6. Observabilityとの接続 — worldタグをSLO/アラートに繋ぐ

StructuredError の worldseverity は、運用の優先順位付けとも非常に相性が良いです

たとえば Datadog や OpenTelemetry を使っているなら、

  • ログのフィールド
  • メトリクスやトレースのタグ(attribute)

として、そのまま付けてしまいます

logger.error({
  err: structuredError,
  world: structuredError.world,
  severity: structuredError.severity,
  action: structuredError.action,
  code: structuredError.code,
});

OTel であれば、span に属性として付与しておくイメージです

span.setAttribute("rml.world", structuredError.world);
span.setAttribute("rml.action", structuredError.action);
span.setAttribute("rml.code", structuredError.code);

こうしておくと、SRE チームは次のような運用ポリシーを引けます

  • RML-3 の critical は深夜でも叩き起こす

    • PagerDuty / Opsgenie のルールで

      • rml.world = "RML3" AND severity = "critical"
        をオンコール必須に
  • RML-2 の error は営業時間内に対応

    • 「今すぐ燃え広がるわけではないが、放置すると痛い」レベル
  • RML-1 の warn は翌朝でよい

    • ローカルな失敗の傾向を見るためのメトリクスとして蓄積

世界観ベースのタグがあることで、

「全部アラートにするか、全部無視するか」の二択ではなく、
「どの世界のアラートを、どの時間帯に、誰に飛ばすか」

という、もう一段階細かい調整が可能になります

SRE チームと話すときは、

  • どの world と severity の組み合わせを SLO/SLA に結びつけるか
  • どの world は「夜中には起こさない」のか

といった線引きを、RML の表と一緒に決めてしまうと会話が楽になりますよ


7. ActionHintの強制力 — 守らせる仕組みを先に作る

ActionHint は「このエラーに対して、呼び出し側にどうしてほしいか」を表すヒントですが、

ヒントは往々にして無視されるものです

アンチパターン 5.2 で挙げたように、

  • ActionHint が escalate-history なのに、とりあえず自動リトライ
  • start-compensation なのに、補償処理を実装していない

といった地獄は、設計だけでは防げません

ここでは、「守らせる」ための仕組みをいくつか挙げます

7.1 クライアントライブラリにロジックを寄せる

一つのやり方は、ActionHint に従うロジックをクライアントライブラリ側に閉じ込めることです

async function callWithRmlHandling<T>(f: () => Promise<T>): Promise<T> {
  try {
    return await f();
  } catch (e) {
    const err = toStructuredError(e);

    switch (err.action) {
      case "retry-local":
        return await f();
      case "retry-with-backoff":
        await sleep(backoff());
        return await f();
      case "start-compensation":
        await startCompensationFlow(err);
        throw err;
      case "escalate-history":
        await notifyIncident(err);
        throw err;
      case "abort":
      default:
        throw err;
    }
  }
}

アプリケーションコード側では、なるべく

await callWithRmlHandling(() => someRmlAwareApiCall());

のように、「共通の入口」を通すことをルール化します

これによって、

  • ActionHint を無視して独自リトライする
  • escalate-history を握りつぶす

といったことがやりづらくなります

7.2 APIレスポンスにも world を埋め込む

もう一つのパターンは、HTTP レスポンス側にも RML 情報を載せることです

HTTP/1.1 409 Conflict
Content-Type: application/json
X-RML-World: RML3
X-RML-Action: escalate-history
Retry-After: 0

{
  "code": "PAYMENT_ALREADY_SETTLED",
  "message": "決済がすでに確定済みのため、キャンセルできません"
}
  • X-RML-WorldRML1/2/3
  • X-RML-Actionretry-local / retry-with-backoff / escalate-history など

を入れておくと、

  • API Gateway
  • BFF(Backend for Frontend)

などの共通レイヤーで、

  • 「RML3 + escalate-history なのに、クライアントが無限リトライしていないか?」

といった監視が可能になります

7.3 ガバナンスとしての「テスト」と「Lint」

最後は文化の話ですが、

  • ActionHint を解釈していないコードを検知する Lint
  • world: "RML3" の例外を、そのままユーザーに 500 として返すテストを落とす

といった 自動チェックを入れておくと、

「わかってはいたけど、忙しくて対応できていない」

を、ある程度防ぎやすくなります


8. 実務で使えるチェックリスト

最後に、RML-2 の例外設計をする際の簡易チェックリストを置いておきます

8.1 例外クラス/エラーコード設計のとき

  • この例外は、どの World(RML-1/2/3)の失敗として扱うべきか?

  • 呼び出し側に期待するアクションは何か?

    • その場で中断
    • リトライ
    • サーガ補償の開始
    • RML-3 へのエスカレーション
  • ログやトレースには「world」「action」「code」が残るようになっているか?

  • 監視・アラート設定で、world / severity を優先度に反映しているか?

8.2 サーガ/ワークフロー設計のとき

  • 各ステップの do/undo に対して、

    • RML-1 レベルの失敗
    • RML-2 レベルの失敗
    • RML-3 レベルにエスカレートすべき失敗
      が、それぞれ何か言語化されているか?
  • 「このステップで RML-3 が発生したとき」の扱い(人間オペ・インシデント)が決まっているか?

  • ActionHint に従う共通のハンドリングレイヤー(クライアントライブラリ等)があるか?

8.3 エンドポイント/API設計のとき

  • この API は、基本的にどの World を前提にしているか?

    • 完全に RML-1 の想定(内部専用)
    • RML-2 の対話前提
    • 実質 RML-3(外部決済など)
  • RML-3 レベルの失敗が起きたとき、

    • ステータスコードやレスポンスヘッダで、それがわかるようになっているか?
    • ログ・監査トレイルに、十分な情報が残るか?

9. おわりに — 「例外を世界ごとに分ける」と、設計の会話がしやすくなる

RML-2 の世界で例外を扱うとき、ポイントはシンプルです

例外を
「技術的な失敗」ではなく
「どの世界で起きた出来事か」
という目で見る

  • RML-1 の失敗は、ローカルで完結させてよい
  • RML-2 の失敗は、サーガや対話として扱う
  • RML-3 の失敗は、歴史として認めて、前向きの修正を積み上げる

この「世界観ベースのラベル付け」ができていると、

  • エンジニア同士の会話も
  • エンジニアとビジネス側・法務側・SRE の会話も

だいぶ楽になります

後の章(仮)では、この RML-3 の世界に踏み込んだときに必要になる

「RML-3時代の組織論(法務とエンジニアの協調)」

を、もう少し具体的に掘り下げていく予定です

Discussion