C# ミニマル API におけるグローバルエラーハンドリング
[[academy-video-youtube({"vid": "B5NsgtdwOlg", "start_time": "0", "title": "C#最小APIにおけるグローバルエラーハンドリング", "creator": "Tim Corey", "length": "13m 30s"})]]
未処理の例外をスローするWeb APIは、デフォルトで開発者がローカルでデバッグするのに役立つ種類のエラーページを返し、第三者がコールスタックをマップするのに役立ちます。 行番号、型名、ソースファイルへのパスがリクエストを行った人にすべて返されます。発生する可能性のあるエンドポイントでエラーをキャッチすることが正しいアプローチですが、忘れられたtry/catchの先にまで効果は及びません。 グローバルハンドラーは、エンドポイントが見逃したものをキャッチする安全ネットです。
彼のビデオ"C# ミニマル API におけるグローバルエラーハンドリング"では、Tim Corey さんが故意に問題のあるエンドポイントを持つ小さなミニマル API を構築し、保護無しで返される開発者エラーページを示し、app.UseExceptionHandler を接続してキャッチされない例外をインターセプトし、一般的な500で応答します。また、エンドポイントレベルのハンドリングが好まれる理由を強調しています:グローバルハンドラはフォールバックであり、戦略ではありません。 スタックトレースがサーバーを離れることがないことを確実にしたいミニマルAPIを出荷する人は、ミドルウェア設定やその設計理論を以下に見つけることができます。
壊れたエンドポイントを持つミニマルAPIの構築
[1:08 - 3:01] TimはErrorDemoAppという名前の新しい.NET 8 ASP.NET Core Web APIプロジェクトから始めます。プロジェクトテンプレートのオプションはデフォルトに近い状態で: HTTPSオン、OpenAPIオン、認証なし、トップレベル文は有効のまま、そしてコントローラーチェックボックスは、これはミニマルAPIであるため、未チェックのままです。 生成されたProgram.csはSwaggerを保持しますが、天気予報サンプルのエンドポイントとそのレコードは削除され、ファイルには基本的なものだけが表示されます。
サンプルの代わりに、/demoに単一のエンドポイントを追加し、その目的は失敗することです:
app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});app.MapGet("/demo", () =>
{
throw new Exception("This is a demo exception");
});Ctrl+F5でプロジェクトを(デバッグなしで開始)実行すると、Visual Studioデバッガーがthrowをインターセプトしないので、実際のHTTP呼び出しのように失敗が現れます。 Swaggerが開き、利用可能な唯一のエンドポイントは/demoで、それを実行すると500の応答が戻ります。 応答ボディには、例外タイプ、メッセージ、Program.csの18行目への参照が含まれています。
デフォルトのエラーページが実装詳細を漏らす理由
[3:01 - 5:00] ブラウザーで/demoに直接アクセスすると(?message= Swaggerラッパーなしで)、JSON応答ではなく、開発者の例外ページが表示されます。 ページには例外名、メッセージ、throwが発生したファイルパスと行番号、生の例外詳細、およびthrow上のスタックフレームがレンダリングされます。 ローカルで作業している開発者にとって、これは素晴らしいものです。 他の誰にとっても、これはコードベースの無料マップです。
Timのポイントは、装飾なしで伝わります: このページは開発者を助けるために存在し、エンドユーザーには決して届くべきではありません。 それが時々届くという事実が、グローバルハンドラーが重要である理由です。 すべてのエンドポイントを例外処理で丁寧にラップしているチームでも、最終的には1つを見逃し、その見逃しのコストは、スタックトレース全体がリクエストした人に渡ることです。
最初にエンドポイントでエラーをキャッチする
[5:00 - 6:30] グローバルハンドラーをインストールする前に、Timはデモエンドポイントをtry/catchでラップし、推奨パスを示します。 ハンドラーは投げられた例外に対して Results.BadRequest(ex.Message) を返します:
app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});app.MapGet("/demo", () =>
{
try
{
throw new Exception("This is a demo exception");
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});結果は、メッセージ文字列のみを含む400です。 スタックトレースもファイルパスも行番号もありません。 メッセージ自体を公開するかどうかはアプリケーションに依存します; 公開APIの場合、メッセージでもチームが望む以上に漏れる可能性があり、その場合、ハンドラーは一般的な文字列に置き換えます。 ローカルキャッチにより、エンドポイントは呼び出し側に見せるものを完全に制御でき、実際に失敗モードがわかっている場合には、500よりも具体的なステータスコードを返すことを選択できます。
このパターンでできないことは、エンドポイントがラップするのを忘れたことをキャッチすることです。新しいコードパス、より深い層からのリスロー、別スレッドで投げられる Task はすべてエンドポイントの try/catch をバイパスします。 それがミドルウェアが埋めるギャップです。
UseExceptionHandlerミドルウェアの配線
[6:30 - 10:00] app.UseHttpsRedirection() ラインのすぐ下で、ハンドラーは app.UseExceptionHandler に登録されます。 ビルダーアクションを受け取るオーバーロードは基礎となるパイプラインの構造を明らかにし、ハンドラーが応答の形状を明示的に設定できるようにします:
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature is not null)
{
Console.WriteLine($"Error: {contextFeature.Error}");
}
await context.Response.WriteAsJsonAsync(new
{
StatusCode = context.Response.StatusCode,
Message = "Internal Server Error"
});
});
});そのブロック内のいくつかの選択事項が重要です。 ステータスコードを500に固定することは、応答数から何も推測できないことを意味します; 内部分の例外タイプにかかわらず、表面は同じように見えます。 コンテンツタイプをapplication/jsonに固定することは、APIの他の応答と一致し、クライアントが単一パーサーで済むようにします。 IExceptionHandlerFeature は元の例外を露出するので、実際のハンドラーがそれをログできます; Tim さんはここで Console.WriteLine をプロジェクトに実際に載せるロガーの代役として使用しています。
最後の WriteAsJsonAsync 呼び出しはステータスコードと一般的なメッセージを持つ匿名オブジェクトを返します。 ボディは、何が失敗したかを超えて失敗したという事実について何も言いません、これがポイントです。 内部診断はログに属し、応答には含まれません。
処理済みおよび未処理パスのテスト
[10:00 - 13:14] try/catchがまだ適用されている状態で、エンドポイントはローカルパスを実行します: Swaggerは"これはデモ例外です"を含む400を表示します。 ミドルウェアはキャッチブロックが最初にそれを解決するため、throwを一切受け取りません。これはTimがデフォルトで望むデザインです: ローカルハンドラーが仕事をし、グローバルハンドラーは休眠したままです。
try/catchを削除して再実行すると、フォールバックが試されます。 同じリクエストは現在、JSON 本文 { "statusCode": 500, "message": "Internal Server Error" } を伴って500を返します。 応答には、例外がどこでスローされ、どのタイプだったかを明らかにするものは何も含まれていません。 ただし、Visual Studio コンソールウィンドウには、Console.WriteLine プレースホルダーを通して記録された元の例外テキストがファイルパスや行番号と共に表示されます。 完全な診断は開発者が読むことができる所に留まります; 応答はリークしない場所に留まります。
このパターンは、ミニマルAPIの検証ミドルウェア、カスタム認証、その他のパイプラインコンポーネントにも当てはまります。 例外ハンドラーはパイプラインの初期段階に配置され、以降の段階から伝播するものをキャッチします。
まとめ: 誤差防御
[13:14 - 13:30] ローカル処理とグローバル処理は相反するものではありません; それらは層です。 ローカルハンドラーは失敗モードが知られているときに意味のある応答をする機会をエンドポイントに提供します。 グローバルハンドラーは、ローカルレイヤーが見逃したものが一貫性があり、汎用的で安全な応答を導くことを保証します。 コントローラーベースのAPIは、わずかな構文の調整で同じ考え方を採用しますが、ミニマルAPI形式は、1つのファイルに全体像を見渡すことができるほど小さい表面を持っているため、最初に慣れる価値があるものです。
結論
[13:14 - 13:30] ミニマル API でグローバルエラーハンドラーを設定するのは3つのステップです:パイプラインの初期段階で UseExceptionHandler を登録し、ハンドラー内部で応答ステータスとコンテントタイプを設定し、実装の詳細が漏れないように意図的に一般的な本文を書きます。 最も失敗しやすいコードパスの周囲にローカルtry/catchブロックを組み合わせれば、グローバルハンドラーが戦略ではなく安全網として機能する誤差防御モデルが形成されます。
実際のロガーにハンドラーが呼び出すときは、例外オブジェクト全体を渡し(メッセージだけではなく)、構造化ログパイプラインが型、スタック、および任意の内部例外を取得するようにしてください。 ロガーのようなSerilogはそれらすべてをクエリ可能なプロパティとして保持し、プロダクション500用に発火するアラートは、誰かがリクエストを再実行することなくローカルで再現するのに十分なコンテキストを持つことを意味します。
彼のYouTubeチャンネルでこのビデオをフルで視聴し、10分間のトレーニングシリーズでプロダクションレディのミニマルAPIを構築するうえでの洞察を得ましょう。

