HttpClientの.Resultは危険?awaitとConfigureAwait(false)の正しい使い方とAggregateException対策(C#)

HttpClient を使った通信処理で「await と .Result を混ぜたらたまに AggregateException が出る」「ConfigureAwait(false) を付けると直った気がする」という相談はよくあります。本記事では、なぜ起きるのか、何が安全で、ループ処理では何に気を付けるべきかを実践的に整理します。

目次

よくある症状:await と .Result を混ぜると例外が読みづらくなる

まず前提として、HttpClient 自体は“失敗することがある”コンポーネントです。ネットワーク断、DNS、サーバー過負荷、タイムアウトなどは運用上ふつうに起きます。ただし、同じ失敗でも書き方によって「例外が読みづらい」「たまに固まる」「負荷が上がると急に失敗が増える」といった“別の問題”が上乗せされます。

相談で多いのは、次のようなコード(またはこれに近い形)です。

// NG:async の中で Task を同期的にブロックしている(sync-over-async)
var response = _httpClient.GetAsync(url).Result;

// さらに NG:ループや高頻度呼び出しでこれをやると、詰まりやすい
for (var i = 0; i < 100; i++)
{
    var body = _httpClient.GetStringAsync(url).Result;
}

.Result を使うと、例外が System.AggregateException に包まれて見えることがあり、ログ上は「AggregateException が出た」だけが目立ってしまいます。実際には HttpRequestException や TaskCanceledException(タイムアウト)など、原因となる例外が内部例外(InnerException)として入っているケースがよくあります。

結論:.Result は避け、await だけで完結させるのが基本

安全な書き方はシンプルで、「最後まで await でつなぐ」ことです。戻り値が Task / Task<T> である限り、呼び出し元も async にして連鎖させるのが定石です。

// OK:await で完結(例外も素直に飛ぶ)
using var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();

var body = await response.Content.ReadAsStringAsync(cancellationToken);

この形にすると、例外は AggregateException ではなく、原因に近い例外型でそのまま捕捉できます。スタックトレースも読みやすくなり、調査・復旧が格段に楽になります。

なぜ .Result が危険なのか:sync-over-async の副作用

.Result(または .Wait())は、Task の完了を同期的にブロックして待ちます。これは一見「待つだけ」ですが、実際には次のような副作用を引き起こします。

観点.Result / .Wait()await
待機の仕方スレッドをブロックして待つスレッドを解放して待つ(非ブロッキング)
例外の見え方AggregateException に包まれやすい(内部例外を追う必要)原因の例外がそのまま投げられやすい
デッドロックUI/古い ASP.NET などで起きやすい起きにくい(本来の async の使い方)
スレッド枯渇高頻度・多並列で起きやすい(特にサーバー)起きにくい(待機中はスレッドを占有しない)
パフォーマンス悪化しやすい(同期ブロックが積み重なる)改善しやすい(スケールしやすい)

デッドロックが起きる典型(UI アプリや古い ASP.NET)

WPF/WinForms などの UI アプリや、ASP.NET(.NET Framework の古いスタック)では、await の継続処理が「元のコンテキスト(UI スレッドや request context)」に戻ろうとします。ところが、そのコンテキストを .Result でブロックしていると、継続が実行できずに待ち続ける、という循環が発生します。これがいわゆるデッドロックです。

「たまに固まる」「環境によってだけ再現する」ように見えるのは、スケジューリングや負荷で再現条件が揺れるためです。

ASP.NET Core でも安心しきれない:スレッドプール枯渇

ASP.NET Core は SynchronizationContext を持たないため、古典的なデッドロックは起きにくいです。しかし、だからといって .Result が安全になるわけではありません。サーバー側で .Result を多用すると、リクエスト処理スレッドをブロックし続け、負荷が上がった瞬間にスレッドプールが枯渇して全体が詰まる、という現象が起きやすくなります。結果としてタイムアウトが増え、「通信が不安定になった」と誤認されがちです。

ConfigureAwait(false) は“例外を消す魔法”ではない

次に、ConfigureAwait(false) の正しい意味を整理します。これは、await 後の継続処理を「元のコンテキストに戻さない」ための指定です。つまり、コンテキスト捕捉を抑制して、余計な依存を減らすのが目的です。

その結果として、UI/古い ASP.NET のように「戻り先コンテキストが詰まっている」状況でデッドロックが回避され、“直ったように見える”ことがあります。しかし、ネットワーク例外そのものが消えるわけではありません。例外が出なくなったと感じる場合は、次のような別要因も疑う価値があります。

  • 本当は例外が起きているが、ログが出ていない(fire-and-forget で未観測)
  • catch で握りつぶしている/上位に伝播していない
  • タイムアウトが変わった、リトライで偶然成功したなど、再現条件が揺れている
コードの種類ConfigureAwait(false) の基本方針注意点
アプリ(UI:WPF/WinForms/MAUI)原則は付けない(付けるなら“UI に戻らない区間”だけ)付けた後に UI 更新すると例外や不具合の原因。UI 更新は Dispatcher 等で戻す。
ASP.NET Core(Web API 等)付けても付けなくても動くことが多いライブラリ層は付ける、アプリ層は統一方針を決める、など運用でブレを減らす。
ライブラリ(共通部品、SDK)付けるのが定石呼び出し元のコンテキストに依存しない設計にする。UI や HttpContext への依存を混ぜない。
バッチ/Worker/BackgroundService付けるメリットが出やすいログ出力やスレッドセーフな設計を徹底。スレッドローカル前提の処理は避ける。

推奨パターン:例外・タイムアウト・応答破棄まで含めて “完成形” にする

HttpClient は「呼べればOK」ではなく、実運用では以下まで揃えると安定します。

  • キャンセル(CancellationToken)を必ず通す
  • レスポンスを確実に Dispose して接続を返す
  • ステータスコードを確認し、必要なら本文もログに残す
  • 大きい応答は ResponseHeadersRead でストリーミングを検討

GET の基本形

public async Task<string> GetBodyAsync(string url, CancellationToken ct)
{
    using var response = await _httpClient.GetAsync(url, ct);
    response.EnsureSuccessStatusCode();


// 文字列として読む(大容量ならストリームを検討)
return await response.Content.ReadAsStringAsync(ct);


}

POST/PUT の基本形(JSON 送信)

public async Task PostJsonAsync(string url, object payload, CancellationToken ct)
{
    using var content = JsonContent.Create(payload);


using var response = await _httpClient.PostAsync(url, content, ct);
response.EnsureSuccessStatusCode();


}

ライブラリ側での ConfigureAwait(false)(必要なときだけ)

public async Task<T> GetJsonAsync<T>(string url, CancellationToken ct)
{
    using var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
    response.EnsureSuccessStatusCode();


var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<T>(json)!;


}

ポイントは「付けるなら一貫して付ける」です。await をまたぐ場所だけ付けると、どこがコンテキスト依存なのか分かりにくくなります。ライブラリ層は基本的に ConfigureAwait(false) を付ける、UI 層は付けない、など層で方針を決めると事故が減ります。

ループ内で呼ぶときの落とし穴と、安定する設計

ループで HttpClient を叩くときは、単に await に直すだけでは足りないことがあります。特に、外部 API を短い間隔で叩く・大量の URL を処理する・失敗時に即リトライする、といった設計は、失敗率を自分で引き上げてしまいます。

ループの目的推奨パターン向いているケース注意点
順番に 1 件ずつ処理foreach + await(逐次)サーバー負荷を抑えたい、順序が必要遅くなりがち。待ち時間が長い場合は並列化も検討。
一気に処理して最短で終えるTask.WhenAll(並列)件数が少ない、相手が強い、社内ネットワーク件数が増えると相手も自分も落ちる。接続数・スロットリング必須。
大量を安全に高速化SemaphoreSlim 等で並列数を制限外部 API、大量 URL、安定性重視タイムアウトとリトライ設計が重要。キューが詰まると遅延が増える。
定期実行(監視・ポーリング)PeriodicTimer / Task.Delay + backoff数秒〜数分おきに実行失敗時に“即再試行”すると雪だるま式に悪化。バックオフ必須。

逐次処理:まずはこれが一番安全

foreach (var url in urls)
{
    using var response = await _httpClient.GetAsync(url, ct);
    response.EnsureSuccessStatusCode();
    var body = await response.Content.ReadAsStringAsync(ct);


// 次の処理...


}

並列処理:Task.WhenAll(ただし制限なしは危険)

// NG 寄り:件数が多いと相手も自分も詰まる
var tasks = urls.Select(url => _httpClient.GetStringAsync(url, ct));
var bodies = await Task.WhenAll(tasks);

推奨:並列数を制限して安定させる(スロットリング)

var maxConcurrency = 5;
using var throttler = new SemaphoreSlim(maxConcurrency);

var tasks = urls.Select(async url =>
{
await throttler.WaitAsync(ct);
try
{
using var response = await _httpClient.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct);
}
finally
{
throttler.Release();
}
});

var bodies = await Task.WhenAll(tasks);

定期実行:PeriodicTimer で“無限ループ+Delay”より読みやすく

using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

while (await timer.WaitForNextTickAsync(ct))
{
try
{
await DoWorkAsync(ct);
}
catch (Exception ex)
{
// ログして次の tick へ
}
}

定期実行は「失敗したらすぐ再試行」より、失敗回数に応じて間隔を伸ばす(バックオフ)ほうが結果的に復旧が早くなります。相手が落ちているときにこちらだけが連打すると、復旧の邪魔になり、レート制限に引っかかりやすくなるからです。

タイムアウト設計:HttpClient.Timeout と CancellationToken を使い分ける

タイムアウトは「全体の上限」と「操作ごとの上限」を分けると事故が減ります。特に、ループ処理では 1 回の遅延が積み上がり、いつまで経っても終わらない状態になりがちです。

手段効く範囲使いどころ注意点
HttpClient.Timeoutリクエスト全体“どんな場合でもこれ以上待たない”上限タイムアウト時は TaskCanceledException として見えることが多い
CancellationTokenSource(TimeSpan)呼び出し単位処理単位で柔軟に設定したいユーザーキャンセルと区別するための判定が必要
サーバー側のタイムアウト相手側相手の SLA に合わせるこちらが短すぎると成功率が下がる
// “この 1 回”だけ 5 秒上限で呼ぶ例
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));

using var response = await _httpClient.GetAsync(url, cts.Token);
response.EnsureSuccessStatusCode();

例外処理:AggregateException の“中身”ではなく、原因別に考える

.Result をやめて await に揃えると、例外設計もシンプルになります。通信処理で現実的に多い例外を、原因と対処の観点で整理すると次の通りです。

例外(代表)よくある原因推奨対応ログに残すと役立つもの
HttpRequestExceptionDNS、接続不可、TLS、ネットワーク断、HTTP/2 の切断など一時障害ならリトライ検討。恒久障害なら即失敗で良い。URL、メソッド、リトライ回数、InnerException、環境情報
TaskCanceledExceptionタイムアウト、またはキャンセル“誰がキャンセルしたか”を区別して扱うタイムアウト値、キャンセル元(ユーザー/上位)、経過時間
OperationCanceledException上位からのキャンセル正常系として扱う場合もある(終了要求など)キャンセル理由、トレース ID
HttpRequestException + StatusCode(.NET 5+)HTTP ステータスが 4xx/5xxコード別に分岐(429 は待つ、401 は認証更新、など)StatusCode、レスポンス本文(機密に注意)、ヘッダー

また、EnsureSuccessStatusCode() を使うかどうかは設計次第です。API 側が 400/500 の本文に詳細エラーを返すなら、EnsureSuccessStatusCode の前に本文を読んでログに残し、ドメイン例外に変換するほうがトラブルシュートしやすいことがあります。

HttpClient は“使い回し”が基本:IHttpClientFactory を検討する

ループ内で new/dispose を繰り返すのは避けましょう。HttpClient は内部に接続プール(ハンドラー)を持ち、使い回すことで効率と安定性が上がります。逆に、毎回 new すると、TIME_WAIT の増加やソケット枯渇の原因になります。

現在の .NET では、アプリケーションで HttpClient を使うなら IHttpClientFactory(DI)を使うのが定石です。ハンドラーの寿命管理や DNS 変更への追従など、運用で効いてくる部分を標準化できます。

ASP.NET Core の例:Typed Client

// Program.cs など
builder.Services.AddHttpClient<MyApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.Timeout = TimeSpan.FromSeconds(30);
});
public class MyApiClient
{
    private readonly HttpClient _client;
    public MyApiClient(HttpClient client) => _client = client;

    public async Task<string> GetSomethingAsync(CancellationToken ct)
    {
        using var res = await _client.GetAsync("/v1/something", ct);
        res.EnsureSuccessStatusCode();
        return await res.Content.ReadAsStringAsync(ct);
    }
}

高頻度アクセス時の調整:MaxConnectionsPerServer など

大量並列で叩く場合は、スロットリングだけでなく、接続数やプールの寿命も現実に効きます。たとえば外部 API が同時接続数に厳しい場合、MaxConnectionsPerServer を下げることで“こちらが暴れない”ようにできます。

builder.Services.AddHttpClient("MyApi")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        MaxConnectionsPerServer = 10,
        PooledConnectionLifetime = TimeSpan.FromMinutes(5)
    });

「ConfigureAwait(false) で直った」ように見えるときのチェックポイント

最後に、現場で本当に多い“勘違い”をまとめます。次のいずれかが混ざっていると、ConfigureAwait(false) を付けたタイミングで偶然状況が変わり、「直った」と見えてしまうことがあります。

  • await し忘れ:Task を返しているのに呼び出し側で await していない(例外が未観測になりやすい)
  • fire-and-forget:ループ内で Task を投げっぱなしにしている(過負荷・例外未観測・順序不定)
  • ログの粒度不足:URL、HTTP メソッド、ステータス、リトライ回数が無く、原因が追えない
  • リトライのしすぎ:失敗時に即時リトライして、さらに失敗率を上げている(とくに 429/503)
  • レスポンス未破棄:HttpResponseMessage を Dispose せず、接続がプールに戻らない

特に「レスポンス未破棄」は静かに効きます。読み切らないまま放置すると接続が占有され、しばらくしてからタイムアウトが増える、という形で表面化します。using var response を基本形に入れておくと、こうした事故を避けやすくなります。

チェックリスト:HttpClient を安全に運用するための実践項目

  • 通信呼び出しは .Result/.Wait() を使わず、async/await で連鎖させる
  • ループ処理は「逐次」「並列(制限あり)」を使い分け、無制限並列を避ける
  • タイムアウトは上限を決め、必要なら呼び出し単位で CancelAfter を使う
  • 例外は AggregateException ではなく、HttpRequestException/TaskCanceledException など原因別に扱う
  • レスポンスは必ず Dispose(using var response)して接続を返す
  • HttpClient は使い回す。ASP.NET Core なら IHttpClientFactory を第一候補にする
  • ConfigureAwait(false) は“ライブラリ層”でのコンテキスト依存排除に使う(UI/HttpContext 依存のコードでは注意)
  • 失敗前提でログ(URL、メソッド、ステータス、所要時間、リトライ回数)を残す

HttpClient の問題は「ネットワークが悪い」で片付けられがちですが、実際は async の使い方(.Result の混入) と ループ設計(無制限・バックオフ無し) が原因になっていることが少なくありません。await を軸に書き方を統一し、並列数・タイムアウト・破棄をセットで整えると、例外は“減る”というより“原因が見える”ようになり、結果として安定運用につながります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次