1767558940
2026-01-04 19:02:00
午前3時です。生産がダウンしています。あなたは次のようなログ行を見つめています。
Error: serialization error: expected ',' or '}' at line 3, column 7
JSON が壊れていることはご存知でしょう。しかし、あなたは何も考えていません なぜ、 どこ、 または 誰が それを引き起こした。設定ローダーだったのでしょうか?ユーザーAPIって? Webhook のコンシューマ?
エラーはスタックの 20 層にまで到達し、元のメッセージは完全に保存されていますが、途中で意味の断片はすべて失われています。
これには名前があります。これを「エラー処理」と呼びます。しかし実際には、それはただ、 エラー転送。私たちはエラーを熱いジャガイモのように扱います。エラーをキャッチし、(おそらく)ラップして、できるだけ早くスタックに投げます。
を追加します println!、サービスを再起動し、バグが再現するまで待ちます。長い夜になりそうだ。
に記載されているように、 大規模なRustプロジェクトにおけるエラー処理の詳細な分析:
「ベスト プラクティスを宣伝する独断的な記事やライブラリが大量にあり、終わることのない壮大な議論につながっています。私たちは皆、エラー処理方法に何か問題があることに気づき始めていましたが、正確な問題を特定するのは困難です。」
現在の慣行の何が問題なのか
の std::error::Error 特徴: 高貴だが欠陥のある抽象化
ラストさんの std::error::Error この特性は、エラーがチェーンを形成していることを前提としています。各エラーにはオプションの source() 根本的な原因を指摘しています。これはほとんどの場合に機能します。ほとんどのエラーには原因がないか、原因が 1 つもありません。
しかし、として 標準ライブラリ 抽象化、それはあまりにも独断的なものです。これは、ソースがツリーを形成するケース、つまり複数のフィールドの失敗を伴う検証エラー、部分的な結果を伴うタイムアウトなどのケースを明確に除外します。これらのシナリオは存在しますが、標準の特性ではそれらを表現する方法がありません。
過去の痕跡: 間違った病気に対する高価な薬
ラストさんの std::backtrace::Backtrace エラーの可観測性を向上させることを目的としていました。何もないよりはマシです。しかし、それらには重大な制限があります。
非同期コードでは、これらはほとんど役に立ちません。 バックトレースには以下が含まれます 49 個のスタック フレーム (そのうち 12 個はへの呼び出し) GenFuture::poll()。の 非同期ワーキンググループのメモ 中断されたタスクは従来のスタック トレースには表示されません。
原点のみが表示され、パスは表示されません。 バックトレースにより、エラーが発生した場所がわかります 作成されました、アプリケーションを介してたどった論理パスではありません。 「これはユーザー X のリクエスト ハンドラーであり、パラメータ Z を指定してサービス Y を呼び出した」ということはわかりません。
バックトレースのキャプチャにはコストがかかります。 標準ライブラリのドキュメントでは、「バックトレースのキャプチャは、非常にコストのかかるランタイム操作になる可能性がある」と認められています。
Provide/Request API: オーバーエンジニアリングの実行
の プロバイダー API (RFC 3192) そして 汎用メンバーアクセス (RFC 2895) エラーへの動的な型ベースのデータ アクセスを追加します。
fn providea>(&'a self, request: &mut Requesta>) {
request.provide_ref::Backtrace>(&self.backtrace);
不安定なもの Provide/Request API は、エラーをより柔軟にするための最新の試みを表しています。アイデア: エラーは、呼び出し元が実行時に要求できる型付きコンテキスト (HTTP ステータス コードやバックトレースなど) を動的に提供できます。
これは力強いですね。実際には、次のような新たな問題が生じます。
予測不可能性:あなたの間違いです かもしれない HTTPステータスコードを提供します。あるいはそうではないかもしれません。実行時までわかりません。
複雑: API は非常に微妙なので、 LLVM は複数のプロバイド呼び出しを最適化するのに苦労します。
場合によっては、名前付きフィールドを含む単純な構造体が、賢い抽象化よりも優れていることがあります。
thiserror: 行動による分類ではなく、起源による分類
thiserror エラー列挙型の定義が簡単になります。
#[derive(Debug, thiserror::Error)]
#[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 どこにでも伝播します ?。 enum バリアントはもう必要ありません。 #[from] 注釈。
問題?その あまりにも 便利。
fn process_request(req: Request) -> anyhow::ResultResponse> {
let user = db.get_user(req.user_id)?;
let data = fetch_external_api(user.api_key)?;
let result = compute(data)?;
毎 ? コンテキストを追加する機会を逃しています。ユーザーIDは何でしたか?どの API を呼び出していたのでしょうか?どの計算が失敗しましたか?エラーはこれを何も知りません。
の anyhow ドキュメントでは使用を推奨しています .context() 情報を追加します。しかし .context() はオプションです。型システムでは必須ではありません。 「後で文脈を追加します」は、自分自身につく最も簡単な嘘です。それより遅いということは、生産が本格化する午前 3 時までは決して行わないことを意味します。
問題: 目的のないエラー処理
Rust コードベースの次の一般的なパターンを考えてみましょう。
#[derive(thiserror::Error, Debug)]
#[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),
これは合理的だと思われます。しかし、次のように自問してください。
-
発信者は何ができるのか
ServiceError::Database? 彼らは再試行できるでしょうか?生の SQL エラーをユーザーに表示する必要がありますか?エラーの種類は、これらの質問の答えには役立ちません。 -
午前3時のデバッグ時、「シリアル化エラー: 予想される
,または}「どのリクエスト、どのフィールド、どのコードパスがここにつながったかがわかりますか?」
これは、エラー処理についての考え方における根本的な断絶です。私たちは以下に焦点を当てます 伝播する 正確には、型を揃える際、コンパイラを満たす際にエラーが発生します。しかし、私たちはエラーがメッセージであることを忘れています。そのメッセージは、最終的には回復しようとするマシンか、デバッグしようとする人間によって読まれることになります。
「ライブラリ vs アプリケーション」の神話
おそらく、次のような一般通念を聞いたことがあるでしょう。 “使用 thiserror 図書館の場合、 anyhow アプリケーション用です。」
これはシンプルで良いルールですが、完全に正しいというわけではありません。として ルカ・パルミエリのメモ: 「それは正しい枠組みではありません。意図について推論する必要があります。」
本当の問題は、ライブラリを作成するのかアプリケーションを作成するのかということではありません。本当の質問は次のとおりです。 呼び出し元がこのエラーに対して何をすることを期待していますか?
2 つの聴衆、2 つのニーズ
誰がエラーを消費するのか、そして彼らが何を必要としているのかを明確にしましょう。
| 観客 | ゴール | ニーズ |
|---|---|---|
| 機械 | 自動回復 | フラットな構造、明確なエラーの種類、予測可能なコード |
| 人間 | デバッグ | 豊富なコンテキスト、コールパス、ビジネスレベルの情報 |
再試行ミドルウェアがエラーを受け取った場合、美しくネストされたエラー チェーンは気にしません。知っておく必要があるのは、次のことだけです。 これは再試行可能ですか? 単純なブール値または列挙型のバリアントで十分です。
午前 3 時にデバッグしているときは、スタックのどこか深いところに、 io::Error。知っておく必要があります: どのファイル、どのユーザー、どのリクエスト、何をしようとしていたのでしょうか?
ほとんどのエラー処理設計は、どちらのユーザー向けにも最適化されません。最適化するのは、 コンパイラ。
マシンの場合: フラット、アクション可能、種類ベース
エラーをプログラムで処理する必要がある場合、複雑さが敵となります。再試行ロジックは、特定のバリアントをチェックするネストされたエラー チェーンを横断する必要はありません。それは次のことを尋ねたいと考えています。 is_retryable()?
以下はうまくいくパターンです。 Apache OpenDAL のエラー設計:
context: Vec&'static str, String)>,
// ... categorized by what the caller CAN DO
Permanent, // Don't retry
Temporary, // Safe to retry
Persistent, // Was retried, still failing
この設計により、明確な意思決定が可能になります。
// Caller can make informed decisions
Err(e) if e.kind() == ErrorKind::RateLimited && e.is_temporary() => {
sleep(Duration::from_secs(1)).await;
Err(e) if e.kind() == ErrorKind::NotFound => {
重要な設計上の決定事項に注目してください。
ErrorKind は、発生元ではなく応答によって分類されます。 NotFound 「そのものは存在しないので、再試行しないでください」という意味です。 RateLimited 「速度を落としてもう一度試してください」という意味です。呼び出し元は、それが S3 404 だったのか、ファイルシステム ENOENT だったのかを知る必要はありません。それに対して何をすべきかを知っている必要があります。
ErrorStatus は明示的です。 エラーの種類から再試行可能性を推測するのではなく、これはファーストクラスのフィールドです。サービスは、再試行すれば解決する可能性があるとわかっている場合、エラーを一時的なものとしてマークできます。
ライブラリごとに 1 つのエラー タイプ。 モジュール間でエラー列挙型を分散させるのではなく、単一のフラットな構造により処理がシンプルになります。の context フィールドは、型を増やすことなく、必要なすべての特異性を提供します。
エラー チェーンをたどったり、エラーの種類から推測したりする必要はもうありません。エラーを直接聞いてください。
人間向け: 低摩擦のコンテキストキャプチャ
適切なエラー コンテキストの最大の敵は機能ではなく、摩擦です。コンテキストの追加が煩わしい場合、開発者はそれを行いません。
の 例 ライブラリ (Rust の 294 行、依存関係なし) は 1 つのアプローチを示しています。 木 のフレーム。それぞれがソース位置を自動的にキャプチャします。 #[track_caller]。線形エラー チェーンとは異なり、ツリーは複数の原因を表すことができるため、並列操作が失敗した場合や検証で複数のエラーが発生した場合に役立ちます。
必要なものは次のとおりです。
自動位置キャプチャ。 高価なバックトレースの代わりに、 #[track_caller] ファイル/行/列をキャプチャするには コストゼロ。すべてのエラー フレームは、それがどこで作成されたかを認識する必要があります。
人間工学に基づいたコンテキストの追加。 コンテキストを追加するための API は非常に自然である必要があります。 ない 追加するのは間違っていると感じます:
.or_raise(|| AppError(format!("failed to fetch user {user_id}")))?;
これを比較してください thiserrorここで、同じコンテキストを追加するには、新しいバリアントを定義し、手動でラッピングする必要があります。
#[derive(thiserror::Error, Debug)]
#[error("failed to fetch user {user_id}: {source}")]
// ... one variant per call site that needs context
fn fetch_user(user_id: &str) -> ResultUser, AppError> {
db.query(user_id).map_err(|e| AppError::FetchUser {
user_id: user_id.to_string(),
モジュール境界でコンテキストを強制します。 ここが exn と決定的に異なる点です anyhow。と anyhow、すべてのエラーは消去されます anyhow::Error、いつでも使用できます ? そして次に進みましょう。型システムがあなたを止めることはありません。コンテキストメソッドは存在しますが、 何もない それらを無視することを防ぎます。
exn は別のアプローチをとります。 Exn 最も外側のエラー タイプを保持します。関数が返った場合 Result、直接はできません ? ある Result>—タイプが一致しません。コンパイラ 力 あなたに電話してください or_raise() そして、 ServiceError、これはまさに、モジュールが何をしようとしていたかについてのコンテキストを追加する必要がある瞬間です。
// This won't compile--type mismatch forces you to add context
pub fn fetch_user(user_id: &str) -> ResultUser, ExnServiceError>> {
let user = db.query(user_id)?; // Error: expected Exn, found Exn
// You must provide context at the boundary
pub fn fetch_user(user_id: &str) -> ResultUser, ExnServiceError>> {
let user = db.query(user_id)
.or_raise(|| ServiceError(format!("failed to fetch user {user_id}")))?; // Now it compiles
型システムはあなたの味方になります。モジュール境界で怠惰になることはありません。
実際には次のようになります。
pub async fn execute(&self, task: Task) -> ResultOutput, 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)?;
毎 ? コンテキストがあります。これが午前 3 時に失敗すると、不可解なメッセージの代わりに、 serialization error、 分かりますか:
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
タスク 7829 でユーザー データを取得していましたが、接続が拒否されました。リクエスト ログ内のタスク ID を grep して、必要なものをすべて見つけることができます。
まとめる
実際のシステムでは、多くの場合、自動回復用の機械可読エラーと、デバッグ用の人可読コンテキストの両方が必要です。パターン: 構造化データにはフラットで種類ベースのエラー タイプ (Apache OpenDAL など) を使用し、それを伝播のためにコンテキスト追跡メカニズムでラップします。
// Machine-oriented: flat struct with status
pub struct StorageError {
// Human-oriented: propagate with context at each layer
pub async fn save_document(doc: Document) -> ResultExnStorageError>> {
let data = serialize(&doc)
.or_raise(|| StorageError::permanent("serialization failed"))?;
storage.write(&doc.path, data)
.or_raise(|| StorageError::temporary("write failed"))?;
境界で、エラー ツリーをたどって構造化エラーを見つけます。
// Extract a typed error from anywhere in the tree
fn find_errorT>(exn: &Exnimpl Error>) -> Option&T> {
fn walkT>(frame: &Frame) -> Option&T> {
if let Some(e) = frame.as_any().downcast_ref::T>() {
frame.children().iter().find_map(walk)
match save_document(doc).await {
// For humans: log the full context tree
log::error!("{:?}", report);
// For machines: find and handle the structured error
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 戻り値の型。
「失敗するかも」なんて考えないでください。 「自分自身について説明する必要があるかもしれない」と考えてください。
エラーの種類が「再試行する必要がありますか?」に答えられない場合は、マシンに失敗したことになります。エラー ログに「どのユーザーでしたか?」という答えがなければ、人間としては不合格です。
エラーは伝播する単なる障害モードではありません。それらはコミュニケーションなのです。これらは、問題が発生したときにシステムが送信するメッセージです。あらゆるコミュニケーションと同様、コミュニケーションもデザインされる価値があります。
転送エラーを停止します。それらの設計を開始します。
リソース
最終編集日 1 月 4 日
#エラーの転送をやめてエラーの設計を始めましょう