dotnet-http-client

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

dotnet-http-client

.NET HTTP客户端

Best practices for consuming HTTP APIs in .NET applications using
IHttpClientFactory
. Covers named and typed clients, resilience pipeline integration,
DelegatingHandler
chains for cross-cutting concerns, and testing strategies.
Out of scope: DI container mechanics and service lifetimes -- see [skill:dotnet-csharp-dependency-injection]. Async/await patterns and cancellation token propagation -- see [skill:dotnet-csharp-async-patterns]. Resilience pipeline configuration (Polly v8, retry, circuit breaker, timeout strategies) is owned by [skill:dotnet-resilience]. Integration testing frameworks -- see [skill:dotnet-integration-testing] for WebApplicationFactory and HTTP client testing patterns.
Cross-references: [skill:dotnet-resilience] for resilience pipeline configuration, [skill:dotnet-csharp-dependency-injection] for service registration, [skill:dotnet-csharp-async-patterns] for async HTTP patterns.

在.NET应用中使用
IHttpClientFactory
消费HTTP API的最佳实践。涵盖命名客户端与类型化客户端、弹性管道集成、用于横切关注点的
DelegatingHandler
链,以及测试策略。
超出范围: DI容器机制与服务生命周期——请参考[skill:dotnet-csharp-dependency-injection]。Async/await模式与取消令牌传播——请参考[skill:dotnet-csharp-async-patterns]。弹性管道配置(Polly v8、重试、断路器、超时策略)由[skill:dotnet-resilience]负责。集成测试框架——请参考[skill:dotnet-integration-testing]中的WebApplicationFactory与HTTP客户端测试模式。
交叉引用:弹性管道配置请参考[skill:dotnet-resilience],服务注册请参考[skill:dotnet-csharp-dependency-injection],异步HTTP模式请参考[skill:dotnet-csharp-async-patterns]。

Why IHttpClientFactory

为什么使用IHttpClientFactory

Creating
HttpClient
instances directly causes two problems:
  1. Socket exhaustion -- each
    HttpClient
    instance holds its own connection pool. Creating and disposing many instances exhausts available sockets (
    SocketException: Address already in use
    ).
  2. DNS staleness -- a long-lived singleton
    HttpClient
    caches DNS lookups indefinitely, missing DNS changes during blue-green deployments or failovers.
IHttpClientFactory
solves both by managing
HttpMessageHandler
lifetimes with automatic pooling and rotation (default: 2-minute handler lifetime).
csharp
// Do not do this
var client = new HttpClient(); // Socket exhaustion risk

// Do not do this either
static readonly HttpClient _client = new(); // DNS staleness risk

// Do this -- use IHttpClientFactory
builder.Services.AddHttpClient();

直接创建
HttpClient
实例会导致两个问题:
  1. 套接字耗尽 -- 每个
    HttpClient
    实例都持有自己的连接池。频繁创建和销毁实例会耗尽可用套接字(
    SocketException: Address already in use
    )。
  2. DNS缓存过时 -- 长期存活的单例
    HttpClient
    会无限期缓存DNS查询结果,在蓝绿部署或故障转移期间无法获取DNS变更。
IHttpClientFactory
通过自动池化和轮换
HttpMessageHandler
的生命周期(默认:2分钟处理程序生命周期)解决了这两个问题。
csharp
// Do not do this
var client = new HttpClient(); // Socket exhaustion risk

// Do not do this either
static readonly HttpClient _client = new(); // DNS staleness risk

// Do this -- use IHttpClientFactory
builder.Services.AddHttpClient();

Named Clients

命名客户端

Register clients by name for scenarios where you consume multiple APIs with different configurations:
csharp
// Registration
builder.Services.AddHttpClient("catalog-api", client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
    client.Timeout = TimeSpan.FromSeconds(30);
});

builder.Services.AddHttpClient("payment-api", client =>
{
    client.BaseAddress = new Uri("https://payments.internal");
    client.DefaultRequestHeaders.Add("X-Api-Version", "2");
});

// Usage
public sealed class OrderService(IHttpClientFactory clientFactory)
{
    public async Task<Product?> GetProductAsync(
        string productId, CancellationToken ct)
    {
        var client = clientFactory.CreateClient("catalog-api");
        var response = await client.GetAsync($"/products/{productId}", ct);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content
            .ReadFromJsonAsync<Product>(ct);
    }
}

按名称注册客户端,适用于使用多个不同配置API的场景:
csharp
// 注册
builder.Services.AddHttpClient("catalog-api", client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
    client.Timeout = TimeSpan.FromSeconds(30);
});

builder.Services.AddHttpClient("payment-api", client =>
{
    client.BaseAddress = new Uri("https://payments.internal");
    client.DefaultRequestHeaders.Add("X-Api-Version", "2");
});

// 使用
public sealed class OrderService(IHttpClientFactory clientFactory)
{
    public async Task<Product?> GetProductAsync(
        string productId, CancellationToken ct)
    {
        var client = clientFactory.CreateClient("catalog-api");
        var response = await client.GetAsync($"/products/{productId}", ct);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content
            .ReadFromJsonAsync<Product>(ct);
    }
}

Typed Clients

类型化客户端

Typed clients encapsulate HTTP logic behind a strongly-typed interface. Prefer typed clients when a service consumes a single API with multiple operations:
csharp
// Typed client class
public sealed class CatalogApiClient(HttpClient httpClient)
{
    public async Task<Product?> GetProductAsync(
        string productId, CancellationToken ct = default)
    {
        var response = await httpClient.GetAsync(
            $"/products/{productId}", ct);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content
            .ReadFromJsonAsync<Product>(ct);
    }

    public async Task<PagedResult<Product>> ListProductsAsync(
        int page = 1,
        int pageSize = 20,
        CancellationToken ct = default)
    {
        var response = await httpClient.GetAsync(
            $"/products?page={page}&pageSize={pageSize}", ct);

        response.EnsureSuccessStatusCode();
        return (await response.Content
            .ReadFromJsonAsync<PagedResult<Product>>(ct))!;
    }

    public async Task<Product> CreateProductAsync(
        CreateProductRequest request,
        CancellationToken ct = default)
    {
        var response = await httpClient.PostAsJsonAsync(
            "/products", request, ct);

        response.EnsureSuccessStatusCode();
        return (await response.Content
            .ReadFromJsonAsync<Product>(ct))!;
    }
}

// Registration
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});
类型化客户端将HTTP逻辑封装在强类型接口之后。当服务使用单个包含多个操作的API时,优先使用类型化客户端:
csharp
// 类型化客户端类
public sealed class CatalogApiClient(HttpClient httpClient)
{
    public async Task<Product?> GetProductAsync(
        string productId, CancellationToken ct = default)
    {
        var response = await httpClient.GetAsync(
            $"/products/{productId}", ct);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content
            .ReadFromJsonAsync<Product>(ct);
    }

    public async Task<PagedResult<Product>> ListProductsAsync(
        int page = 1,
        int pageSize = 20,
        CancellationToken ct = default)
    {
        var response = await httpClient.GetAsync(
            $"/products?page={page}&pageSize={pageSize}", ct);

        response.EnsureSuccessStatusCode();
        return (await response.Content
            .ReadFromJsonAsync<PagedResult<Product>>(ct))!;
    }

    public async Task<Product> CreateProductAsync(
        CreateProductRequest request,
        CancellationToken ct = default)
    {
        var response = await httpClient.PostAsJsonAsync(
            "/products", request, ct);

        response.EnsureSuccessStatusCode();
        return (await response.Content
            .ReadFromJsonAsync<Product>(ct))!;
    }
}

// 注册
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

Typed Client with Interface

带接口的类型化客户端

For testability, define an interface:
csharp
public interface ICatalogApiClient
{
    Task<Product?> GetProductAsync(string productId, CancellationToken ct = default);
    Task<PagedResult<Product>> ListProductsAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
}

public sealed class CatalogApiClient(HttpClient httpClient) : ICatalogApiClient
{
    // Implementation as above
}

// Registration with interface
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
});

为了可测试性,定义一个接口:
csharp
public interface ICatalogApiClient
{
    Task<Product?> GetProductAsync(string productId, CancellationToken ct = default);
    Task<PagedResult<Product>> ListProductsAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
}

public sealed class CatalogApiClient(HttpClient httpClient) : ICatalogApiClient
{
    // 实现如上
}

// 带接口的注册
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
});

Resilience Pipelines

弹性管道

Apply resilience to HTTP clients using
Microsoft.Extensions.Http.Resilience
. See [skill:dotnet-resilience] for detailed pipeline configuration, strategy options, and migration guidance.
使用
Microsoft.Extensions.Http.Resilience
为HTTP客户端应用弹性机制。请参考[skill:dotnet-resilience]获取详细的管道配置、策略选项和迁移指南。

Standard Resilience Handler (Recommended)

标准弹性处理程序(推荐)

The standard handler applies the full pipeline (rate limiter, total timeout, retry, circuit breaker, attempt timeout) with sensible defaults:
csharp
builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddStandardResilienceHandler();
标准处理程序应用完整的管道(速率限制器、总超时、重试、断路器、尝试超时),并带有合理的默认值:
csharp
builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddStandardResilienceHandler();

Standard Handler with Custom Options

带自定义选项的标准处理程序

csharp
builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 5;
        options.Retry.Delay = TimeSpan.FromSeconds(1);
        options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60);
    });
csharp
builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 5;
        options.Retry.Delay = TimeSpan.FromSeconds(1);
        options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60);
    });

Hedging Handler (for Read-Only APIs)

对冲处理程序(适用于只读API)

For idempotent read operations where tail latency matters:
csharp
builder.Services
    .AddHttpClient("search-api")
    .AddStandardHedgingHandler(options =>
    {
        options.Hedging.MaxHedgedAttempts = 2;
        options.Hedging.Delay = TimeSpan.FromMilliseconds(500);
    });
See [skill:dotnet-resilience] for when to use hedging vs standard retry.

对于幂等的读取操作,当尾部延迟很重要时使用:
csharp
builder.Services
    .AddHttpClient("search-api")
    .AddStandardHedgingHandler(options =>
    {
        options.Hedging.MaxHedgedAttempts = 2;
        options.Hedging.Delay = TimeSpan.FromMilliseconds(500);
    });
请参考[skill:dotnet-resilience]了解何时使用对冲策略与标准重试策略。

DelegatingHandlers

DelegatingHandler

DelegatingHandler
provides a pipeline of message handlers that process outgoing requests and incoming responses. Use them for cross-cutting concerns that apply to HTTP traffic.
DelegatingHandler
提供了一个消息处理程序管道,用于处理传出请求和传入响应。使用它们处理适用于HTTP流量的横切关注点。

Handler Pipeline Order

处理程序管道顺序

Handlers execute in registration order for requests (outermost to innermost) and reverse order for responses:
Request  --> Handler A --> Handler B --> Handler C --> HttpClientHandler --> Server
Response <-- Handler A <-- Handler B <-- Handler C <-- HttpClientHandler <-- Server
处理程序按注册顺序执行请求(从最外层到最内层),并按相反顺序执行响应:
Request  --> Handler A --> Handler B --> Handler C --> HttpClientHandler --> Server
Response <-- Handler A <-- Handler B <-- Handler C <-- HttpClientHandler <-- Server

Common Handlers

常见处理程序

Request Logging

请求日志

csharp
public sealed class RequestLoggingHandler(
    ILogger<RequestLoggingHandler> logger) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var stopwatch = Stopwatch.StartNew();

        logger.LogInformation(
            "HTTP {Method} {Uri}",
            request.Method,
            request.RequestUri);

        var response = await base.SendAsync(request, cancellationToken);

        stopwatch.Stop();
        logger.LogInformation(
            "HTTP {Method} {Uri} responded {StatusCode} in {ElapsedMs}ms",
            request.Method,
            request.RequestUri,
            (int)response.StatusCode,
            stopwatch.ElapsedMilliseconds);

        return response;
    }
}

// Registration
builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .AddHttpMessageHandler<RequestLoggingHandler>();
csharp
public sealed class RequestLoggingHandler(
    ILogger<RequestLoggingHandler> logger) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var stopwatch = Stopwatch.StartNew();

        logger.LogInformation(
            "HTTP {Method} {Uri}",
            request.Method,
            request.RequestUri);

        var response = await base.SendAsync(request, cancellationToken);

        stopwatch.Stop();
        logger.LogInformation(
            "HTTP {Method} {Uri} responded {StatusCode} in {ElapsedMs}ms",
            request.Method,
            request.RequestUri,
            (int)response.StatusCode,
            stopwatch.ElapsedMilliseconds);

        return response;
    }
}

// 注册
builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .AddHttpMessageHandler<RequestLoggingHandler>();

API Key Authentication

API密钥认证

csharp
public sealed class ApiKeyHandler(IConfiguration config) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var apiKey = config["ExternalApi:ApiKey"]
            ?? throw new InvalidOperationException("API key not configured");

        request.Headers.Add("X-Api-Key", apiKey);

        return base.SendAsync(request, cancellationToken);
    }
}

// Registration
builder.Services.AddTransient<ApiKeyHandler>();
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .AddHttpMessageHandler<ApiKeyHandler>();
csharp
public sealed class ApiKeyHandler(IConfiguration config) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var apiKey = config["ExternalApi:ApiKey"]
            ?? throw new InvalidOperationException("API key not configured");

        request.Headers.Add("X-Api-Key", apiKey);

        return base.SendAsync(request, cancellationToken);
    }
}

// 注册
builder.Services.AddTransient<ApiKeyHandler>();
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .AddHttpMessageHandler<ApiKeyHandler>();

Bearer Token (from Downstream Auth)

Bearer令牌(来自下游认证)

csharp
public sealed class BearerTokenHandler(
    IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var token = httpContextAccessor.HttpContext?
            .Request.Headers.Authorization
            .ToString()
            .Replace("Bearer ", "");

        if (!string.IsNullOrEmpty(token))
        {
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", token);
        }

        return base.SendAsync(request, cancellationToken);
    }
}
csharp
public sealed class BearerTokenHandler(
    IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var token = httpContextAccessor.HttpContext?
            .Request.Headers.Authorization
            .ToString()
            .Replace("Bearer ", "");

        if (!string.IsNullOrEmpty(token))
        {
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", token);
        }

        return base.SendAsync(request, cancellationToken);
    }
}

Correlation ID Propagation

关联ID传播

csharp
public sealed class CorrelationIdHandler : DelegatingHandler
{
    private const string HeaderName = "X-Correlation-Id";

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        if (!request.Headers.Contains(HeaderName))
        {
            var correlationId = Activity.Current?.Id
                ?? Guid.NewGuid().ToString();
            request.Headers.Add(HeaderName, correlationId);
        }

        return base.SendAsync(request, cancellationToken);
    }
}
csharp
public sealed class CorrelationIdHandler : DelegatingHandler
{
    private const string HeaderName = "X-Correlation-Id";

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        if (!request.Headers.Contains(HeaderName))
        {
            var correlationId = Activity.Current?.Id
                ?? Guid.NewGuid().ToString();
            request.Headers.Add(HeaderName, correlationId);
        }

        return base.SendAsync(request, cancellationToken);
    }
}

Chaining Multiple Handlers

链式多个处理程序

Handlers are added in execution order:
csharp
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<RequestLoggingHandler>();

builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddHttpMessageHandler<CorrelationIdHandler>()   // 1st (outermost): add correlation ID
    .AddHttpMessageHandler<BearerTokenHandler>()     // 2nd: add auth token
    .AddHttpMessageHandler<RequestLoggingHandler>()  // 3rd: log request/response
    .AddStandardResilienceHandler();                 // 4th (innermost): resilience pipeline
Note: In
IHttpClientFactory
, handlers registered first are outermost.
.AddStandardResilienceHandler()
added last is innermost -- it wraps the actual HTTP call directly. This means retries happen inside the resilience handler without re-executing the outer DelegatingHandlers. This is typically correct: correlation IDs and auth tokens are set once by the outer handlers, and the resilience layer retries the raw HTTP call. If you need per-retry token refresh (e.g., expired bearer tokens), move the token handler inside the resilience boundary or use a custom
ResiliencePipelineBuilder
callback.

处理程序按执行顺序添加:
csharp
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<RequestLoggingHandler>();

builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddHttpMessageHandler<CorrelationIdHandler>()   // 第一个(最外层):添加关联ID
    .AddHttpMessageHandler<BearerTokenHandler>()     // 第二个:添加认证令牌
    .AddHttpMessageHandler<RequestLoggingHandler>()  // 第三个:记录请求/响应
    .AddStandardResilienceHandler();                 // 第四个(最内层):弹性管道
注意:
IHttpClientFactory
中,先注册的处理程序是最外层。最后添加的
.AddStandardResilienceHandler()
是最内层——它直接包装实际的HTTP调用。这意味着重试发生在弹性处理程序内部,不会重新执行外层的DelegatingHandler。这通常是正确的:关联ID和认证令牌由外层处理程序设置一次,弹性层重试原始HTTP调用。如果需要每次重试刷新令牌(例如过期的Bearer令牌),请将令牌处理程序移到弹性边界内部,或使用自定义
ResiliencePipelineBuilder
回调。

Configuration Patterns

配置模式

Base Address from Configuration

从配置中获取基础地址

csharp
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
    var baseUrl = builder.Configuration["Services:CatalogApi:BaseUrl"]
        ?? throw new InvalidOperationException(
            "CatalogApi base URL not configured");
    client.BaseAddress = new Uri(baseUrl);
});
json
{
  "Services": {
    "CatalogApi": {
      "BaseUrl": "https://catalog.internal"
    }
  }
}
csharp
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
    var baseUrl = builder.Configuration["Services:CatalogApi:BaseUrl"]
        ?? throw new InvalidOperationException(
            "CatalogApi基础URL未配置");
    client.BaseAddress = new Uri(baseUrl);
});
json
{
  "Services": {
    "CatalogApi": {
      "BaseUrl": "https://catalog.internal"
    }
  }
}

Handler Lifetime

处理程序生命周期

The default handler lifetime is 2 minutes. Adjust for services with different DNS characteristics:
csharp
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));
Shorter lifetime (1 min): for services behind load balancers with frequent DNS changes. Longer lifetime (5-10 min): for stable internal services where connection reuse improves performance.

默认处理程序生命周期为2分钟。根据服务的DNS特性调整:
csharp
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));
更短的生命周期(1分钟):适用于负载均衡器后DNS频繁变更的服务。 更长的生命周期(5-10分钟):适用于稳定的内部服务,连接复用可提升性能。

Testing HTTP Clients

测试HTTP客户端

Unit Testing with MockHttpMessageHandler

使用MockHttpMessageHandler进行单元测试

Test typed clients by providing a mock handler that returns controlled responses:
csharp
public sealed class CatalogApiClientTests
{
    [Fact]
    public async Task GetProductAsync_ReturnsProduct_WhenFound()
    {
        // Arrange
        var expectedProduct = new Product { Id = "p1", Name = "Widget" };
        var handler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = JsonContent.Create(expectedProduct)
            });

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        var client = new CatalogApiClient(httpClient);

        // Act
        var result = await client.GetProductAsync("p1");

        // Assert
        Assert.NotNull(result);
        Assert.Equal("Widget", result.Name);
    }

    [Fact]
    public async Task GetProductAsync_ReturnsNull_WhenNotFound()
    {
        var handler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.NotFound));

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        var client = new CatalogApiClient(httpClient);

        var result = await client.GetProductAsync("missing");

        Assert.Null(result);
    }
}

// Reusable mock handler
public sealed class MockHttpMessageHandler(
    HttpResponseMessage response) : HttpMessageHandler
{
    private HttpRequestMessage? _lastRequest;

    public HttpRequestMessage? LastRequest => _lastRequest;

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        _lastRequest = request;
        return Task.FromResult(response);
    }
}
通过提供返回可控响应的模拟处理程序来测试类型化客户端:
csharp
public sealed class CatalogApiClientTests
{
    [Fact]
    public async Task GetProductAsync_ReturnsProduct_WhenFound()
    {
        // 准备
        var expectedProduct = new Product { Id = "p1", Name = "Widget" };
        var handler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = JsonContent.Create(expectedProduct)
            });

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        var client = new CatalogApiClient(httpClient);

        // 执行
        var result = await client.GetProductAsync("p1");

        // 断言
        Assert.NotNull(result);
        Assert.Equal("Widget", result.Name);
    }

    [Fact]
    public async Task GetProductAsync_ReturnsNull_WhenNotFound()
    {
        var handler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.NotFound));

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        var client = new CatalogApiClient(httpClient);

        var result = await client.GetProductAsync("missing");

        Assert.Null(result);
    }
}

// 可复用的模拟处理程序
public sealed class MockHttpMessageHandler(
    HttpResponseMessage response) : HttpMessageHandler
{
    private HttpRequestMessage? _lastRequest;

    public HttpRequestMessage? LastRequest => _lastRequest;

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        _lastRequest = request;
        return Task.FromResult(response);
    }
}

Testing DelegatingHandlers

测试DelegatingHandler

Test handlers in isolation by providing an inner handler:
csharp
public sealed class ApiKeyHandlerTests
{
    [Fact]
    public async Task AddsApiKeyHeader()
    {
        // Arrange
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ExternalApi:ApiKey"] = "test-key-123"
            })
            .Build();

        var innerHandler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.OK));

        var handler = new ApiKeyHandler(config)
        {
            InnerHandler = innerHandler
        };

        var client = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        // Act
        await client.GetAsync("/test");

        // Assert
        Assert.NotNull(innerHandler.LastRequest);
        Assert.Equal(
            "test-key-123",
            innerHandler.LastRequest.Headers
                .GetValues("X-Api-Key").Single());
    }
}
通过提供内部处理程序单独测试处理程序:
csharp
public sealed class ApiKeyHandlerTests
{
    [Fact]
    public async Task AddsApiKeyHeader()
    {
        // 准备
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ExternalApi:ApiKey"] = "test-key-123"
            })
            .Build();

        var innerHandler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.OK));

        var handler = new ApiKeyHandler(config)
        {
            InnerHandler = innerHandler
        };

        var client = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        // 执行
        await client.GetAsync("/test");

        // 断言
        Assert.NotNull(innerHandler.LastRequest);
        Assert.Equal(
            "test-key-123",
            innerHandler.LastRequest.Headers
                .GetValues("X-Api-Key").Single());
    }
}

Integration Testing with WebApplicationFactory

使用WebApplicationFactory进行集成测试

Test the full HTTP client pipeline including DI registration:
csharp
// See [skill:dotnet-integration-testing] for WebApplicationFactory patterns

测试完整的HTTP客户端管道,包括DI注册:
csharp
// 请参考[skill:dotnet-integration-testing]了解WebApplicationFactory模式

Named vs Typed Clients -- Decision Guide

命名客户端 vs 类型化客户端——决策指南

FactorNamed ClientTyped Client
API surfaceSimple (1-2 calls)Rich (multiple operations)
Type safetyRequires string nameStrongly typed
EncapsulationHTTP logic in consuming classHTTP logic in client class
TestabilityMock
IHttpClientFactory
Mock the client interface
Multiple APIsOne name per APIOne class per API
RecommendationAd-hoc or simple callsPrimary pattern for API consumption
Default to typed clients. Use named clients only for simple, one-off HTTP calls where a full typed client class adds unnecessary ceremony.

因素命名客户端类型化客户端
API表面简单(1-2个调用)丰富(多个操作)
类型安全需要字符串名称强类型
封装性HTTP逻辑在消费类中HTTP逻辑在客户端类中
可测试性模拟
IHttpClientFactory
模拟客户端接口
多API场景每个API对应一个名称每个API对应一个类
推荐场景临时或简单调用API消费的主要模式
默认使用类型化客户端。仅在简单的一次性HTTP调用中使用命名客户端,此时完整的类型化客户端类会增加不必要的复杂度。

Key Principles

核心原则

  • Always use IHttpClientFactory -- never
    new HttpClient()
    in application code
  • Prefer typed clients -- encapsulate HTTP logic behind a strongly-typed interface
  • Apply resilience via pipeline -- use
    AddStandardResilienceHandler()
    (see [skill:dotnet-resilience]) rather than manual retry loops
  • Keep handlers focused -- each
    DelegatingHandler
    should do one thing (auth, logging, correlation)
  • Register handlers as Transient -- DelegatingHandlers are created per-client-instance and should not hold state across requests
  • Pass CancellationToken everywhere -- from endpoint to typed client to HTTP call
  • Use ReadFromJsonAsync / PostAsJsonAsync -- avoid manual serialization with
    StringContent

  • 始终使用IHttpClientFactory——在应用代码中永远不要使用
    new HttpClient()
  • 优先使用类型化客户端——将HTTP逻辑封装在强类型接口之后
  • 通过管道应用弹性机制——使用
    AddStandardResilienceHandler()
    (请参考[skill:dotnet-resilience])而非手动重试循环
  • 保持处理程序聚焦——每个
    DelegatingHandler
    应只负责一件事(认证、日志、关联)
  • 将处理程序注册为Transient——DelegatingHandler按客户端实例创建,不应跨请求保存状态
  • 始终传递CancellationToken——从端点到类型化客户端再到HTTP调用
  • 使用ReadFromJsonAsync / PostAsJsonAsync——避免使用
    StringContent
    手动序列化

Agent Gotchas

注意事项

  1. Do not create HttpClient with
    new
    -- always inject
    IHttpClientFactory
    or a typed client. Direct instantiation causes socket exhaustion.
  2. Do not dispose typed clients -- the factory manages handler lifetimes. Disposing the
    HttpClient
    instance is harmless (it does not close pooled connections), but wrapping it in
    using
    is misleading.
  3. Do not set
    BaseAddress
    with a trailing path
    --
    new Uri("https://api.example.com/v2")
    will drop
    /v2
    when combining with relative URIs. Use
    new Uri("https://api.example.com/v2/")
    (trailing slash) or use absolute URIs in calls.
  4. Understand that resilience added last is innermost --
    AddStandardResilienceHandler()
    registered after
    AddHttpMessageHandler
    calls wraps the HTTP call directly. Retries do not re-execute outer DelegatingHandlers. This is correct for most cases (tokens/correlation IDs set once). If you need per-retry token refresh, place the token handler after the resilience handler or use a custom pipeline callback.
  5. Do not register DelegatingHandlers as Singleton -- they are pooled with the
    HttpMessageHandler
    pipeline and must be Transient.

  1. 不要使用
    new
    创建HttpClient
    ——始终注入
    IHttpClientFactory
    或类型化客户端。直接实例化会导致套接字耗尽。
  2. 不要释放类型化客户端——工厂管理处理程序的生命周期。释放
    HttpClient
    实例是无害的(它不会关闭池化连接),但用
    using
    包装会产生误导。
  3. 不要设置带有尾随路径的
    BaseAddress
    ——
    new Uri("https://api.example.com/v2")
    在与相对URI组合时会丢失
    /v2
    。使用
    new Uri("https://api.example.com/v2/")
    (带尾随斜杠)或在调用中使用绝对URI。
  4. 了解最后添加的弹性机制是最内层——在
    AddHttpMessageHandler
    调用之后注册的
    AddStandardResilienceHandler()
    直接包装HTTP调用。重试不会重新执行外层的DelegatingHandler。这在大多数情况下是正确的(令牌/关联ID只设置一次)。如果需要每次重试刷新令牌,请将令牌处理程序放在弹性处理程序之后,或使用自定义管道回调。
  5. 不要将DelegatingHandler注册为Singleton——它们与
    HttpMessageHandler
    管道一起池化,必须注册为Transient。

References

参考资料