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 に揃えると、例外設計もシンプルになります。通信処理で現実的に多い例外を、原因と対処の観点で整理すると次の通りです。
| 例外(代表) | よくある原因 | 推奨対応 | ログに残すと役立つもの |
|---|---|---|---|
| HttpRequestException | DNS、接続不可、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 を軸に書き方を統一し、並列数・タイムアウト・破棄をセットで整えると、例外は“減る”というより“原因が見える”ようになり、結果として安定運用につながります。

コメント