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

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

更新履歴(7件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
接続プールがハンドラー側にあることを示す図と、ソケット枯渇とDNS変更という2つの失敗がどうつながっているかを示す図を追加しました。あわせて第4章のハンドラープールの図をテキストから作図に置き換え、図番号を記事内で通しに直しています。本文の説明は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
エフェメラルポート枯渇の説明と切り分けを直しました。動的ポート範囲の16384個を「1台から同時に張れる接続の上限」と書いていましたが、TCPは接続を4つ組で識別するため、同じポート番号が宛先の違う接続に載ることは仕様上あり得ます。マシン全体のTIME_WAITの総数を16384と比べる目安表も、他の宛先への健全な接続を数えて誤診する形だったので、Microsoftの切り分け手順(TIME_WAITの多さ自体は証拠にならないこと、外向き接続の一斉失敗・イベントID 4227/4231・単一PIDへの偏りで確認すること)に沿って、プロセス単位・宛先単位で束ねる形に置き換えました。`SocketException`が出た=枯渇ではないことも明記しています。
.NET Framework向けの代替策のコード例で、接続の寿命を設定するメソッドを定義したまま呼び出していなかったため、最初のリクエストより前に必ず通る形に直しました。呼ばれないままだと接続が無期限に維持され、対策が効きません。
ソケットが枯渇する仕組みを、エフェメラルポートの本数とTIME-WAITから順に説明する形にしました。あわせてDIとGeneric Hostの用語説明、.NET Frameworkに留まる場合の代替策、netstatの結果の判定基準、ハンドラープールの構造図を追加しています。
本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589979)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「HttpClientをusingで囲んではいけない ── C#業務アプリのHTTP通信実務(生成パターン・タイムアウト・リトライ)」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589979 https://staging.comcomponent.com/blog/csharp-httpclient-practical-guide/

DOI(最新版)
10.5281/zenodo.21589979
DOI(この版)
10.5281/zenodo.21732963

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

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

1. まず結論(判断表)

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

表に出てくる略語だけ先に開いておきます。DI(Dependency Injection: 依存性注入) は、クラスが必要な部品を自分で new せず外から渡してもらう設計手法で、.NETでは Microsoft.Extensions.DependencyInjection がその入れ物です。Generic Host は、そのDIコンテナーに加えて設定・ログ・常駐処理の起動停止までまとめて面倒を見る.NETの汎用ホストで、「Generic Hostとは何か」と「Generic Host + BackgroundServiceをデスクトップアプリで使う」で扱っています。IHttpClientFactory は、そのDIの上に載る HttpClient の供給役です(第4章)。DIを使っていないアプリでも読めるように、DIなしの手段(static + PooledConnectionLifetime)から順に説明します。

アプリの形態 推奨パターン 理由
数秒〜数分で終わるコンソールツール(.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

なお、この記事のコード例はいずれも説明に必要な部分だけを抜き出した断片です。loggerMicrosoft.Extensions.Logging.ILoggerct は呼び出し元から渡された CancellationTokenurlclient はそれぞれ接続先URLと HttpClient のインスタンスを指し、いずれも周囲のクラス・メソッドで用意されている前提で書いています。そのまま Main に貼っても動きません。

この記事の知識マップ

C#のHttpClientはリクエストごとにusingで生成・破棄すると、閉じたソケットがTIME_WAIT状態でポートを占有し続けソケット枯渇を招き、逆にstaticのまま固定するとDNSを接続作成時にしか解決せず接続先の変更に追従できなくなる。公式の解決策は、SocketsHttpHandlerのPooledConnectionLifetimeで接続の寿命を区切るか、DIを使うならIHttpClientFactoryにハンドラーの管理を任せ、既定2分ごとに入れ替えて両方の問題を防ぐことである。ただしCookieContainerが意図せず共有される副作用があり、SocketsHttpHandlerが使えない.NET FrameworkではIHttpClientFactory導入かServicePoint.ConnectionLeaseTimeoutでの折衷策が必要になる。既定100秒のタイムアウトは接続先ごとに明示し、リトライは標準のresilienceハンドラーに任せつつ二重登録になり得るメソッドは無効化する。

C# HttpClientの正しい持ち方の知識マップHttpClientを毎回生成・破棄するとソケット枯渇を招き、staticのまま固定するとDNS変更に追従できなくなること、SocketsHttpHandlerのPooledConnectionLifetimeとIHttpClientFactoryがそれぞれ公式の解決策であること、タイムアウトとリトライの設計指針を示す図利用する原因になり得る利用する原因になり得る軽減する防止する防止するで構成できる両立しない推奨される対応防止するで構成できる利用する利用する原因になり得る利用するの後継原因になり得る前提とするHttpClientIHttpClientFactorySocketsHttpHandlerリクエストごとのHttpClient生成・破棄TCPのTIME-WAIT状態エフェメラルポート(動的ポート)ソケット枯渇static HttpClientのDNS変更への非追従PooledConnectionLifetime.NET FrameworkServicePoint.ConnectionLeaseTimeoutHttpClient.TimeoutプロパティCancellationToken(.NET)Microsoft.Extensions.Http.Resilience 標準ハンドラー安全でないHTTPメソッドの既定リトライによる二重実行リスクプロキシの自動検出WebClient/HttpWebRequest(レガシーHTTP API)CookieContainerの意図しない共有.NET(Core以降)

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全19件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

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

ただ、「ソケットが残る」だけでは、なぜ枯渇にまで至るのかが腹落ちしません。もう一段だけ内部を見ておきます。鍵は、接続のたびに消費される自分側のポート番号が有限だということです。

  1. クライアントがサーバーへTCP接続を張るとき、宛先ポート(HTTPSなら443)は決まっていますが、自分側のポート番号はOSが空きから自動で割り当てます。これをエフェメラルポート(ephemeral port、Windowsの用語では動的ポート)と呼びます。
  2. Windowsの既定の動的ポート範囲は 49152〜65535、つまり16384個です5。これは、外向き接続を張るときにOSが割り当て元にするプールで、トランスポート(TCP/UDP)ごと・IPv4/IPv6ごとに設定されます5「1台につき同時16384接続まで」という上限ではありません。TCPは接続を「自分側のアドレスとポート」「相手側のアドレスとポート」の4つ組で区別するため6、同じポート番号が宛先の違う複数の接続に載ることは仕様上あり得ます。効いてくるのは総数そのものより、1つのプロセスがどれだけの速さでプールを削るかです。
  3. 接続を閉じても、先に閉じた側のポートはすぐには再利用できません。遅れて届いた古いパケットが、同じポートの組み合わせで張られた新しい接続に紛れ込むのを防ぐため、TCPはTIME-WAIT状態を一定時間(2×MSL: Maximum Segment Lifetimeの2倍)維持します6
  4. 結果として、「リクエストのたびに接続を張って閉じる」設計は、1リクエストにつきポートを1個、数十秒から数分間、使用済みとして押さえ続けることになります。毎秒100リクエストなら毎秒100個の速度で16384個の枠を削っていく計算で、数分で底を突きます。

接続プールを持っているのは HttpClient そのものではなく、その内側のハンドラーです。インスタンスを作り直すとプールごと作り直しになる、というのが枯渇の入口です。

リクエストのたびに new して using で破棄するハンドラーごと作り直される接続プールは毎回空毎回 TCP 接続を張る閉じてもポートは TIME-WAIT で残るポートを削り続ける = ソケット枯渇static の HttpClient を1つ使い回すハンドラーが接続プールを保持し続けるファクトリからクライアントを都度受け取る(使い回すのはクライアントではなくハンドラー・第4章)確立済みの接続を再利用するポートを新たに消費しない

図1: 再利用されるのはハンドラーが抱える接続プール。HttpClient を作り直すとプールも一緒に捨てられる

プールを使い切ると、次の接続要求は割り当てるポートがなく失敗します。CPUもメモリも余裕があるのに接続だけができないという一見不可解な症状になるのは、枯渇しているのがポート番号という別の資源だからです。ただし、SocketException が出た=枯渇、ではありません。相手の停止、経路の遮断、DNS、TLS の失敗も同じ例外型で上がってきます。切り分けは次項の手順で行います。Microsoftの公式ガイドラインも、接続を閉じてもTCPポートは即座に解放されず、リクエスト頻度が高いとOSの利用可能ポート数の上限に達しうる、と明記しています1

この不具合が厄介なのは、開発中・テスト中にはまず再現しないことです。1秒に数回の手動操作では枠を削り切れないためで、本番の繁忙時間帯だけ落ちる、月末バッチのときだけ落ちる、という形で現れます。調査する場合は、現象発生時に次のコマンドでTIME_WAITのソケット数と、自環境のポート範囲を確認します。

# TIME_WAIT状態のソケット数を数える
(netstat -ano | Select-String "TIME_WAIT").Count

# 自分の環境の動的ポート範囲を確認する(既定は開始49152・16384個)
netsh int ipv4 show dynamicport tcp

ここで、マシン全体のTIME_WAITの総数を範囲の16384と比べてはいけません。そのマシンが多数の宛先へつないでいれば総数は大きくなりますが、それは他の宛先への健全な接続を数えているだけで、こちらのアプリが枯渇に近いことを意味しません。Microsoftの切り分け手順にも、TIME_WAITが大量にあること自体は枯渇の証拠にならない(将来枯渇し得ることを示すだけだ)と明記されています7

見るべきは、プロセス単位・宛先単位の内訳増え方です。

# プロセスと状態で束ねる。1つのPIDにTIME_WAITが偏っていれば、そのプロセスが犯人
Get-NetTCPConnection | Group-Object State, OwningProcess |
    Sort-Object Count -Descending | Select-Object -First 10 Count, Name

# 疑わしい宛先だけを、ローカルアドレスと相手側エンドポイントで束ねる
Get-NetTCPConnection -State TimeWait |
    Group-Object LocalAddress, RemoteAddress, RemotePort |
    Sort-Object Count -Descending | Select-Object -First 10 Count, Name

そのうえで、本当に枯渇しているかは症状で確かめます。Microsoftが挙げている確認手順は次の3点です7

確認すること 見かた
外向き接続が軒並み失敗するか 共有へのアクセス、別サーバーへのRDP、telnet を試す。1つの宛先だけ失敗するなら枯渇ではない
イベントログ システムログの イベントID 4227 / 4231
特定のPIDへの偏り netstat -anob で、1つのPIDにTIME_WAITが集中しているか

絶対数より「増え方」のほうが確かな証拠です。アプリを再起動した直後から右肩上がりに増え続け、負荷を止めても数分間減らないなら、生成パターンが原因とほぼ断定できます。逆に、負荷に応じて増減して頭打ちになるなら、接続は再利用できています。なお、TIME_WAITのソケットはプロセスから切り離された状態のため、PID列から呼び出し元アプリを特定できないことがあります。その場合は外部アドレス列(接続先)で絞り込んでください。Windows 10 / Windows Server 2016 以降なら、TIME_WAITを抜けて BOUND になったポートも netstat -anobqGet-NetTCPConnection で見られます7

なお、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

ここで対象環境を1か所に整理しておきます。この記事で出てくる手段のうち、どれが自分の環境で使えるかはこの表で確認してください。

手段 .NET Framework 4.8 .NET Core 2.1+ / .NET 5+
HttpClient 本体 使える(内部実装は HttpWebRequest / ServicePoint) 使える(内部実装は SocketsHttpHandler)
SocketsHttpHandler.PooledConnectionLifetime 使えない 使える。DNS追従の第一候補1
IHttpClientFactory Microsoft.Extensions.Http パッケージで使える。公式推奨1 使える
ServicePointManager.DefaultConnectionLimit 使える(既定2という小ささに注意)8 HttpClient には効かない。上限は MaxConnectionsPerServer で指定する
ServicePoint.ConnectionLeaseTimeout 使える(下記) 実質的に意味を持たない9

.NET Frameworkでの第一選択は、公式ガイドラインどおり Microsoft.Extensions.Http パッケージで IHttpClientFactory を導入することです1。とはいえ「既存の大きなWinFormsアプリに、まずDIを導入するところから始める余裕はない」という現場も多いはずです。その場合の折衷案が、HttpClient は1個を共有したうえで、接続の寿命を ServicePoint.ConnectionLeaseTimeout で区切る方法です。

using System;
using System.Net;
using System.Net.Http;

// .NET Framework 4.8 での折衷案:
// HttpClientは共有し、接続の寿命はServicePoint側で区切ってDNS変更に追従させる
private static readonly Uri ApiBase = new Uri("https://order.example.co.jp/");

private static readonly HttpClient SharedClient = CreateSharedClient();

private static HttpClient CreateSharedClient()
{
    // 最初のリクエストより前に必ず通す。ここを呼び忘れると
    // ConnectionLeaseTimeout は既定の -1(接続を無期限に維持)のままで、
    // DNS変更に追従しない=この節の対策が何も効いていない状態になる
    ServicePoint sp = ServicePointManager.FindServicePoint(ApiBase);
    sp.ConnectionLeaseTimeout = (int)TimeSpan.FromMinutes(2).TotalMilliseconds; // 単位はミリ秒

    return new HttpClient { BaseAddress = ApiBase };
}

ConnectionLeaseTimeout は「指定時間が経過した後、リクエストを1件処理し終えた時点でその接続を閉じる」という設定で、ロードバランサーの切り替えのように接続を定期的に張り直したい場面のために用意されているものです9。閉じられた接続は次のリクエストで作り直され、そこでDNSが再解決されます。発想は PooledConnectionLifetime とまったく同じで、.NET Framework側の対応物だと考えてください。

ただし ServicePoint を含む WebRequest 系のAPIは、新規開発では使わないことが公式に明記されています9。これはあくまで.NET Frameworkのまま延命する場合の手当てであり、.NETへの移行時には PooledConnectionLifetimeIHttpClientFactory に置き換えてください。移行時の観点は「.NET Framework→.NET移行前チェックリスト」にまとめています。

ここまでの2つの失敗は、どちらも「接続をいつ作り直すか」を決めていないことから来ています。第2章と第3章の関係を1枚にすると次のようになります。

リクエストごとに new して using で破棄1個を static にして持ち続けるだけ接続プールを共有したうえで、接続に寿命を持たせるHttpClient と接続プールをどう持つか接続プールが毎回捨てられる→ エフェメラルポートを削り続ける= ソケット枯渇(第2章)接続がプールに残り続ける→ DNS は接続作成時にしか解決されない= 切り替え後も旧IPへつなぐ(第3章)一定周期で接続を作り直す= 枯渇と DNS 変更の両方に対応できる環境ごとの手段は第1章の判断表のとおり・.NET 5+ : static + PooledConnectionLifetime・DI / Generic Host : IHttpClientFactory(クライアントは都度取得し、共有されるのはハンドラー・第4章)・.NET Framework : IHttpClientFactory、難しければ ConnectionLeaseTimeout

図2: 「使い回さない」と「使い回しっぱなし」はどちらも失敗する。正解は接続プールを使い回したうえで、接続に寿命を持たせること。何を共有するのかは手段ごとに違う(ファクトリならクライアントではなくハンドラー)

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);
});

ファクトリが何をしているかは、図で見るのが早いです。HttpClient と、接続プールを持つハンドラーが別物であることが、この仕組みのすべてです。

既定2分で寿命切れ。新規の払い出しを止め、使用中の処理が終わり次第、破棄されるCreateClient 1回目(使い捨てのクライアント)CreateClient 2回目(使い捨てのクライアント)入れ替え後の CreateClient(使い捨てのクライアント)ハンドラー 1接続プールの実体はここ接続プールTCP接続を再利用し、ソケットを捨てないハンドラー 2新しく作られる新しい接続プールここで DNS が再解決される

図3: IHttpClientFactoryのハンドラープールと定期的な入れ替え

この図から、記事の冒頭に書いた2つの結論が読み取れます。「使い捨てNG」なのは、自前の new HttpClient() では上段(クライアント)と下段(ハンドラー・接続プール)が1対1で心中し、捨てるたびにソケットまで捨ててしまうからです。「staticも中途半端」なのは、static にすると下段が固定されて破棄されなくなり、ソケットは守れるものの、ハンドラーの入れ替え(=DNSの再解決)を示す矢印が一度も起きないからです。ファクトリは上段を使い捨てにしたまま、下段だけを共有し、かつ定期的に入れ替えることで、両方を同時に満たしています。

ファクトリの動きで押さえておくべき点は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秒です10。画面操作の延長で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 が入ります10。ただし、上記のような自前の 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再送で産業用カメラ通信が止まる原因と切り分け」で扱った手法が参考になります。

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で並行度を上げる場合はこの上限を明示的に引き上げてください8
  • 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 7 8 9 10

  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, The default dynamic port range for TCP/IP has changed in Windows Vista and in Windows Server 2008. Windows Vista / Windows Server 2008以降の既定の動的ポート範囲が開始49152・終了65535であること、netsh int ipv4 show dynamicport tcpで現在の範囲を確認できることについて。  2

  6. IETF, RFC 9293 - Transmission Control Protocol (TCP), Section 3.3.2. TCPのTIME-WAIT状態と、2×MSL(Maximum Segment Lifetime)の待機によって遅延パケットが後続の接続に混入するのを防ぐ仕組みについて。あわせて、TCPの接続が両端のソケット(アドレスとポートの組)の対で識別されることについて。  2

  7. Microsoft Learn, TCP/IP port exhaustion troubleshooting. 動的ポート範囲がトランスポートごとに設定されること、TIME_WAITが大量にあること自体は枯渇の証拠にならず将来枯渇し得ることを示すにすぎないこと、外向き接続の一斉失敗・イベントID 4227/4231・単一PIDへのTIME_WAITの偏りで確認すること、Windows 10 / Windows Server 2016 以降ではnetstat -anobqGet-NetTCPConnectionでBOUND状態のポートも見られることについて。  2 3

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

  9. Microsoft Learn, ServicePoint.ConnectionLeaseTimeout Property. 既定値が-1(無期限)でミリ秒指定であること、指定時間の経過後にリクエストを処理し終えた接続が閉じられること、ロードバランシングのように接続を定期的に張り直したい場面向けの設定であること、WebRequest/ServicePoint系APIが新規開発では非推奨であること、.NET 9以降はこのプロパティがPooledConnectionLifetimeにマップされるものの実質的な効果を持たないことについて。  2 3

  10. Microsoft Learn, HttpClient.Timeout Property. 既定値が100秒であること、タイムアウト時に.NET 5以降では内部例外にTimeoutExceptionを持つTaskCanceledExceptionがスローされることについて。  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 ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る