Error トレイトとエコシステム

17 分で読了
このページの目次

エラー設計では、まず以下の2つの問いに答える必要がある。「呼び出し元はどのような情報に基づいて行動すべきか」「トラブルシューターは失敗を理解するためにどのような情報を必要とするか」。具体的ないずれの列挙型は、マッチング可能なカテゴリを表現するのに適しており、コンテキストは当時どのビジネスステップを処理していたかを説明する。これらは共存可能であり、「復旧可能」と「報告可能」を排他的な選択として分離する必要はない。

Error は共通のエラーインターフェースを提供する

std::error::Error は Debug と Display を要求し、source() によって現在の失敗を引き起こした別のエラーを公開できる。Display は人間が読みやすい説明を目的とし、Debug はデバッグ用の表現を目的とする。source は構造化された原因の関係を表すものであり、複数の文字列を1つの文章に連結するものではない。Error

アプリケーションのコンテキストサービス起動時にリスニング設定の読み込みに失敗1ドメインエラー PortErrorポートの文字列が u16 に解析できない2低レベルの ParseIntError整数パーサーが示した具体的な理由3エラー原因の連鎖とコールスタックは異なる情報であるsource は原因を連鎖させる;backtrace はキャプチャ時のコールスタックを記録し、原因の連鎖からは復元できない。

エラー原因の連鎖は1層だけの場合もあれば、解析、ストレージ、アプリケーション層を跨ぐ場合もある。一方、backtrace は特定の時点でスタック情報を収集する必要がある。source を辿っても、関数の呼び出しスタックは自動的に導出されない。スタックが存在する場合でも、収集位置が必ずしも障害の発生当初の位置であるとは限らない。

派生マクロを使わなくても、完全な原因連鎖を実装できる

まずは標準ライブラリのバージョンを見て、thiserror が実際にどのようなコードを生成しているのかを確認する。フィールド名は失敗した設定を特定し、source は元の解析エラーを保持し、Display は現在の層の説明のみを担当する。

use std::{error::Error, fmt, num::ParseIntError};
#[derive(Debug)]
struct FieldError {
    field: &'static str,
    source: ParseIntError,
}
impl fmt::Display for FieldError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "設定フィールド {} が有効な u16 ではありません", self.field)
    }
}
impl Error for FieldError {
    fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.source) }
}
fn parse(field: &'static str, text: &str) -> Result<u16, FieldError> {
    text.parse().map_err(|source| FieldError { field, source })
}
fn main() {
    let error = parse("port", "bad").unwrap_err();
    assert_eq!(error.field, "port");
    assert!(error.to_string().contains("port"));
    let source = error.source().unwrap();
    assert!(source.downcast_ref::<ParseIntError>().is_some());
    assert!(source.source().is_none());
}

source は借用を返すため、参照は現在のエラーオブジェクトを超えてはならない。返却型の 'static 制約は、擦除された原因型に対するものであり、この参照を静的参照に延長するものではない。Error トレイト自体も、すべての実装が Send、Sync、または 'static でなければならないことを要求しない。これらは通常、スレッド間の伝播や特定のエラーコンテナによって追加で課される要件である。

上位層の Display がすべての source のテキストを埋め込んでしまうと、レポートツールが階層ごとに印刷する際に同じ原因が重複する可能性がある。各層が新しいコンテキストのみを記述し、統一されたレポートツールが原因連鎖を展開するようにする。インターフェースが1行で完全な表示を必要とする場合でも、全体のポリシーの一貫性を保つべきである。

ラップ後も原因の型は残るか

同じ表示文でも、ParseIntError を保つ原因連鎖と String だけでは構造が違います。内外の層を選ぶと、通常の dyn Error のダウンキャストが現在のオブジェクトを検査することが分かります。表示、原因関係、記録したスタックは別の情報です。

図を準備しています
ラップ後も原因の型は残るか

source は辿れる原因オブジェクトを保ち、to_string は表示文字列だけを残します。

完全な例:型は判断に、コンテキストは特定に使用する

Cargo.toml:

[package]
name = "error-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
thiserror = "2"
anyhow = "1"

src/main.rs:

use anyhow::Context;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum PortError {
    #[error("port is not a valid u16")]
    Invalid(#[from] ParseIntError),
    #[error("port zero is disabled by this application")]
    Zero,
}

fn parse_port(text: &str) -> Result<u16, PortError> {
    let port = text.parse::<u16>()?;
    if port == 0 {
        return Err(PortError::Zero);
    }
    Ok(port)
}

fn startup(text: &str) -> anyhow::Result<u16> {
    parse_port(text).context("cannot configure listening port")
}

fn main() {
    assert_eq!(startup("8080").unwrap(), 8080);
    assert!(matches!(parse_port("0"), Err(PortError::Zero)));
    assert!(matches!(parse_port("70000"), Err(PortError::Invalid(_))));

    let error = startup("bad").unwrap_err();
    assert!(matches!(error.downcast_ref::<PortError>(), Some(PortError::Invalid(_))));
    for (level, cause) in error.chain().enumerate() {
        eprintln!("{level}: {cause}");
    }
    assert_eq!(error.chain().count(), 3);
}

ここでポート 0 を禁止するのはアプリケーションのポリシーであり、u16 やネットワークプロトコルのルールではない。低レベルの解析失敗は #[from] によって Invalid に変換され、ビジネス上の拒否は直接 Zero を構築する。アプリケーションの上位層で起動コンテキストを追加しても、downcast_ref を通じて PortError を特定できる。

thiserror は通常のトレイト実装を生成する

thiserror は Display、Error、および指定された From 実装の生成を支援し、公開されるエラーは依然として独自の型である。#[from] はそのフィールドを source としても公開する。From はソースエラーのみを受け取るため、バリアントは任意のパスやビジネス ID フィールドを要求できない(ドキュメントでは別途対応するバックトレースフィールドが許可されている)。thiserror

エラーにパス情報を付与する必要がある場合は、呼び出し箇所で path と source を含むバリアントを map_err で構築し、元のエラーを source として保持するべきである。単に error.to_string() を保存するのではなく、前者は原因の型を保持し、後者は表示テキストのみを残す。

#[error(transparent)] は、表示と source の動作を内部エラーに透過的に委譲するため、ビジネス上の説明を自動的に追加するものではない。「どの設定の読み込みに失敗したか」を説明する必要がある場合は、このコンテキストを明示的にモデル化するべきである。

同じ低レベルエラーでも、異なるビジネスステップから来る可能性がある

From は渡されたエラー値に基づいて変換するだけで、設定の読み込み中かチェックポイントの書き込み中かを認識できない。複数のステップで io::Error が発生する場合は、発生箇所に操作コンテキストを追加し、同じソースを持つ #[from] バリアントを2つ作成して競合が生じるのを避けるべきである。

以下の例では、前述の thiserror 依存関係を使用し、実際のファイル読み書きを行わずに、失敗する操作を注入して構築方法を示す。

use std::{error::Error as _, io};
use thiserror::Error;
#[derive(Debug, Error)]
#[error("操作 {operation} が失敗しました。リソース {resource}")]
struct OperationError {
    operation: &'static str,
    resource: String,
    source: io::Error,
}
fn perform(resource: &str, action: impl FnOnce() -> io::Result<()>)
    -> Result<(), OperationError>
{
    action().map_err(|source| OperationError {
        operation: "load-config", resource: resource.to_owned(), source,
    })
}
fn main() {
    let error = perform("service-config", || {
        Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
    }).unwrap_err();
    assert_eq!(error.operation, "load-config");
    assert_eq!(error.source.kind(), io::ErrorKind::PermissionDenied);
    assert!(error.source().unwrap().downcast_ref::<io::Error>().is_some());
    assert!(perform("service-config", || Ok(())).is_ok());
}

ここで resource はレポートに適したリソース識別子を保持し、完全な入力内容を含める必要はない。#[source] はエラーの原因を保持し、map_err は呼び出し箇所で From にはないコンテキストを埋め込む。エラーのラップ層数は、対応する価値のある新しい情報であるべきであり、各ヘルパー関数を経るたびに同じ意味の説明をラップしてはならない。

anyhow はエラーをラップするが、復旧戦略を定義しない

anyhow::Error は、Error + Send + Sync + 'static を満たす具体的なエラーを受け入れることができる。context は説明を追加し、with_context は失敗した場合のみ高価なコンテキストの構築を許可する。ラップされたエラーに対しては、依然として型ダウンキャストが可能である。anyhow

すべてのエラーを文字列に変換し、文字列に timeout が含まれているかどうかによってリトライを決定すると、ビジネスロジックが不安定なテキストに紐付けられてしまう。レートリミット、認証失敗、一時的な利用不可を区別する必要がある場合は、アプリケーション内部であっても、具体的なカテゴリまたは安定したエラーコードを保持すべきである。

anyhow はパニックを自動的にキャッチせず、ログを自動的に記録せず、副作用を元に戻さない。エラーの伝播とレポートを便利にするが、復旧、リトライ、トランザクションは依然としてプログラム自身の戦略である。

コンテキストは読みやすいものでも、識別可能な型でもよい

以下でも同じ Cargo 依存関係を使用する。コンテキスト LoadingConfig はビジネスステップを識別し、低レベルの ParseIntError は具体的な失敗を保持し、どちらも anyhow の downcast_ref で特定できる。

use anyhow::Context;
use std::{fmt, num::ParseIntError};
#[derive(Debug)]
struct LoadingConfig;
impl fmt::Display for LoadingConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("リスニング設定の読み込みに失敗しました")
    }
}
fn main() {
    let error = "bad".parse::<u16>().context(LoadingConfig).unwrap_err();
    assert!(error.downcast_ref::<LoadingConfig>().is_some());
    assert!(error.downcast_ref::<ParseIntError>().is_some());
    assert_eq!(error.to_string(), "リスニング設定の読み込みに失敗しました");
    assert_eq!(error.chain().count(), 2);
}

ここでは anyhow がコンテキストラップに対して提供する特別なサポートを使用している。任意の Box<dyn Error> に対する downcast が自動的に source 連鎖を辿るとは限らない。標準トレイトオブジェクト上での downcast は現在のオブジェクトの具体的な型をチェックするため、原因を調べる場合は明示的に source を沿って進む必要がある。anyhow Context と downcast

'static 制約は、エラーがリークしたりプロセス終了まで生存したりしなければならないことを意味しない。String を保持するエラーは 'static を満たし、すぐに解放できる。一時的な入力 &str を保持するエラーは、'static を要求するコンテナに収まらない可能性がある。タスク間で位置情報を保持する必要がある場合は、所有権を持つ短い識別子、範囲、または行番号を保存できる。

原因連鎖、コンテキスト、backtrace の役割分担

情報回答する質問典型的な内容
エラーカテゴリ呼び出し元は次に何ができるか権限なし、無効な設定、一時的に利用不可
ビジネスコンテキスト当時どの操作を実行していたか特定タスクの設定読み込み
source 原因連鎖この層でなぜ失敗したか解析失敗は無効な数字に起因
backtraceキャプチャ時に経由した呼び出し関数と位置。シンボル情報に影響される

anyhow のスタックトレースサポートは、ビルドと実行の設定に影響される。通常の std ビルドでは、RUST_LIB_BACKTRACE=1 を使用してエラーバックトレースをリクエストできる。RUST_BACKTRACE はパニックバックトレースも含む。「source 連鎖がある」ことを「必ず完全なスタックがある」と混同してはならない。

構造化エラー + 原因 + コンテキストプログラムが判断可能なカテゴリを保持呼び出し元の判断停止、入力修正、有限のリトライ内部診断操作識別子、原因連鎖、関連ID外部への応答安定コードと対処の案内内部エラーは境界によって異なる情報を提供するエラーメッセージは調整可能である。復旧ロジックは文字列マッチングに依存すべきではなく、内部原因をすべて外部に公開する必要もない。

エラーカテゴリからアクションへは、ビジネス判断が1つ必要

カテゴリ考えられるアクション確認が必要な条件
設定の構文エラーフィールドを指摘して起動を停止デフォルト値で明示的なエラーを隠してはならない
権限不足権限または身份を修正同じリクエストのリトライは通常、権限を変更しない
リクエストタイムアウト結果を確認するか有限リトライ元の操作が既に成功している可能性があるため、冪等性を考慮する
レートリミットサービスの指示に従って待機リトライ予算、タイムアウト、リクエストの有効性
リソースが存在しない空の結果を返すか失敗を報告そのリソースが本来オプショナルかどうか

Error は is_retryable を定義しない。その理由は、「リトライ可能かどうか」は通常、操作のセマンティクスに依存するためである。たとえエラーが一時的なネットワーク失敗から来たとしても、送金リクエストを transient マーカだけで再送信してはならない。応答が消失した場合、サーバー側では既に成功している可能性がある。型は判断の根拠を提供するが、リトライプロトコルには回数、時間、冪等性の制約が含まれる必要がある。

インターフェースの境界での取舍

ライブラリがマッチング可能なエラー型を提供することは、通常、呼び出し元の復旧に有利であり、thiserror は実装コードを削減できる。アプリケーションの最上位でコンテキストを統一して携带することは、通常 anyhow に適している。これらは一般的な役割分担であり、「ライブラリは特定の crate を使用しなければならず、アプリケーションはエラーを区別する必要がない」というルールではない。

公開エラー列挙型のバリアントも、互換性設計の一部である。拡張性を考慮して non_exhaustive を使用できる。エラーメッセージは問題を特定するのに十分であるべきだが、パスワードや完全な認証情報をそのまま記録してはならない。通常、エラーを真正に処理または報告する境界で1回記録し、伝播するたびに同じ失敗を繰り返し出力しないようにする。

公開列挙型がサードパーティのパーサーエラーを直接バリアントフィールドとして公開すると、呼び出し元はその具体的な型にマッチングし始め、依存関係のアップグレードが API に影響を与える可能性がある。安定したカテゴリアクセサを必要に応じて提供し、実装の詳細をプライベートな表現に保持できる。その代償として、呼び出し元が直接依存できる情報は減少する。non_exhaustive は外部の網羅的マッチングに影響を与えるのみで、フィールド型、エラーコード、復旧セマンティクスを自動的に安定させるものではない。

外部 API は安定した code、短い message、関連する識別子を返すことができ、内部ログにはより詳細な原因を保持できる。Debug や完全な source 連鎖を、長期互換性のある応答フォーマットとして直接使用してはならない。テストでは、バリアント、キーワードフィールド、source 型、および必要なコンテキストをアサートし、サードパーティの完全な英語のエラーメッセージを、永遠に同じでなければならない契約として記述しないようにする。

まず Result と Option の伝播セマンティクスを把握し、その後でエラー型とレポート方法を決定することで、? を自動復旧またはログメカニズムと誤解するのを避けることができる。