浏览知识库目录

C#

HTTP、JSON 与 API 客户端

编写带生命周期管理、超时、取消、响应限制和数据验证的 HttpClient API 客户端。

HTTP、JSON 与 API 客户端

网络调用的失败面远大于本地方法:DNS、连接、TLS、超时、取消、状态码、响应大小、JSON 格式和业务数据都可能失败。可靠客户端必须明确这些层次,并避免错误的生命周期或无上限重试。


一、学习目标

  • 通过 IHttpClientFactory 管理 HttpClient
  • 构造 URI、请求头和 JSON 内容
  • 设置总超时并传播取消
  • 区分网络、HTTP、数据和业务错误
  • 验证响应类型、大小和内容
  • 设计可替换、可离线测试的客户端

二、HttpClient 生命周期

不要每次请求都创建并释放 HttpClient。连接池需要跨请求复用,频繁创建可能耗尽套接字并反复建立 TLS。

注册类型化客户端:

builder.Services.AddHttpClient<RemoteTaskClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.Timeout = TimeSpan.FromSeconds(15);
    client.DefaultRequestHeaders.UserAgent.ParseAdd(
        "StudyTasks/1.0");
});
public sealed class RemoteTaskClient(
    HttpClient httpClient,
    ILogger<RemoteTaskClient> logger)
{
}

工厂管理底层处理器生命周期,类型化客户端可以短生命周期创建。不要在请求之间修改共享 DefaultRequestHeaders;每次不同的头放到 HttpRequestMessage


三、发送 JSON 请求

public async Task<RemoteTaskDto> CreateAsync(
    CreateTaskRequest payload,
    string token,
    CancellationToken cancellationToken)
{
    using var request = new HttpRequestMessage(
        HttpMethod.Post,
        "v1/tasks")
    {
        Content = JsonContent.Create(payload)
    };

    request.Headers.Authorization =
        new AuthenticationHeaderValue("Bearer", token);

    using HttpResponseMessage response =
        await httpClient.SendAsync(
            request,
            HttpCompletionOption.ResponseHeadersRead,
            cancellationToken);

    return await ReadTaskAsync(response, cancellationToken);
}

ResponseHeadersRead 在收到响应头后返回,避免先无上限缓冲整个正文;此时必须在读取完成前保持响应对象存活。

Token 来自秘密配置,不进入 URL、日志和异常文本。


四、处理状态码

private static void EnsureExpectedStatus(
    HttpResponseMessage response)
{
    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        throw new RemoteTaskNotFoundException();
    }

    if (response.StatusCode == HttpStatusCode.TooManyRequests)
    {
        throw new RemoteRateLimitException(
            response.Headers.RetryAfter);
    }

    if (!response.IsSuccessStatusCode)
    {
        throw new RemoteServiceException(
            response.StatusCode,
            $"远端返回 HTTP {(int)response.StatusCode}");
    }
}

EnsureSuccessStatusCode 适合只需通用异常的简单调用;业务客户端通常需要把已知状态码转换成稳定语义。

不要默认把整个错误正文放进异常。它可能巨大、含 HTML、Token 或用户数据。需要诊断时限制字节数并脱敏。


五、验证内容类型和大小

private static async Task<RemoteTaskDto> ReadTaskAsync(
    HttpResponseMessage response,
    CancellationToken cancellationToken)
{
    EnsureExpectedStatus(response);

    MediaTypeHeaderValue? contentType =
        response.Content.Headers.ContentType;
    if (contentType?.MediaType is not "application/json")
    {
        throw new RemoteProtocolException("响应不是 JSON");
    }

    long? length = response.Content.Headers.ContentLength;
    if (length is > 1_048_576)
    {
        throw new RemoteProtocolException("响应超过 1 MiB");
    }

    await using Stream stream =
        await response.Content.ReadAsStreamAsync(cancellationToken);

    RemoteTaskDto dto =
        await JsonSerializer.DeserializeAsync<RemoteTaskDto>(
            stream,
            JsonOptions,
            cancellationToken)
        ?? throw new RemoteProtocolException("响应根值为 null");

    return Validate(dto);
}

缺少 Content-Length 时,流仍可能无限增长。高风险场景用限制读取流或受控缓冲设置硬上限。

JSON 成功反序列化后继续校验 ID、状态、标题和时间关系。


六、错误分类

OperationCanceledException
  -> 用户取消或拥有策略的超时

HttpRequestException
  -> DNS、连接、TLS、协议传输

非成功状态码
  -> 远端明确响应

JsonException / 协议校验
  -> 响应格式不符合契约

领域校验失败
  -> 格式可读但业务数据非法

只在清楚语义的边界转换异常,并保留 inner exception。CLI 再把类别映射为消息和退出码。

不要把所有问题都报告成“网络错误”;HTTP 401 需要修复凭据,429 可能稍后重试,非法 JSON 则可能是服务版本不兼容。


七、超时与取消

HttpClient.Timeout 提供调用上限,调用方令牌支持用户取消。若某用例需要更短超时:

using var timeout = CancellationTokenSource.CreateLinkedTokenSource(
    cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));

await httpClient.SendAsync(request, timeout.Token);

捕获取消时判断是外部令牌还是内部超时。不要创建令牌源后忘记释放。

超时要覆盖整个有意义的操作,包括读取正文,而不是只覆盖响应头。


八、重试与幂等

可考虑重试的暂时错误:

  • 部分连接失败
  • HTTP 408、429
  • 部分 5xx

重试前必须确认操作幂等。GET 通常可重试;POST 创建任务若没有幂等键,第一次可能已成功但响应丢失,直接重试会创建重复数据。

重试策略应:

  • 次数有限
  • 指数退避并带随机抖动
  • 尊重 Retry-After
  • 传播取消
  • 记录最终失败而非每次都报高等级警报

验证错误、401、403、404 通常不应自动重试。


九、可测试设计

Core 定义:

public interface IRemoteTaskClient
{
    Task<IReadOnlyList<StudyTask>> FetchAsync(
        CancellationToken cancellationToken);
}

服务测试使用假实现。HTTP 适配器测试则替换 HttpMessageHandler,返回本地构造的响应:

internal sealed class StubHandler(
    Func<HttpRequestMessage, HttpResponseMessage> responder)
    : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken) =>
        Task.FromResult(responder(request));
}

覆盖成功、401、429、500、错误内容类型、超大正文、非法 JSON、超时和取消。测试不依赖公网。


十、常见错误

每次请求 new HttpClient

会破坏连接复用。使用 IHttpClientFactory 或受控长生命周期客户端。

对所有错误自动重试

会放大故障、重复非幂等操作并延迟明确失败。

只验证 JSON 语法

外部数据必须映射并验证为领域对象。

日志记录完整请求和响应

可能泄露认证、个人数据和大正文。只记录允许的元数据。


十一、练习与自测

练习:

  1. 实现类型化客户端,设置 BaseAddress、User-Agent 和超时。
  2. 为五类失败定义稳定异常。
  3. 使用 StubHandler 覆盖非法 JSON 和取消。
  4. 为创建任务设计幂等键,再决定哪些错误可重试。

自测:

  • 为什么不应每次创建 HttpClient?
  • ResponseHeadersRead 改变了谁的资源责任?
  • HTTP 500 与非法 JSON 分别属于哪类失败?
  • POST 在什么条件下可以安全重试?

十二、官方资料

上一篇:SQLite、事务与数据访问 | 下一篇:异步、并发与取消