不要用 using 包裹 HttpClient —— C# 业务应用的 HTTP 通信实务(创建模式・超时设计・重试)
· Go Komura · CSharp, .NET, HttpClient, IHttpClientFactory, 网络, async/await, Windows 开发, 技术咨询
“一到下午,连接外部 API 就开始因为 SocketException 失败”“明明已经切换了目标服务器,应用却还在连着旧服务器不放”——C# 的 HttpClient 如果只是调用 GetAsync,用法看起来很简单,但如果搞错了实例该如何创建、如何持有,就会埋下这类“当时能跑,一到生产环境就出问题”的隐患。
本文假设 Windows 业务应用调用外部 Web API 或内部服务的场景,按照实务中最容易犹豫不决的顺序,整理 HttpClient 的正确创建模式、超时设计、重试、错误处理,以及 Windows 环境特有的一些坑。
1. 先给结论(判断表)
HttpClient 该怎么持有,答案会因应用的架构而不同。先用一张判断表做个总览。
| 应用形态 | 推荐模式 | 理由 |
|---|---|---|
| 运行数秒到数分钟即结束的控制台工具(.NET) | 使用一个 static/单例的 HttpClient |
短生命周期进程下,DNS 变更问题基本可以忽略 |
| 长期驻留的应用、Windows 服务(.NET,不使用 DI) | static 的 HttpClient + 设置 SocketsHttpHandler.PooledConnectionLifetime |
可同时解决套接字耗尽和 DNS 变更这两个问题 |
| 使用 Generic Host / DI 的应用(.NET) | IHttpClientFactory(AddHttpClient) |
可以把 Handler 的池化与替换都交给工厂处理。通过命名客户端/类型化客户端可以按连接目标分离配置 |
| .NET Framework 应用 | 通过 Microsoft.Extensions.Http 包引入 IHttpClientFactory |
.NET Framework 下自行创建实例容易出现端口耗尽,官方也推荐使用工厂1 |
| 不同连接目标的代理・Cookie・证书设置各不相同 | 按配置分别创建 HttpClient(不要复用) |
Handler 的连接相关设置在发送第一个请求之后就无法再修改2 |
在此基础上,先把结论写在前面。
- 不要每次请求都
new HttpClient(),然后用using释放。HttpClient内部持有连接池,其设计前提就是被复用。每次创建再释放,会在高负载时耗尽可用的套接字,进而抛出SocketException2。 - 但也不能仅仅改成
static就完事。HttpClient只在建立连接时解析一次 DNS,并不参考 DNS 记录的 TTL,因此即便连接目标的 IP 地址发生了变化,它也会继续使用旧的连接。官方推荐的解决方案是用SocketsHttpHandler.PooledConnectionLifetime来限定连接的生命周期1。 - 如果在使用 Generic Host 或 DI,就交给
IHttpClientFactory处理。 工厂会对 Handler 进行池化,并默认每 2 分钟替换一次,从而同时应对套接字耗尽和 DNS 变更两个问题3。 - 超时的默认值是 100 秒。 对业务应用的用户体验来说,这个时长跟“无限卡死”没什么区别,所以应该按连接目标分别明确设置。
- 重试不要自己造轮子,从
Microsoft.Extensions.Http.Resilience的标准处理程序开始。 它以经过验证的默认值一次性提供重试、熔断器、超时这一整套能力,可以在设计阶段就避免自制重试循环中常见的“无条件重发失败的 POST 导致重复注册”之类的事故4。
2. 为什么“用 using 每次都创建”不可取 —— 套接字耗尽
由于 HttpClient 实现了 IDisposable,下面这样的代码乍一看似乎没有问题。
// 反模式: 每次请求都创建后立即释放
public async Task<string> GetDataAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
问题在于,即使调用了 Dispose,在操作系统层面套接字也不会立刻被释放。根据 TCP 的规范,主动关闭一方的套接字会在 TIME_WAIT 状态下停留一段时间。调用频率低的时候不会出什么问题,但一旦负载升高,未释放的套接字就会不断累积,某个时刻会突然因为 SocketException 而无法建立连接2。
这个问题麻烦的地方在于,在开发、测试阶段基本不会复现。它通常表现为只在生产环境的高峰时段才发生故障,或者只在月末批处理时才出问题。如果要排查这个问题,可以在故障发生时用下面的命令统计 TIME_WAIT 状态的套接字数量,从而大致判断原因。
# 按连接目标统计 TIME_WAIT 状态的套接字数量
netstat -ano | Select-String "TIME_WAIT" | Measure-Object
另外,通过 IHttpClientFactory 获取的 HttpClient 不在这个问题的讨论范围内。由工厂生成的客户端即使调用 Dispose,其 Handler(连接池的实体)也不会被销毁,因此用 using 包裹是安全的3。
3. 改成 static 也不是终点 —— DNS 变更问题
作为套接字耗尽的对策,把 HttpClient 改成 static 是正确的方向,但这样做仍然遗留了另一个问题。HttpClient 只在建立连接时解析一次 DNS,也不参考 DNS 记录的 TTL2。只要连接还存活在连接池中,即便目标 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 的场景请参考下一章介绍的 IHttpClientFactory1。
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 点需要掌握。
- Handler 会被池化,默认每 2 分钟替换一次。 每次调用
CreateClient都会返回一个新的HttpClient,但背后的 Handler(连接池)是共享的,因此不会出现套接字耗尽,而定期替换又能跟上 DNS 的变更3。 - 工厂生成的
HttpClient前提是短生命周期使用。 如果把拿到的实例保存在单例字段中长期持有,就无法参与 Handler 的替换,也就无法跟上 DNS 变更。出于同样的理由,也应避免把类型化客户端注入到单例服务中3。 - 依赖 Cookie 的应用需要格外注意。 由于 Handler 是被池化的,
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引发的超时,其内部异常会包含TimeoutException5。但如果是像上面那样用自己的CancellationTokenSource触发的超时,则不会带有这个内部异常。要区分“超时”和“用户发起的取消”,比起看内部异常,更可靠的做法是判断“调用方传入的 Token 是否已被取消”(参见第 7 章的代码示例)。如果 catch 分支只捕获了HttpRequestException,就会漏掉超时的情况,需要注意。 Timeout限制的是“整个请求”的耗时。 如果只想限制建立连接的时间,可以配合使用SocketsHttpHandler.ConnectTimeout。像“服务器宕机时希望 3 秒就放弃,但正常情况下大响应体希望能等 60 秒”这样的需求,就可以用这两者的组合来表达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。
有一点需要特别注意默认值:标准处理程序默认会对所有 HTTP 方法进行重试。对于像注册类 POST 一旦被重复执行就会出问题的 API,应禁用不安全方法的重试4。
httpClientBuilder.AddStandardResilienceHandler(options =>
{
// 禁用 POST/PUT/DELETE 等的重复执行
options.Retry.DisableForUnsafeHttpMethods();
});
另外需要说明的是,重试能解决的终究只是暂时性的失败。像“连接已经建立却收不到数据”“响应异常缓慢”这类症状,很多时候其实是 TCP 层面的问题,排查这类问题时可以参考「TCP 重传与 RFC1323 —— 工业相机通信中断排查」一文中介绍的方法。
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.Json 提供的 GetFromJsonAsync / PostAsJsonAsync / ReadFromJsonAsync,这样就不用自己手写基于字符串的序列化代码。
8. Windows 业务应用特有的陷阱
最后,整理一些在 Windows 环境实务中经常踩到的坑。
- 代理自动检测导致第一次请求很慢。 在 Windows 的默认设置下,
HttpClient会使用操作系统的代理设置(包括自动检测)。如果确定不需要代理,可以通过HttpClientHandler.UseProxy = false关闭自动检测,从而消除等待检测的时间2。反过来,在必须使用公司内网代理的环境中,通过WebProxy显式指定代理,可以避免“开发机上能跑,服务器上跑不了”的问题。 - 代理设置要在发送第一个请求之前完成。 Handler 的连接相关设置,一旦发送过请求之后再修改也不会生效2。
- 并发连接数的默认值在 .NET 和 .NET Framework 中完全相反。 在 .NET(
SocketsHttpHandler)中,HTTP/1.1 的并发连接数默认是无限制的,因此在大量并发请求下连接数会持续增长,可能触发防火墙或服务端的限制。在并发量较高的处理中,应通过MaxConnectionsPerServer设置上限2。反过来,在 .NET Framework 中ServicePointManager.DefaultConnectionLimit的默认值很小(非 ASP.NET 环境下为 2),会出现并发请求在内部被阻塞、进而导致超时的问题。如果要在 .NET Framework 中提高并发度,需要显式调高这个上限6。 - 从 Windows 服务调用时,代理和 TLS 的上下文与普通用户不同。 服务的运行账户没有普通用户的代理设置和凭据,这是“交互式用户下能跑,服务下跑不了”这类通信故障的常见原因。关于服务特有的运行上下文,请参考「Windows 服务的创建与运维」。
- 不要把通信目标的 URL 或 API 密钥硬编码在代码里。 连接目标的切换应通过配置文件处理(参见「配置管理实务」),敏感信息的保存应参考「用 DPAPI 避免明文配置」中的方法。
结语
HttpClient 的实务,决定质量的与其说是“怎么调用”,不如说是“怎么持有”。每次请求都创建实例会导致套接字耗尽,不假思索地改成 static 又会导致无法跟上 DNS 变更,而这两个问题在开发阶段都难以察觉。在 .NET 中,答案是带 PooledConnectionLifetime 的共享实例或 IHttpClientFactory;在 .NET Framework 中,答案是引入 IHttpClientFactory。在此基础上,按连接目标显式设置超时,把重试交给标准的 resilience 处理程序——做到这一步,才算是一个真正把“网络偶尔会失败”作为前提来设计的业务应用。
对现有应用通信相关问题的排查(只在繁忙时段才出现的通信故障、超时设计的梳理、外部 API 对接的新开发),往往需要结合实际代码和运行环境来判断,如果拿不定主意,欢迎随时咨询。
相关文章
- C# async/await 实战判断表
- Generic Host 到底是什么
- 在桌面应用中使用 Generic Host + BackgroundService
- 不只是 appsettings.json —— Windows 业务应用的配置管理实务
- 在 WinForms/WPF 应用中集成 Entra ID 认证
- TCP 重传与 RFC1323 —— 工业相机通信中断排查
- TCP 中「发送单位=接收单位」的误区
- 异常处理中,catch 与日志应该放在哪里
- Windows 应用的敏感信息保存 - 用 DPAPI 避免明文配置
参考资料
-
Microsoft Learn, Guidelines for using HttpClient. 关于 .NET Core/.NET 5+ 中应使用设置了
PooledConnectionLifetime的长生命周期客户端、或由IHttpClientFactory生成的短生命周期客户端,.NET Framework 中推荐使用IHttpClientFactory,以及在使用 Cookie 的应用中应避免使用IHttpClientFactory(因为CookieContainer会被共享)等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Microsoft Learn, HttpClient Class. 关于每次请求都创建实例会导致套接字耗尽和
SocketException,DNS 只在建立连接时解析一次且不参考 TTL,Handler 的连接设置在发送第一个请求之后无法修改,HTTP/1.1 的并发连接数默认无限制,大文件下载时推荐使用流式处理,以及代理的默认行为与通过UseProxy禁用代理等内容。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 -
Microsoft Learn, IHttpClientFactory with .NET. 关于 Handler 的默认生命周期为 2 分钟,工厂生成的
HttpClient前提是短生命周期使用,工厂生成的客户端调用Dispose不会销毁 Handler,以及将类型化客户端注入单例会导致无法跟上 DNS 变更等内容。 ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, Build resilient HTTP apps: Key development patterns. 关于
AddStandardResilienceHandler所配置的 5 层策略(限流器/整体超时 30 秒/最多 3 次指数退避重试/熔断器/单次尝试超时 10 秒),作为处理对象的状态码(408/429/5xx)与异常,以及通过DisableForUnsafeHttpMethods禁用 POST 等方法的重试等内容。 ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, HttpClient.Timeout Property. 关于默认值为 100 秒,以及在 .NET 5 及以后版本中超时会抛出内部异常包含
TimeoutException的TaskCanceledException等内容。 ↩ ↩2 -
Microsoft Learn, ServicePointManager.DefaultConnectionLimit Property. 关于默认的并发连接数在 ASP.NET 托管的应用中为 10,而在其他场景(桌面应用等)中为 2 等内容。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
WinForms/WPF应用的多语言化 ── resx、附属程序集与区域文化切换的实务
本文从实务角度系统梳理Windows桌面应用多语言化的要点:CurrentCulture与CurrentUICulture的区别、resx与附属程序集构成的资源机制、WinForms的Localizable属性、WPF的现实方案选择、运行时语言切换,以及排版、格式、RTL等内容。
CSV 不是「纯文本」──C# 业务应用中 CSV 实务(字符编码・Excel 兼容性・注入攻击防范)
梳理业务应用 CSV 读写中常见的事故模式──用 Split(',') 自行解析、无 BOM 的 UTF-8 导致 Excel 乱码、前导零丢失、CSV 注入攻击──并从实务角度讲解 RFC 4180 规则、.NET 中 Shift_JIS 的处理方式(CodePagesE...
不只是 appsettings.json ── Windows 业务应用的配置管理实务(环境差异化设置・敏感信息・写入位置)
本文从实务角度整理 Windows 业务应用的配置管理,涵盖 appsettings.json 的分层、IConfiguration 与 IOptions/IOptionsSnapshot/IOptionsMonitor 的选用方式、环境差异化设置、可写入配置的存放位置、敏...
Windows 业务应用程序的打印与 PDF 输出 ── System.Drawing.Printing / WPF / 报表库的取舍选型
本文按需求整理成判断表,梳理 PrintDocument 的 WinForms 打印、WPF 的 FlowDocument/FixedDocument 打印,以及 PDF 输出的可选方案。并从实务角度讲解分页控制、DPI 偏差等实现陷阱,以及从 Windows 服务打印、依...
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
常见问题
汇总了咨询这一主题时常见的问题。
- HttpClient 明明实现了 IDisposable,为什么不能用 using 包裹?
- 问题出在“每次请求都创建后立刻释放”这种用法。HttpClient 内部维护着连接池,其设计前提是在应用程序的整个生命周期内被复用。如果每次都创建再释放,即便 Dispose 之后,套接字仍会在 TIME_WAIT 状态下停留一段时间,高负载时就会耗尽可用套接字,进而抛出 SocketException。请改为在应用退出时只释放一次,或者通过 IHttpClientFactory 获取实例。通过工厂获取的 HttpClient 即使调用 Dispose,其底层的 Handler 也不会被销毁,因此用 using 包裹反而是安全的。
- 是否应该从 .NET Framework 的 WebClient 或 HttpWebRequest 迁移过来?
- 建议新代码统一使用 HttpClient。WebClient 和 HttpWebRequest 是出于兼容性而保留的旧 API,微软官方也推荐在新开发中使用 HttpClient。虽然没必要把现有代码一次性全部替换,但在需要改动通信相关代码时,逐步迁移到 HttpClient 会让超时控制、异步支持、可测试性等方面的维护更轻松。
- 重试应该进行几次、间隔多长?
- 与其自行拍板决定,不如以 Microsoft.Extensions.Http.Resilience 标准处理程序的默认值(最多 3 次、指数退避加抖动、首次延迟 2 秒)作为起点,这样更稳妥。比起次数,更重要的是判断“这个请求是否可以重试”——像 POST 这类重复执行可能导致重复注册的操作,应默认禁用重试,或者先在服务端确保幂等性(同一请求被处理两次结果也不变)之后再开启重试。
- 为什么在公司内部代理环境下,只有第一次请求会异常缓慢?
- 在 Windows 的默认设置下,HttpClient 会尝试自动检测代理,因此首次建立连接时可能会花费一些时间在检测处理上。如果明确知道某个环境不需要代理(例如服务器内部通信),可以将 HttpClientHandler 的 UseProxy 设为 false 以关闭自动检测来改善这个问题。反过来,在必须使用代理的公司内网环境中,不依赖自动检测、而是用 WebProxy 显式指定代理,会让行为更加稳定。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。