.NET HttpClient タイムアウト: TaskCanceledException と TimeoutRejectedException

API が遅すぎると、発生したタイムアウトに応じて、.NET アプリは 2 つの例外のうち 1 つを受け取ります。 HttpClient.Timeout は TaskCanceledExceptionをスローします。 Microsoft.Extensions.Http.Resilience の標準のレジリエンス ハンドラは、Polly の TimeoutRejectedException をスローします。 それらは異なる場所に由来するため、デフォルト設定も異なり、それぞれ個別の catch ブロックが必要です。

どのタイムアウトが発生したか

Timeout Default コードが取得するもの 設定
HttpClient.Timeout 100 秒 TaskCanceledException、TimeoutException を InnerException として持つ (.NET 5 以降) HttpClient.Timeout
標準ハンドラー試行タイムアウト 1 回の試行につき 10 秒 最初は何も起こらない: ハンドラーが再試行する AddStandardResilienceHandler(options => ...)
標準ハンドラーの合計タイムアウト 30 秒 (再試行の回数をすべて含みます) Polly.Timeout.TimeoutRejectedException AddStandardResilienceHandler(options => ...)

HttpClient.Timeout

HttpClient.Timeout は、 HttpClient インスタンスが送信するすべての要求に適用されます。 1 つの要求に対して別のタイムアウトを設定するには、独自のタイムアウトを持つ CancellationTokenSource から CancellationToken を渡してください。 2 つのうち、より短い方が適用されます。 Timeout.InfiniteTimeSpan と設定してオフにします。

.NET 5 以降では、タイムアウトが発生すると、TimeoutException を含む TaskCanceledException がスローされます。 以前のバージョンの .NET Core では、内部例外は存在しません。 .NET Framework では、代わりに HttpRequestException が表示されます。 詳細については、HttpClient.Timeout および HttpClient クラスを使用した HTTP 要求の作成 に関するページを参照してください。

TaskCanceledException は、誰かが要求をキャンセルしたことを意味します。これにはたとえば、ページを閉じたユーザーなどが該当します。 タイムアウトとキャンセルを見分けるには、ex.InnerException is TimeoutException を確認するか、自身のトークンがキャンセルされていないかを確認してください。

標準のレジリエンス ハンドラー

AddStandardResilienceHandler() は、レートリミッター、合計タイムアウト、再試行、サーキット ブレーカー、および試行タイムアウトをチェーンします。 1 回の試行に 10 秒以上かかった場合、試行タイムアウトによってその試行はキャンセルされ、再試行の戦略によって再試行が行われます。再試行は最大 3 回まで行われ、2 秒から開始して、指数バックオフとジッターが適用されます。 再試行を含めた要求全体の処理に 30 秒以上かかった場合、タイムアウトで要求はキャンセルされ、コードでは TimeoutRejectedException が発生します。

TimeoutRejectedException は Exceptionから派生します。 これは TimeoutException でも HttpRequestExceptionでもなく、 catch (HttpRequestException) ブロックではキャッチされません。 既定値の完全な一覧については、「 標準の回復性ハンドラーの既定値」を参照してください。

たとえば、標準ハンドラーのデフォルト設定を使用している .NET 10 アプリが、1 回の応答につき 11~15 秒かかる API を呼び出した場合、30 秒後にアプリは TimeoutRejectedException を取得し、その catch (HttpRequestException) ブロックは実行されません。

HttpClient タイムアウトを処理する方法

  1. API を呼び出す場合は、両方の例外をキャッチします。 標準ハンドラーを使用する場合は、TimeoutRejectedException をキャッチしてください。 HttpClient.Timeout が発生した際に、TaskCanceledException をキャッチします。
  2. タイムアウトとキャンセルを区別します。 内部例外がTaskCanceledExceptionの場合にのみ、TimeoutExceptionをタイムアウトとして扱います。 呼び出し元がキャンセルしたら、静かに停止します。
  3. API に合ったタイムアウトを選択します。 API の処理に 10 秒以上かかることが頻繁にある場合は、AddStandardResilienceHandler(options => ...) で「再試行タイムアウト」と「合計タイムアウト」を変更します。
  4. API によって安全と判断できる場合を除き、POST または PATCH を再試行しないでください。 標準ハンドラーは、既定ですべてのメソッド ( POSTを含む) を再試行します。 options.Retry.DisableForUnsafeHttpMethods() を呼び出すと、POST、PATCH、PUT、DELETE、CONNECTを除外します。options.Retry.DisableFor(HttpMethod.Post, HttpMethod.Patch) では、べき等な PUT と DELETE の再試行を継続します。
  5. 何が起こったかをユーザーに伝えてください。 一般的なエラーではなく、「サービスが遅いので、再試行してください」と表示します。
using Polly.Timeout;

public async Task<string?> GetForecastAsync(HttpClient client, CancellationToken cancellationToken)
{
    try
    {
        return await client.GetStringAsync("https://api.contoso.com/forecast", cancellationToken);
    }
    catch (TimeoutRejectedException)
    {
        // Standard resilience handler: total timeout expired after all retries
        return null;
    }
    catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
    {
        // HttpClient.Timeout expired
        return null;
    }
    catch (HttpRequestException)
    {
        // Network error, or an error status code after all retries
        return null;
    }
}

アプリがタイムアウトを処理することをテストする方法

開発中にタイムアウトが発生することはほとんどないため、 catch ブロックが実行されることはほとんどありません。 テスト方法は、ユーザーより先にバグを見つけられるかどうかを決定します。

Approach 見つけたもの 欠落しているもの
運用環境の待機 実際のタイムアウト すべて、ユーザーがクリックするまで
テストで API をモックするか、コーディング エージェントにモックを記述させる モックが正しい例外をスローした場合に、 catch ブロックを実行するかどうか 実際の HttpClient セットアップ、回復性ハンドラーの再試行、および実際にスローされる例外。 アプリでは、モックに到達するためにテスト専用のスイッチも必要です。
実際の API を呼び出し、処理が遅いことを期待します 実際の動作 API を意図的に低速にすることはできません
アプリの実際のトラフィックをインターセプトし、応答を遅らせる 実際の HttpClient、レジリエンス ハンドラー、例外 アプリ自体には何の変化もないため、コードを単独でテストすることにはなりません。 それ用に単体テストを取っておいてください。

アプリで試す

Dev Proxyは、API に対するアプリの要求をインターセプトし、LatencyPlugin で応答を遅延させます。 .NETはシステム プロキシを使用するため、コードを変更する必要はありません。 次の使用例は、標準ハンドラーの 10 秒の試行タイムアウトよりも長い時間、各応答を 11 ~ 15 秒遅延します。 https://api.contoso.comを、アプリが呼び出す API の URL に置き換えます。

ファイル: devproxyrc.json

{
  "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.3.1/rc.schema.json",
  "plugins": [
    {
      "name": "LatencyPlugin",
      "enabled": true,
      "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll",
      "configSection": "slowApi"
    }
  ],
  "urlsToWatch": [
    "https://api.contoso.com/*"
  ],
  "slowApi": {
    "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.3.1/latencyplugin.schema.json",
    "minMs": 11000,
    "maxMs": 15000
  }
}

devproxy --config-file devproxyrc.json を使用して Dev Proxy を起動し、アプリを実行します。 各試行がタイムアウトすると、ハンドラーが再試行を行い、30 秒後にコードに TimeoutRejectedException が返されます。 代わりに HttpClient.Timeout をテストするには、minMs 設定し、構成したタイムアウトよりも長い値に設定します。 設定の詳細については、.NET アプリケーションでの Dev Proxy の使用を参照してください。 Dev Proxy をインストールするには、「Dev Proxy のセットアップ」を参照してください。

次のステップ

参照