HttpClientをusingで囲んではいけない ── C#業務アプリのHTTP通信実務(生成パターン・タイムアウト・リトライ)

· · CSharp, .NET, HttpClient, IHttpClientFactory, ネットワーク, async/await, Windows開発, 技術相談

「昼過ぎになると外部APIへの接続が SocketException で失敗し始める」「接続先を切り替えたのに、アプリが古いサーバーへつなぎ続ける」──C#の HttpClient は、GetAsync を呼ぶだけなら簡単ですが、インスタンスをどう生成・保持するかを誤ると、この種の「そのときは動くのに、運用に乗ってから壊れる」不具合を仕込むことになります。

この記事では、Windows業務アプリが外部のWeb APIや社内サービスを呼ぶ場面を想定して、HttpClient の正しい生成パターン、タイムアウト設計、リトライ、エラー処理、そしてWindows環境固有の落とし穴までを実務で判断に迷う順に整理します。

1. まず結論(判断表)

HttpClient の持ち方は、アプリの構成によって答えが変わります。まず判断表にまとめます。

アプリの形態 推奨パターン 理由
数秒〜数分で終わるコンソールツール(.NET) static/シングルトンの HttpClient 1個 短命プロセスならDNS変更問題は実質無視できる
長時間常駐するアプリ・Windowsサービス(.NET、DIなし) staticHttpClient + SocketsHttpHandler.PooledConnectionLifetime 設定 ソケット枯渇とDNS変更問題の両方を解決できる
Generic Host / DIを使うアプリ(.NET) IHttpClientFactory(AddHttpClient) ハンドラーのプールと入れ替えをファクトリに任せられる。名前付き/型付きクライアントで接続先ごとの設定を分離できる
.NET Frameworkのアプリ Microsoft.Extensions.Http パッケージで IHttpClientFactory を導入 .NET Frameworkでは自前生成でのポート枯渇が起きやすく、公式にもファクトリの利用が推奨されている1
接続先ごとにプロキシ・Cookie・証明書設定が異なる 設定ごとに HttpClient を分ける(使い回さない) ハンドラーの接続設定は最初のリクエスト送信後には変更できない2

そのうえで、結論を先に書きます。

  • リクエストのたびに new HttpClient() して using で破棄してはいけません。 HttpClient は内部に接続プールを持ち、再利用される前提で設計されています。毎回生成・破棄すると、高負荷時に利用可能なソケットを使い果たし、SocketException が発生します2
  • かといって static にして終わりでもありません。 HttpClient はDNSを接続作成時にしか解決しないため、接続先のIPアドレスが変わっても古い接続を使い続けます。SocketsHttpHandler.PooledConnectionLifetime で接続の寿命を区切るのが公式に推奨されている解決策です1
  • Generic HostやDIを使っているなら IHttpClientFactory に任せます。 ファクトリがハンドラーをプールし、既定2分で入れ替えることで、ソケット枯渇とDNS変更の両方に対処してくれます3
  • タイムアウトの既定値は100秒です。 業務アプリの体感としては「無限に固まる」のと大差ない長さなので、接続先ごとに要件を決めて明示設定してください。
  • リトライは自作せず、Microsoft.Extensions.Http.Resilience の標準ハンドラーから始めます。 リトライ・サーキットブレーカー・タイムアウトを実績ある既定値のセットで導入でき、自作のリトライループにありがちな「失敗したPOSTを無条件に再送して二重登録」のような事故を設計段階で避けられます4

2. なぜ「usingで都度生成」がだめなのか ── ソケット枯渇

HttpClientIDisposable を実装しているため、次のようなコードは一見正しく見えます。

// アンチパターン: リクエストのたびに生成して破棄する
public async Task<string> GetDataAsync(string url)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}

問題は、Dispose してもOSレベルではソケットがすぐには解放されないことです。TCPの仕様上、閉じた側のソケットはTIME_WAIT状態でしばらく残ります。呼び出し頻度が低いうちは何も起きませんが、負荷が上がると未解放のソケットが積み上がり、あるとき突然 SocketException で接続できなくなります2

この不具合が厄介なのは、開発中・テスト中にはまず再現しないことです。本番の繁忙時間帯だけ落ちる、月末バッチのときだけ落ちる、という形で現れます。調査する場合は、現象発生時に次のコマンドでTIME_WAITのソケット数を確認すると、原因の当たりが付きます。

# 接続先ごとのTIME_WAITソケット数を集計する
netstat -ano | Select-String "TIME_WAIT" | Measure-Object

なお、IHttpClientFactory 経由で受け取った HttpClient はこの話の対象外です。ファクトリ製のクライアントは Dispose してもハンドラー(接続プールの実体)は破棄されないため、using で囲んでも安全です3

3. staticにすれば終わりではない ── DNS変更問題

ソケット枯渇の対策として HttpClientstatic にするのは正しい方向ですが、それだけでは別の問題が残ります。HttpClient はDNSを接続の作成時にしか解決せず、DNSレコードのTTLも参照しません2。接続がプール内で生き続ける限り、接続先のIPアドレスが変わっても古いIPへつなぎ続けます。

「フェイルオーバーでDNSを切り替えたのに、再起動するまで旧サーバーを見続けた」という障害はこの仕組みが原因です。公式ガイドラインが推奨する解決策は、SocketsHttpHandler.PooledConnectionLifetime で接続の寿命を区切ることです1

// .NET (Core) / .NET 5+ での推奨パターン:
// 接続を一定周期で作り直させることで、DNS変更に追従する
private static readonly HttpClient SharedClient = new(new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2)
});

寿命を迎えた接続は次のリクエスト時に作り直され、そのタイミングでDNSが再解決されます。値はDNS変更にどれだけ早く追従したいかで決めます。公式ドキュメントの例では2分が使われていますが、接続先が滅多に変わらない社内システムならもっと長くても問題ありません1

なお SocketsHttpHandler は.NET Core 2.1以降の実装であり、.NET Frameworkでは使えません。.NET Frameworkの場合は次章の IHttpClientFactory を使ってください1

4. DIを使うなら IHttpClientFactory

Generic HostやDIコンテナーを使うアプリでは、IHttpClientFactory(AddHttpClient)が第一候補です。Generic Hostそのものの説明は「Generic Hostとは何か」を、デスクトップアプリへの導入は「Generic Host + BackgroundServiceをデスクトップアプリで使う」を参照してください。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// 名前付きクライアント: 接続先ごとに設定を分離する
builder.Services.AddHttpClient("OrderApi", client =>
{
    client.BaseAddress = new Uri("https://order.example.co.jp/");
    client.Timeout = TimeSpan.FromSeconds(10);
});

ファクトリの動きで押さえておくべき点は3つです。

  • ハンドラーはプールされ、既定で2分ごとに入れ替わります。 CreateClient のたびに新しい HttpClient が返りますが、裏側のハンドラー(接続プール)は共有されるためソケット枯渇は起きず、定期的な入れ替えによってDNS変更にも追従します3
  • ファクトリ製の HttpClient は短命に使うのが前提です。 受け取ったインスタンスをシングルトンのフィールドに保持すると、ハンドラーの入れ替えに参加できなくなり、DNS変更に追従しなくなります。型付きクライアントをシングルトンサービスに注入する構成も同じ理由で避けてください3
  • Cookieに依存するアプリでは注意が必要です。 ハンドラーがプールされる結果、CookieContainer が意図せず共有されます。Cookieを使う場合はファクトリを避けるか、Cookie処理を無効化して自前でヘッダーを付ける設計が公式に案内されています1

認証付きAPI(Microsoft Entra ID保護のAPIなど)を呼ぶ場合のトークン取得まわりは「WinForms/WPFアプリにEntra ID認証を組み込む」で扱っているので、そちらを参照してください。

5. タイムアウト設計 ── 既定の100秒は業務アプリには長すぎる

HttpClient.Timeout の既定値は100秒です5。画面操作の延長でAPIを呼ぶ業務アプリで100秒待たせるのは「固まった」と同じなので、接続先ごとに明示的に設定します。

// クライアント全体の既定タイムアウト
client.Timeout = TimeSpan.FromSeconds(10);

// 特定のリクエストだけ短く/長くしたい場合はCancellationTokenSourceを使う
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
HttpResponseMessage response = await client.GetAsync(url, cts.Token);

設計上の注意点は次のとおりです。

  • タイムアウト時の例外は TaskCanceledException です。 .NET 5以降、HttpClient.Timeout 起因のタイムアウトでは内部例外に TimeoutException が入ります5。ただし、上記のような自前の CancellationTokenSource によるタイムアウトでは内部例外は付きません。タイムアウトとユーザー起因のキャンセルの区別は、内部例外ではなく「呼び出し側から渡されたトークンがキャンセル済みかどうか」で判定するのが確実です(第7章のコード例)。catch節で HttpRequestException しか見ていないとタイムアウトを取り逃がすので注意してください。
  • Timeout は「リクエスト全体」の制限です。 接続確立だけを短く制限したい場合は SocketsHttpHandler.ConnectTimeout を併用します。「サーバーが落ちているときは3秒で諦めたいが、正常時の大きいレスポンスは60秒待ちたい」という要件はこの2つの組み合わせで表現できます2
  • 大きいファイルのダウンロードは既定のバッファリングを避けます。 HttpClient は既定でレスポンス全体をメモリに読み込むため、数十MB以上のダウンロードでは HttpCompletionOption.ResponseHeadersRead を指定してストリームで処理します2

タイムアウト値を appsettings.json に切り出して環境ごとに変える構成は「Windows業務アプリの構成管理実務」の判断表がそのまま使えます。

なお、WinForms/WPFからHTTP通信を呼ぶ際に .Result.Wait() でブロックするとUIスレッドのデッドロックを引き起こします。この定番の落とし穴は「C# async/await実務判断表」で詳しく説明しているので、通信処理を書く前に一読をおすすめします。

6. リトライ ── 自作ループではなく標準の resilience ハンドラーを使う

ネットワークは一時的に失敗するものなので、外部APIを呼ぶ処理にはリトライが必要です。ただし for ループと Task.Delay による自作リトライは、次の点を全部自分で正しく実装する必要があり、割に合いません。

  • リトライしてよい失敗(タイムアウト、HTTP 408/429/5xx)と、しても無駄な失敗(HTTP 400/401/404)の区別
  • 再実行すると事故になるHTTPメソッド(POSTによる登録の二重実行など)の除外
  • 指数バックオフとジッター(全クライアントが同時に再送してサーバーを再度倒すのを防ぐゆらぎ)
  • 障害が続くときに呼び出し自体を止めるサーキットブレーカー

Microsoft.Extensions.Http.Resilience パッケージの標準ハンドラーは、この一式を実績ある既定値で提供します4

builder.Services.AddHttpClient("OrderApi", client =>
    {
        client.BaseAddress = new Uri("https://order.example.co.jp/");
    })
    .AddStandardResilienceHandler(); // リトライ+サーキットブレーカー+タイムアウトの標準セット

標準ハンドラーの既定値は、リクエスト全体のタイムアウト30秒、最大3回の指数バックオフリトライ(初回遅延2秒・ジッター付き)、試行ごとのタイムアウト10秒、そしてHTTP 408/429/5xxと HttpRequestException を一時的エラーとして扱う構成です4

1点だけ既定値で注意すべきなのは、標準ハンドラーは既定ですべてのHTTPメソッドをリトライすることです。登録系のPOSTが二重実行されると困るAPIでは、安全でないメソッドのリトライを無効化してください4

httpClientBuilder.AddStandardResilienceHandler(options =>
{
    // POST/PUT/DELETE等の再実行を無効化する
    options.Retry.DisableForUnsafeHttpMethods();
});

なお、リトライで解決するのはあくまで一時的な失敗です。接続が確立しているのにデータが流れない・応答が極端に遅いといった症状はTCPレイヤーの問題であることも多く、その切り分けは「TCP再送とRFC1323 ── 産業用カメラの通信停止調査」で扱った手法が参考になります。

7. エラー処理 ── ステータスコードをどう扱うか

HttpClient は、HTTP 404や500のような「HTTPとしては応答が返ってきた失敗」では例外を投げません。例外になるのは、接続失敗・タイムアウト・キャンセルなど応答自体が得られなかった場合です。この2系統を意識して書き分けます。

try
{
    using HttpResponseMessage response = await client.GetAsync(url, ct);

    if (!response.IsSuccessStatusCode)
    {
        // 応答は返ってきたが失敗: ステータスコードで分岐できる
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null; // 「存在しない」を正常系として扱う例
        }
        response.EnsureSuccessStatusCode(); // それ以外はHttpRequestExceptionへ
    }

    return await response.Content.ReadFromJsonAsync<Order>(ct);
}
catch (HttpRequestException ex)
{
    // 接続失敗、またはEnsureSuccessStatusCodeによる失敗ステータス。
    // .NET 5以降はex.StatusCodeで失敗時のステータスコードを参照できる
    logger.LogError(ex, "注文APIの呼び出しに失敗しました。StatusCode={StatusCode}", ex.StatusCode);
    throw;
}
catch (TaskCanceledException) when (ct.IsCancellationRequested)
{
    // 呼び出し側から渡されたトークンによるキャンセル(画面を閉じた等)。
    // エラーではないのでログを汚さずそのまま伝播させる
    throw;
}
catch (TaskCanceledException ex)
{
    // HttpClient.Timeout、または自前のタイムアウト用CTSの期限切れ
    logger.LogError(ex, "注文APIがタイムアウトしました");
    throw;
}

「404を例外として扱うか、nullとして扱うか」のような判断は接続先APIの意味論次第です。何でも EnsureSuccessStatusCode 一発で例外にしてしまうと、呼び出し側のcatch節が肥大化します。例外にするもの・戻り値で表現するものの線引きは「例外のcatch・ログ・エラー処理の実務」の考え方がそのまま適用できます。

JSONの送受信は、System.Net.Http.JsonGetFromJsonAsync / PostAsJsonAsync / ReadFromJsonAsync を使うと、文字列経由のシリアライズを自前で書かずに済みます。

8. Windows業務アプリ固有の落とし穴

最後に、Windows環境の実務でよく踏む落とし穴をまとめます。

  • プロキシの自動検出で最初のリクエストが遅い。 Windowsの既定では、HttpClient はOSのプロキシ設定(自動検出を含む)を使います。プロキシ不要と分かっている場合は HttpClientHandler.UseProxy = false で無効化すると、検出待ちがなくなります2。逆に社内プロキシ必須の環境では、WebProxy で明示指定する方が「開発機では動くのにサーバーでは動かない」を避けられます。
  • プロキシ設定は最初のリクエスト前に済ませます。 ハンドラーの接続系設定は、一度リクエストを送った後に変更しても反映されません2
  • 同時接続数の既定値は.NETと.NET Frameworkで正反対です。 .NET(SocketsHttpHandler)ではHTTP/1.1の同時接続数は既定で無制限のため、大量の並行リクエストで接続が増え続け、ファイアウォールやサーバー側の制限に当たることがあります。並行度が高い処理では MaxConnectionsPerServer で上限を設けてください2。逆に.NET Frameworkでは ServicePointManager.DefaultConnectionLimit の既定値が2(非ASP.NET環境)と小さく、並行リクエストが内部で待たされてタイムアウトする側の問題が起きます。.NET Frameworkで並行度を上げる場合はこの上限を明示的に引き上げてください6
  • Windowsサービスから呼ぶ場合はプロキシとTLSの文脈がユーザーと異なります。 サービスの実行アカウントにはユーザーのプロキシ設定や資格情報がないため、「対話ユーザーでは動くのにサービスでは動かない」通信不具合の定番原因になります。サービス特有の実行文脈は「Windowsサービスの作り方と運用」を参照してください。
  • 通信先のURLやAPIキーをコードに埋め込まない。 接続先の切り替えは構成ファイル(「構成管理実務」)、秘密情報の保存は「DPAPIで平文設定を避ける」の方法に寄せてください。

まとめ

HttpClient の実務は、「どう呼ぶか」より「どう持つか」で品質が決まります。リクエストごとの生成はソケット枯渇を、無邪気な static 化はDNS変更への非追従を招き、どちらも開発中には見えません。.NETなら PooledConnectionLifetime 付きの共有インスタンスか IHttpClientFactory、.NET Frameworkなら IHttpClientFactory の導入が答えです。そのうえで、タイムアウトを接続先ごとに明示し、リトライは標準のresilienceハンドラーに任せる──ここまでやって初めて、「たまに失敗するネットワーク」を前提にした業務アプリになります。

既存アプリの通信まわりの見直し(繁忙時間帯だけ落ちる通信、タイムアウト設計の整理、外部API連携の新規実装)は、実際のコードと運用環境を見ながらの判断が必要になることが多いので、迷ったらご相談ください。

関連記事

関連する相談領域

合同会社小村ソフトでは、外部API連携を含むWindows業務アプリの開発、既存アプリの通信不具合(ソケット枯渇・タイムアウト・断続的な接続失敗)の調査と改修方針の技術相談を扱っています。

参考リンク

  1. Microsoft Learn, Guidelines for using HttpClient. .NET Core/.NET 5+ではPooledConnectionLifetimeを設定した長寿命クライアントまたはIHttpClientFactory製の短寿命クライアントを使うこと、.NET FrameworkではIHttpClientFactoryの利用が推奨されること、Cookieを使うアプリではCookieContainer共有のためIHttpClientFactoryを避けるべきことについて。  2 3 4 5 6

  2. Microsoft Learn, HttpClient Class. リクエストごとの生成がソケット枯渇とSocketExceptionを招くこと、DNSを接続作成時にしか解決せずTTLも参照しないこと、ハンドラーの接続設定が最初のリクエスト後は変更できないこと、HTTP/1.1の同時接続数が既定で無制限であること、大きなダウンロードでのストリーミング推奨、プロキシの既定動作とUseProxyによる無効化について。  2 3 4 5 6 7 8 9

  3. Microsoft Learn, IHttpClientFactory with .NET. ハンドラーの既定寿命が2分であること、ファクトリ製HttpClientは短命に使う前提であること、ファクトリ製クライアントのDisposeがハンドラーを破棄しないこと、型付きクライアントをシングルトンに注入するとDNS変更に追従しなくなることについて。  2 3 4

  4. Microsoft Learn, Build resilient HTTP apps: Key development patterns. AddStandardResilienceHandlerが構成する5段階の戦略(レートリミッター/全体タイムアウト30秒/最大3回の指数バックオフリトライ/サーキットブレーカー/試行タイムアウト10秒)、対象となるステータスコード(408/429/5xx)と例外、DisableForUnsafeHttpMethodsによるPOST等のリトライ無効化について。  2 3 4

  5. Microsoft Learn, HttpClient.Timeout Property. 既定値が100秒であること、タイムアウト時に.NET 5以降では内部例外にTimeoutExceptionを持つTaskCanceledExceptionがスローされることについて。  2

  6. Microsoft Learn, ServicePointManager.DefaultConnectionLimit Property. 既定の同時接続数がASP.NETホストされたアプリケーションでは10、それ以外(デスクトップアプリ等)では2であることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

HttpClientはIDisposableなのに、usingで囲んではいけないのですか?
「リクエストのたびに生成して破棄する」使い方が問題です。HttpClientは内部に接続プールを持ち、アプリケーションの生存期間を通して再利用する前提で設計されています。毎回生成・破棄すると、破棄後もソケットがTIME_WAIT状態でしばらく残るため、高負荷時にソケットを使い果たしてSocketExceptionが発生します。アプリ終了時に1回だけ破棄する、またはIHttpClientFactory経由で取得する形にしてください。ファクトリ経由で受け取ったHttpClientは、Disposeしてもハンドラーは破棄されないため、usingで囲んでも問題ありません。
.NET FrameworkのWebClientやHttpWebRequestからは移行すべきですか?
新規コードはHttpClientに統一することをおすすめします。WebClientとHttpWebRequestは互換性のために残されている古いAPIで、Microsoftも新規開発ではHttpClientの使用を推奨しています。既存コードの一括置き換えまでは不要ですが、通信まわりに手を入れるタイミングで順次HttpClientへ寄せていくと、タイムアウト制御・async対応・テスト容易性の面で保守が楽になります。
リトライは何回、どのくらいの間隔で行うべきですか?
自前で決めるより、Microsoft.Extensions.Http.Resilienceの標準ハンドラーの既定値(最大3回、指数バックオフ+ジッター、初回遅延2秒)を出発点にするのが安全です。重要なのは回数よりも「リトライしてよいリクエストか」の判別で、POSTのような再実行すると二重登録になりうる操作は、既定で無効化するか、サーバー側で冪等性(同じリクエストを2回受けても結果が変わらない性質)を担保してからリトライを有効にしてください。
社内プロキシ環境で最初のリクエストだけ極端に遅いのはなぜですか?
Windowsの既定設定では、HttpClientはプロキシの自動検出を試みるため、最初の接続時に検出処理の時間がかかることがあります。プロキシが不要なことが分かっている環境(サーバー内通信など)では、HttpClientHandlerのUseProxyをfalseにして自動検出を無効化すると改善します。逆にプロキシ必須の社内環境では、自動検出に頼らずWebProxyで明示的に指定する方が動作が安定します。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る