
2026/01/05 4:02
転送エラーを止め、設計から始めましょう。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
この記事では、Rust の現在のエラーハンドリングパターンが機械可読性か人間にやさしい設計のいずれかを優先し、両方を同時に満たしていないことを説明しています。実際の障害―JSON シリアライゼーションバグ(「expected ',' or '}'」)が 20 スタックフレームを経ても変わらず伝播するケース―は、元の問題が呼び出しチェーン上で移動すると意味を失う様子を示しています。
記事では一般的な Rust パターンを批判しています:
は線形ソースチェーンを前提としており、ツリー構造のエラー(例:複数の検証失敗)を表現できません。std::error::Error- バックトレース はコストが高く、非同期コードでは多くの場合無用です(多数の
フレームを表示し)、エラーの起点のみを明らかにします。GenFuture::poll() - Provide/Request API は実行時に HTTP ステータスコードを予測不可能に公開し、リトライすべきかログすべきか呼び出し側が混乱します。
はアクション可能な応答ではなく起点別にエラーを分類するため、呼び出し側は処理方法を不明確になります。thiserror
は型情報を消去し、anyhow
での伝搬を簡易化しますが、?
を明示的に使用しない限り文脈の欠落を促進します。.context()
著者は二層構造の解決策を提案しています:
- 機械可読性の意思決定(例:リトライロジック)用のフラットで種類ベースのエラー構造 (
,ErrorKind
)。Apache OpenDAL の設計がこのアプローチの代表例です。ErrorStatus
ライブラリを使用したコンテキストツリー。exn
により呼び出し位置を自動記録し、#[track_caller]
を通じて文脈メッセージを強制します。この層は開発者に豊富な診断情報を提供します。or_raise
エラーを静かな失敗ではなくコミュニケーションメッセージとして扱うことで、ログの明瞭化、自動リトライロジックの改善、および Rust ベースサービスでのインシデント対応時間の短縮が期待できます。両方構造のエラーハンドリングを採用した企業は、より信頼性の高いシステムと高速なデバッグサイクルを実現できるでしょう。
本文
3 時です。プロダクションは停止しています。
あなたの目に映るログ行にはこう書かれています。
Error: serialization error: expected ',' or '}' at line 3, column 7
JSON が壊れていることは分かりますが、誰がいつどこでそれを起こしたのか、まったく手掛かりがありません。
設定ローダーでしょうか?ユーザー API でしょうか?Webhook のコンシューマーでしょうか?
このエラーはスタック上の 20 層をすべて通過し、元のメッセージをそのまま保持したまま流れ込んできましたが、意味だけは途切れてしまいました。
これに対して私たちは「Error Handling(エラーハンドリング)」と呼びます。実際にはただ単に「**Error Forwarding(エラー転送)」です。
エラーを熱いポテトのように扱い、捕捉したら (必要なら) ラップし、できるだけ早くスタック上へ投げ上げる――という慣行です。
println! を追加してサービスを再起動、バグが再現するまで待つ。長い夜になることは間違いありません。
大規模 Rust プロジェクトにおけるエラーハンドリングの詳細分析から
“多くの意見が固められた記事やライブラリがベストプラクティスを宣言し、終わりのない激しい議論を巻き起こしています。私たちはすべてがエラー処理に何か問題があることに気づき始めましたが、その正確な問題点を突き止めるのは難しかった。”
現在の慣行で問題となっている点
std::error::Error
トレイト:高貴だが欠陥のある抽象化
std::error::ErrorRust の
std::error::Error トレイトは、エラーは「チェーン」構造を持ち、各エラーにオプションで source() があり、根本原因を指すことができると想定しています。これはほとんどの場合機能します――大多数のエラーはソースを持たないか、一つだけです。
しかし、標準ライブラリとしては「意見が強い」設計になっています。
複数の原因がツリー構造で結ばれるケース(複数フィールドに対する検証失敗、タイムアウトと部分結果の両立など)を明示的に除外しています。
こうしたシナリオは実際に存在し、標準トレイトにはそれらを表現する方法がありません。
バックトレース:誤った病気への高価な薬
std::backtrace::Backtrace はエラーの可視化を改善するために導入されました。「何もないよりはマシ」ですが、重大な制限があります。
- 非同期コードではほぼ無用。バックトレースには 49 フレームが含まれ、そのうち 12 は
の呼び出しです。GenFuture::poll()
Async Working Group は「中断されたタスクは従来のスタックトレースからは見えない」と指摘しています。 - 起点だけを示す。バックトレースはエラーが作られた場所を教えてくれるのみで、アプリケーション内で経由した論理的パス(例:ユーザー X のリクエストハンドラ → サービス Y を呼び出し、パラメータ Z で)まで追跡できません。
- 取得コストが高い。標準ライブラリのドキュメントは「バックトレースを取得することはかなり高価な実行時操作になる可能性があります」と明言しています。
Provide/Request API:過剰設計の実例
Provider API (RFC 3192) と汎用メンバアクセス(RFC 2895)は、エラーに動的型ベースのデータアクセスを追加します:
fn provide<'a>(&'a self, request: &mut Request<'a>) { request.provide_ref::<Backtrace>(&self.backtrace); }
不安定な Provide/Request API は「エラーをより柔軟に扱えるようにする」最新の試みです。
つまり、エラーは実行時に HTTP ステータスコードやバックトレースといった型付きコンテキストを動的に提供できるということです。
しかし実際には次の問題が発生します。
- 予測不能 ― エラーが HTTP ステータスコードを持つかどうかは実行時まで分からない。
- 複雑性 ― API の細部は LLVM が最適化しにくいほど微妙です。
- もっと単純な構造体で名前付きフィールドを持たせる方が、巧妙な抽象よりも望ましい場合があります。
thiserror:起源別の分類、行動別ではない
thiserror を使えばエラー列挙型を簡単に定義できます:
#[derive(Debug, thiserror::Error)] pub enum DatabaseError { #[error("connection failed: {0}")] Connection(#[from] ConnectionError), #[error("query failed: {0}")] Query(#[from] QueryError), #[error("serialization failed: {0}")] Serde(#[from] serde_json::Error), }
これは妥当に見えますが、共通の慣習として「起源別」に分類している点に注意してください。
DatabaseError::Query を受け取ったとき、呼び出し側は何をすべきか――リトライする?ユーザーへ報告する?ログだけで終える?――が分かりません。エラーは単に「どの依存が失敗したか」を示しているだけです。
“このエラ―タイプは、呼び出し側に問題を解決する方法ではなく、何を解決したかを伝えます。”
anyhow:便利すぎてコンテキストを忘れる
anyhow は逆のアプローチで、型消失を採用します。
anyhow::Result<T> をどこでも使い、? で伝搬させるだけです。列挙型や #[from] アノテーションは不要です。
fn process_request(req: Request) -> anyhow::Result<Response> { let user = db.get_user(req.user_id)?; let data = fetch_external_api(user.api_key)?; let result = compute(data)?; Ok(result) }
毎回
? を使うと、コンテキストを追加する機会が失われます。ユーザー ID は?呼び出している API は?計算のどこで失敗したか?これらはエラーに全く含まれません。
anyhow のドキュメントでは
.context() を使って情報を付加するよう勧めていますが、これは任意です。型システムが要求しないため、「あとでコンテキストを追加する」と思い込んでしまうのは容易です。結局「あとで」ということは「決して」実行されません――3 時にプロダクションが炎上したときまで。
問題点:目的のないエラーハンドリング
Rust コードベースでよく見られるパターンを考えてみましょう:
#[derive(thiserror::Error, Debug)] pub enum ServiceError { #[error("database error: {0}")] Database(#[from] sqlx::Error), #[error("http error: {0}")] Http(#[from] reqwest::Error), #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), // ... 10 以上のバリアント }
一見妥当に思えますが、次の質問を自問してください。
を受け取ったとき、呼び出し側は何をすべきか?リトライする?ユーザーへ生の SQL エラーを表示する?ログだけで終える?ServiceError::Database- 3 時にデバッグしているとき、「serialization error: expected , or }」というメッセージから、どのリクエスト・フィールド・コードパスがここまで来たかは分からない。
これは「エラー処理を正確に伝搬させること」「型を合わせること」を優先し、最終的には人間や機械が読むメッセージとしての価値を忘れている根本的なギャップです。エラーは「失敗モード」ではなく、「メッセージ」です――復旧するマシンか、デバッグする人間に伝える情報なのです。
「ライブラリ vs アプリケーション」の神話
一般的には「ライブラリには thiserror を、アプリケーションには anyhow を」という単純なルールが広まっています。
Luca Palmieri は「それは正しい枠組みではない。意図に基づいて判断すべきだ」と指摘しています。
本当の質問は「ライブラリかアプリケーションか」ではなく、「呼び出し側がこのエラーで何をすることを期待しているか」です。
| 対象 | 目的 | 必要性 |
|---|---|---|
| マシン(自動復旧) | フラット構造、明確なエラー種別、予測可能なコード | 再試行ロジックの一貫性 |
| 人間(デバッグ) | 豊富なコンテキスト、呼び出し経路、ビジネスレベル情報 | ファイル・行番号・リクエスト ID など |
再試行ミドルウェアは「このエラーがリトライ可能か」だけを知りたくて、深いエラーチェーンを辿る必要はありません。
3 時にデバッグしているときは「どのファイル・ユーザー・リクエストだったか」が重要です。
ほとんどの設計は両者のニーズを最適化せず、コンパイラ向けに最適化されています。
マシン向け:フラットでアクショナブルな Kind ベース
プログラム的にエラーを扱う際は複雑さが敵です。
再試行ロジックは「ネストされたエラーチェーンをたどる」よりも、単純に is_retryable()? を問いたいのです。
Apache OpenDAL のエラー設計から導出したパターン:
pub struct Error { kind: ErrorKind, message: String, status: ErrorStatus, operation: &'static str, context: Vec<(&'static str, String)>, source: Option<anyhow::Error>, } pub enum ErrorKind { NotFound, PermissionDenied, RateLimited, // ... } pub enum ErrorStatus { Permanent, // 再試行しない Temporary, // 安全に再試行可能 Persistent, // 再試行済みでも失敗続き }
この設計は明確な意思決定を可能にします:
match result { Err(e) if e.kind() == ErrorKind::RateLimited && e.is_temporary() => { sleep(Duration::from_secs(1)).await; retry().await } Err(e) if e.kind() == ErrorKind::NotFound => { create_default().await } Err(e) => return Err(e), Ok(v) => v, }
設計上のポイント
は「応答」別に分類します。ErrorKind
*
→ 「存在しないのでリトライ不要」。NotFound
*
→ 「速度を落として再試行する」。RateLimited
を明示的に持たせることで、エラー型からリトライ可能性を推測する必要がなくなります。ErrorStatus- ライブラリごとに一つのエラーロジックで済みます。コンテキストフィールドは詳細情報を提供し、型多重化を避けられます。
人間向け:低摩擦でコンテキストを捕捉
優れたエラーコンテキストの最大の敵は「フリクション」です。
追加が面倒なら開発者はそれを実行しません。
exn ライブラリ(294 行、依存無し)は次のアプローチを示します:
- エラーはツリー構造でフレームを持つ。
により自動的に作成位置(ファイル・行・列)を捕捉。#[track_caller]
*線形チェーンではなく、ツリーは並列操作失敗や複数フィールド検証エラーなどで有効です。
必要なもの
- 自動位置捕捉 ― コストゼロで
を使い、各フレームが作成場所を知る。#[track_caller] - 操作性の高いコンテキスト追加 ― API が直感的に使用できることで「追加しない」ことが違和感になる。
fetch_user(user_id) .or_raise(|| AppError(format!("failed to fetch user {user_id}")))?; - モジュール境界でのコンテキスト強制 ― exn は
で外側エラー型を保持します。Exn<E>
を返す関数は、Result<T, Exn<ServiceError>>
を直接Result<U, Exn<DatabaseError>>
できません。型不一致がコンパイル時に検出され、必ず?
を呼び、必要なコンテキストを付加するタイミングで強制されます。or_raise()
実際のコード例
pub async fn execute(&self, task: Task) -> Result<Output, ExecutorError> { let make_error = || ExecutorError(format!("failed to execute task {}", task.id)); let user = self.fetch_user(task.user_id).await.or_raise(make_error)?; let result = self.process(user).or_raise(make_error)?; Ok(result) }
3 時に失敗したときは、次のような情報が得られます:
failed to execute task 7829, at src/executor.rs:45:12 ||-> failed to fetch user "John Doe", at src/executor.rs:52:10 ||-> connection refused, at src/client.rs:89:24
これにより、タスク ID 7829 をリクエストログで検索し、必要なすべての情報を即座に取得できます。
実装のまとめ
実際のシステムでは両方が必要です:
- マシン向け:自動復旧用のフラット、Kind ベースのエラー型。
- 人間向け:各層でコンテキストを追跡する仕組み。
パターンは次のようになります。
// マシン指向:フラット構造 + ステータス pub struct StorageError { pub status: ErrorStatus, pub message: String, } // 人間指向:コンテキスト付きで伝搬 pub async fn save_document(doc: Document) -> Result<(), Exn<StorageError>> { let data = serialize(&doc) .or_raise(|| StorageError::permanent("serialization failed"))?; storage.write(&doc.path, data).await .or_raise(|| StorageError::temporary("write failed"))?; Ok(()) }
境界でエラーツリーを走査し、構造化されたエラーを取得します:
match save_document(doc).await { Ok(()) => Ok(()), Err(report) => { // 人間向け:フルコンテキストツリーをログに出力 log::error!("{:?}", report); // マシン向け:構造化エラーを抽出して処理 if let Some(err) = find_error::<StorageError>(&report) { if err.status == ErrorStatus::Temporary { return queue_for_retry(report); } return Err(map_to_http_status(err.kind)); } Err(StatusCode::INTERNAL_SERVER_ERROR) } }
Provide/Request API のようにランタイムで型を推測するのではなく、
StorageError という実際の構造体を持ち、IDE が補完できるようにします。予測不可能な振舞いや実行時の驚きはなく、単純かつメンテナブルです。
結論
次回関数を書くときは、
Result の戻り値を「失敗するかもしれない」ではなく、「自分を説明しなければならない」と捉えてください。
- マシンが「リトライすべきか?」 を判断できないエラー → 失敗。
- 人間が「どのユーザーだったか?」 が分からないログ → 失敗。
エラーは単なる失敗モードではなく、コミュニケーションです。
システムが何らかの問題に直面したとき、送るメッセージをしっかり設計することこそが最も重要です。
エラーフォワーディングはやめて、デザインされたエラー を作りましょう。