不要用 using 包住 HttpClient ── C# 商用應用程式的 HTTP 通訊實務(生成模式・逾時・重試)

· · CSharp, .NET, HttpClient, IHttpClientFactory, 網路, async/await, Windows 開發, 技術諮詢

「一到下午,對外部 API 的連線就開始出現 SocketException 失敗」「明明切換了連線目標,應用程式卻還是持續連往舊伺服器」──C# 的 HttpClient,如果只是呼叫 GetAsync,用起來很簡單,但如果弄錯了要如何生成、持有實例,就會埋下這種「當下能動,但上線運行一段時間後才壞掉」的問題。

本文假設 Windows 商用應用程式呼叫外部 Web API 或內部服務的情境,依照實務上容易感到迷惘的順序,整理 HttpClient 的正確生成模式、逾時設計、重試、錯誤處理,以及 Windows 環境特有的陷阱。

1. 先講結論(判斷表)

HttpClient 該怎麼持有,答案會依應用程式的架構而異。先整理成判斷表。

應用程式的形態 建議模式 理由
執行時間僅數秒到數分鐘的主控台工具 (.NET) 使用 1 個 static/單例的 HttpClient 若是短命的行程,DNS 變更問題實質上可以忽略
長時間常駐的應用程式・Windows 服務 (.NET,不使用 DI) static 的 HttpClient +設定 SocketsHttpHandler.PooledConnectionLifetime 可同時解決 Socket 枯竭與 DNS 變更問題
使用 Generic Host / DI 的應用程式 (.NET) IHttpClientFactory (AddHttpClient) 可以把 Handler 的集區與汰換交給工廠處理。可用具名/型別化用戶端,依連線目標分離設定
.NET Framework 的應用程式 透過 Microsoft.Extensions.Http 套件導入 IHttpClientFactory 在 .NET Framework 中自行生成容易發生連接埠枯竭,官方也建議使用工廠1
依連線目標而異的 Proxy・Cookie・憑證設定 依設定分別建立 HttpClient(不共用) Handler 的連線設定在送出第一次請求後就無法變更2

在此基礎上,先寫出結論。

  • 不能每次請求都 new HttpClient(),然後用 using 釋放。 HttpClient 內部持有連線集區,其設計前提是被重複使用。若每次都產生、釋放,高負載時會把可用的 Socket 用盡,發生 SocketException2
  • 但也不是把它設成 static 就結束了。 HttpClient 只會在建立連線時解析 DNS,因此即使連線目標的 IP 位址改變,也會繼續使用舊的連線。官方建議的解法是用 SocketsHttpHandler.PooledConnectionLifetime 為連線設定壽命上限1
  • 如果使用 Generic Host 或 DI,就交給 IHttpClientFactory 處理。 工廠會將 Handler 集區化,並在預設 2 分鐘後汰換,藉此同時應對 Socket 枯竭與 DNS 變更兩個問題3
  • 逾時的預設值是 100 秒。 對商用應用程式的使用體感來說,這跟「無限期卡住」沒什麼兩樣,請依連線目標決定需求並明確設定。
  • 重試不要自己寫,先從 Microsoft.Extensions.Http.Resilience 的標準處理器開始。 它能以一組經過驗證的預設值,導入重試、斷路器(circuit breaker)、逾時等機制,並在設計階段就能避免自製重試迴圈常見的「無條件重送失敗的 POST,導致重複登錄」之類的事故4

2. 為什麼「每次都用 using 生成」不行 ── Socket 枯竭

因為 HttpClient 實作了 IDisposable,所以下面這樣的程式碼乍看之下似乎沒問題。

// 反模式:每次請求都產生並釋放
public async Task<string> GetDataAsync(string url)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}

問題在於,即使呼叫 Dispose,在作業系統層級 Socket 也不會立刻被釋放。依 TCP 規格,關閉一方的 Socket 會以 TIME_WAIT 狀態殘留一段時間。呼叫頻率低時不會有任何問題,但負載一旦升高,未釋放的 Socket 就會不斷累積,某個時間點會突然因 SocketException 而無法連線2

這類問題麻煩的地方在於,開發、測試階段幾乎不會重現。它通常會以「只有在正式環境的尖峰時段才會掛掉」、「只有在月底批次處理時才會掛掉」這種形式出現。若要調查,可以在問題發生時用下面的指令確認 TIME_WAIT 狀態的 Socket 數量,藉此推測原因所在。

# 依連線目標統計 TIME_WAIT 狀態的 Socket 數量
netstat -ano | Select-String "TIME_WAIT" | Measure-Object

另外,透過 IHttpClientFactory 取得的 HttpClient 不在此討論範圍內。工廠製造的用戶端即使呼叫 Dispose,Handler(連線集區的實體)也不會被釋放,因此用 using 包起來是安全的3

3. 設成 static 並非萬事 OK ── DNS 變更問題

為了因應 Socket 枯竭而將 HttpClient 設成 static,方向是對的,但這樣做仍會留下另一個問題。HttpClient 只會在建立連線時解析 DNS,也不會參照 DNS 記錄的 TTL2。只要連線還存活在集區中,即使連線目標的 IP 位址改變,也會持續連往舊的 IP。

「明明因為容錯移轉(failover)切換了 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,請使用下一章介紹的 IHttpClientFactory1

4. 若使用 DI,就用 IHttpClientFactory

在使用 Generic Host 或 DI 容器的應用程式中,IHttpClientFactoryAddHttpClient)是首選。關於 Generic Host 本身的說明,請參考「.NET 的 Generic Host 是什麼 - 先整理 DI、設定、日誌、BackgroundService」;在桌面應用程式中導入的方式,請參考「把 Generic Host / BackgroundService 帶進桌面應用程式的理由 - 啟動・壽命・graceful shutdown 的整理會輕鬆很多」。

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 點需要掌握。

  • Handler 會被集區化,預設每 2 分鐘汰換一次。 每次呼叫 CreateClient 都會回傳新的 HttpClient,但背後的 Handler(連線集區)是共用的,因此不會發生 Socket 枯竭,而定期汰換也能讓其跟上 DNS 變更3
  • 工廠製造的 HttpClient 的設計前提是短命使用。 若把取得的實例保存在單例(singleton)欄位中,就無法參與 Handler 的汰換,也就無法跟上 DNS 變更。基於相同理由,也請避免把型別化用戶端注入到單例服務中的架構3
  • 依賴 Cookie 的應用程式需要特別注意。 由於 Handler 會被集區化,CookieContainer 會在非預期的情況下被共用。若要使用 Cookie,官方的建議是避免使用工廠,或是停用 Cookie 處理、改為自行附加標頭1

若要呼叫需要驗證的 API(例如受 Microsoft Entra ID 保護的 API),關於權杖(token)取得的部分,已在「在 WinForms/WPF 應用程式中導入 Entra ID 驗證 ── MSAL.NET 與 WAM 代理的實務架構」中說明,請參考該文。

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 造成的逾時,其內部例外(inner exception)會是 TimeoutException5。不過,像上面那樣使用自訂 CancellationTokenSource 造成的逾時,並不會附帶內部例外。要區分「逾時」與「使用者取消」,與其看內部例外,不如判斷「呼叫端傳入的 token 是否已經被取消」會更可靠(見第 7 章的程式碼範例)。請注意,如果 catch 區塊只捕捉 HttpRequestException,會漏接逾時的情況。
  • Timeout 是「整個請求」的限制。 若只想限制建立連線的時間,可以併用 SocketsHttpHandler.ConnectTimeout。像「伺服器掛掉時想在 3 秒內放棄,但正常情況下較大的回應想等待 60 秒」這樣的需求,就可以用這兩者的組合來表達2
  • 下載大型檔案時要避免預設的緩衝行為。 HttpClient 預設會把整個回應讀進記憶體,因此在下載數十 MB 以上的檔案時,應指定 HttpCompletionOption.ResponseHeadersRead,改以串流方式處理2

若想把逾時值抽出到 appsettings.json,依環境切換不同數值,「不只是 appsettings.json ── Windows 商用應用程式的組態管理實務(環境別設定・機密資訊・寫入位置)」中的判斷表可以直接套用。

另外,從 WinForms/WPF 呼叫 HTTP 通訊時,如果用 .Result.Wait() 進行阻塞式呼叫,會導致 UI 執行緒發生死結(deadlock)。這個經典陷阱在「C# async/await 的最佳實踐 - Task.Run 與 ConfigureAwait 的判斷表」中有詳細說明,建議在撰寫通訊處理程式碼之前先讀一遍。

6. 重試 ── 不要自製迴圈,改用標準的 resilience 處理器

網路本來就會發生暫時性的失敗,因此呼叫外部 API 的處理需要重試機制。不過,用 for 迴圈搭配 Task.Delay 自製重試,需要把下面這些點全部自行正確實作,實在不划算。

  • 區分「可以重試的失敗」(逾時、HTTP 408/429/5xx)與「重試也沒用的失敗」(HTTP 400/401/404)
  • 排除重新執行會造成事故的 HTTP 方法(例如 POST 造成的重複登錄)
  • 指數退避與抖動(jitter,防止所有用戶端同時重送,再度把伺服器打垮的隨機擾動)
  • 在故障持續發生時,直接停止呼叫本身的斷路器(circuit breaker)

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

有一點在預設值上需要特別注意:標準處理器預設會對所有 HTTP 方法進行重試。 若是登錄類的 POST 一旦重複執行就會造成困擾的 API,請停用不安全方法的重試4

httpClientBuilder.AddStandardResilienceHandler(options =>
{
    // 停用 POST/PUT/DELETE 等方法的重新執行
    options.Retry.DisableForUnsafeHttpMethods();
});

另外,重試能解決的終究只是暫時性的失敗。像「連線已經建立,但資料傳不過來」「回應極端緩慢」這類症狀,很多時候其實是 TCP 層的問題,這種情況的排查方式可以參考「TCP 重送讓工業相機通訊卡幾秒時 - RFC1323 timestamp 與重送等待的切分」中介紹的手法。

7. 錯誤處理 ── 該如何處理狀態碼

HttpClient 對於像 HTTP 404 或 500 這類「就 HTTP 而言確實有回應,但屬於失敗」的情況,並不會拋出例外。會拋出例外的,是連線失敗、逾時、取消等「連回應本身都沒能取得」的情況。撰寫程式碼時要意識到這兩種系統並分開處理。

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)
{
    // 由呼叫端傳入的 token 所觸發的取消(例如關閉畫面等)。
    // 這不算錯誤,因此不弄髒日誌,直接原樣往上傳播
    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 環境的實務中經常踩到的陷阱。

  • Proxy 自動偵測會拖慢第一次請求。 在 Windows 的預設設定下,HttpClient 會使用作業系統的 Proxy 設定(包含自動偵測)。若確定不需要 Proxy,將 HttpClientHandler.UseProxy 設為 false 停用即可省去偵測等待時間2。反之,在必須使用公司內部 Proxy 的環境中,透過 WebProxy 明確指定,較能避免「開發機能動,伺服器卻不能動」的情況。
  • Proxy 設定要在第一次請求之前就完成。 Handler 的連線相關設定,一旦送出過請求後再變更也不會生效2
  • 同時連線數的預設值,在 .NET 與 .NET Framework 上恰好相反。 在 .NET(SocketsHttpHandler)中,HTTP/1.1 的同時連線數預設是無限制,因此在大量平行請求下連線數會持續增加,有時會碰到防火牆或伺服器端的限制。在高並行度的處理中,請用 MaxConnectionsPerServer 設定上限2。反之,在 .NET Framework 中,ServicePointManager.DefaultConnectionLimit 的預設值很小(非 ASP.NET 環境下為 2),會發生平行請求在內部被排隊等待、進而逾時的問題。若要在 .NET Framework 中提高並行度,請明確調高這個上限6
  • 從 Windows 服務呼叫時,Proxy 與 TLS 的執行內容和使用者不同。 服務的執行帳戶並沒有使用者的 Proxy 設定或憑證,這是「互動式使用者能動,但服務不能動」這類通訊問題的經典成因。關於服務特有的執行內容,請參考「Windows 服務的建立與維運 ── 從工作排程器的取捨到 BackgroundService 服務化」。
  • 不要把通訊目標的 URL 或 API 金鑰寫死在程式碼裡。 連線目標的切換請交給設定檔(「不只是 appsettings.json ── Windows 商用應用程式的組態管理實務(環境別設定・機密資訊・寫入位置)」),機密資訊的保存則請採用「Windows 應用程式不要把機密資訊以明文存進設定檔的最佳實踐」中的方法。

結語

HttpClient 的實務品質,與其說取決於「怎麼呼叫」,不如說取決於「怎麼持有」。每次請求都生成會招致 Socket 枯竭,天真地設成 static 又會導致無法跟上 DNS 變更,而這兩者在開發階段都看不出來。若是 .NET,答案是附帶 PooledConnectionLifetime 的共用實例或 IHttpClientFactory;若是 .NET Framework,答案就是導入 IHttpClientFactory。在此基礎之上,再依連線目標明確設定逾時,並將重試交給標準的 resilience 處理器──做到這個程度,才算是一個把「網路偶爾會失敗」當作前提的商用應用程式。

對既有應用程式通訊相關的檢討(只在尖峰時段掛掉的通訊、逾時設計的整理、外部 API 串接的新開發),往往需要一邊查看實際程式碼與運行環境一邊做判斷,如果拿不定主意,歡迎與我們聯絡諮詢。

相關文章

相關諮詢領域

合同會社小村軟體處理包含外部 API 串接在內的 Windows 商用應用程式開發,以及既有應用程式通訊問題(Socket 枯竭・逾時・間歇性連線失敗)的調查與改善方針的技術諮詢。

參考資料

  1. Microsoft Learn, Guidelines for using HttpClient。關於 .NET Core/.NET 5+ 應使用設定了 PooledConnectionLifetime 的長壽命用戶端,或是 IHttpClientFactory 製造的短壽命用戶端;.NET Framework 建議使用 IHttpClientFactory;以及使用 Cookie 的應用程式應避免 IHttpClientFactory(因 CookieContainer 會被共用)等內容。  2 3 4 5 6

  2. Microsoft Learn, HttpClient Class。關於每次請求都生成會導致 Socket 枯竭與 SocketException;DNS 只在建立連線時解析、也不參照 TTL;Handler 的連線設定在第一次請求後就無法變更;HTTP/1.1 的同時連線數預設無限制;大型下載建議使用串流;以及 Proxy 的預設行為與透過 UseProxy 停用等內容。  2 3 4 5 6 7 8 9

  3. Microsoft Learn, IHttpClientFactory with .NET。關於 Handler 的預設壽命為 2 分鐘;工廠製造的 HttpClient 應以短命方式使用;工廠製造的用戶端呼叫 Dispose 不會釋放 Handler;以及把型別化用戶端注入單例會導致無法跟上 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 以降在逾時發生時,會拋出內部例外為 TimeoutExceptionTaskCanceledException 等內容。  2

  6. Microsoft Learn, ServicePointManager.DefaultConnectionLimit Property。關於預設同時連線數在 ASP.NET 代管的應用程式中為 10,其他情況(桌面應用程式等)為 2 等內容。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

HttpClient 明明實作了 IDisposable,為什麼不能用 using 包起來?
問題出在「每次請求都產生並釋放」這種用法。HttpClient 內部持有連線集區(connection pool),其設計前提是在應用程式的生命週期內重複使用。若每次都產生、釋放,即使釋放後 Socket 仍會以 TIME_WAIT 狀態殘留一段時間,高負載時就會把 Socket 用盡,進而引發 SocketException。請改成只在應用程式結束時釋放一次,或是透過 IHttpClientFactory 取得。透過工廠(factory)取得的 HttpClient,即使呼叫 Dispose,其 Handler 也不會被釋放,因此用 using 包起來也沒問題。
應該從 .NET Framework 的 WebClient 或 HttpWebRequest 遷移過來嗎?
建議新寫的程式碼統一改用 HttpClient。WebClient 與 HttpWebRequest 是為了相容性而保留下來的舊 API,Microsoft 也建議新開發一律使用 HttpClient。並不需要一次性替換所有既有程式碼,但只要在調整通訊相關程式碼時逐步遷移到 HttpClient,在逾時控制、async 支援、易測試性方面都會讓維護變得輕鬆許多。
重試應該做幾次、間隔多久比較好?
與其自行決定,不如以 Microsoft.Extensions.Http.Resilience 標準處理器的預設值(最多 3 次、指數退避+抖動(jitter)、首次延遲 2 秒)作為起點,這樣比較安全。比起次數,更重要的是判斷「這個請求是否可以重試」──像 POST 這種重新執行可能造成重複登錄的操作,應該預設停用重試,或是在伺服器端確保冪等性(同一個請求送兩次結果也不會改變的特性)之後,才啟用重試。
為什麼在公司內部 Proxy 環境下,只有第一次請求會特別慢?
在 Windows 的預設設定下,HttpClient 會嘗試自動偵測 Proxy,因此第一次建立連線時,偵測處理可能會花費一些時間。若確定環境不需要 Proxy(例如伺服器內部通訊),可以將 HttpClientHandler 的 UseProxy 設為 false 停用自動偵測即可改善。反之,在必須使用 Proxy 的公司內部環境中,與其依賴自動偵測,不如透過 WebProxy 明確指定,行為會更穩定。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽