在 .NET 中限制 HTTP 处理程序的速率

本文介绍如何创建一个客户端 HTTP 处理程序,该处理程序对发送的请求数进行速率限制。 你将看到访问 "www.example.com" 资源的 HttpClient。 资源由依赖它们的应用使用,当应用对单个资源发出过多请求时,它可能会导致资源争用。 资源争用在过多应用使用资源时发生,并且该资源无法为请求它的所有应用提供服务。 这可能导致用户体验不佳,在某些情况下,甚至可能导致拒绝服务 (DoS) 攻击。 有关 DoS 的详细信息,请参阅 OWASP:拒绝服务

什么是速率限制?

速率限制是限制可以访问的资源量的概念。 例如,你可能知道应用访问的数据库每分钟可以安全地处理 1,000 个请求,但它可能处理不了更多。 可以在应用中放置一个速率限制器,每分钟只允许 1,000 个请求,并在它们可以访问数据库之前拒绝更多请求。 因此,限制数据库速率并允许应用处理安全数量的请求。 这是分布式系统中的一种常见模式,其中你可能有多个应用实例正在运行,并且你希望确保它们不会同时尝试访问数据库。 有多个不同的速率限制算法来控制请求流。

若要在 .NET 中使用速率限制,请引用 System.Threading.RateLimiting NuGet 包。

实现 DelegatingHandler 子类

若要控制请求流,请实现自定义 DelegatingHandler 子类。 这是一种 HttpMessageHandler,允许在请求发送到服务器之前截获和处理请求。 还可以在响应返回到调用方之前截获和处理响应。 在此示例中,你将实现一个自定义 DelegatingHandler 子类,该子类限制可以发送到单个资源的请求数。 请考虑以下自定义 ClientSideRateLimitedHandler 类:

internal sealed class ClientSideRateLimitedHandler(
    RateLimiter limiter)
    : DelegatingHandler(new HttpClientHandler()), IAsyncDisposable
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        using RateLimitLease lease = await limiter.AcquireAsync(
            permitCount: 1, cancellationToken);

        if (lease.IsAcquired)
        {
            return await base.SendAsync(request, cancellationToken);
        }

        var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
        if (lease.TryGetMetadata(
                MetadataName.RetryAfter, out TimeSpan retryAfter))
        {
            response.Headers.Add(
                "Retry-After",
                ((int)retryAfter.TotalSeconds).ToString(
                    NumberFormatInfo.InvariantInfo));
        }

        return response;
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    { 
        await limiter.DisposeAsync().ConfigureAwait(false);

        Dispose(disposing: false);
        GC.SuppressFinalize(this);
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);

        if (disposing)
        {
            limiter.Dispose();
        }
    }
}

上述 C# 代码:

  • 继承 DelegatingHandler 类型。
  • 实现 IAsyncDisposable 接口。
  • 定义从构造函数分配的 RateLimiter 字段。
  • 替代 SendAsync 方法,以便在请求发送到服务器之前截获和处理请求。
  • 重写 DisposeAsync() 方法以释放 RateLimiter 实例。

仔细观察 SendAsync 方法,你会发现它:

  • 依靠 RateLimiter 实例从 AcquireAsync 获取 RateLimitLease
  • lease.IsAcquired 属性为 true 时,请求会发送到服务器。
  • 否则,将返回带有 429 状态代码的 HttpResponseMessage,如果 lease 包含 RetryAfter 值,则 Retry-After 标头将设置为该值。

模拟多个并发请求

要将此自定义 DelegatingHandler 子类投入测试,你将创建一个模拟许多并发请求的控制台应用。 此 Program 类使用自定义 ClientSideRateLimitedHandler 创建一个 HttpClient

var options = new TokenBucketRateLimiterOptions
{ 
    TokenLimit = 8, 
    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
    QueueLimit = 3, 
    ReplenishmentPeriod = TimeSpan.FromMilliseconds(1), 
    TokensPerPeriod = 2, 
    AutoReplenishment = true
};

// Create an HTTP client with the client-side rate limited handler.
using HttpClient client = new(
    handler: new ClientSideRateLimitedHandler(
        limiter: new TokenBucketRateLimiter(options)));

// Create 100 urls with a unique query string.
var oneHundredUrls = Enumerable.Range(0, 100).Select(
    i => $"https://example.com?iteration={i:0#}");

// Flood the HTTP client with requests.
var floodOneThroughFortyNineTask = Parallel.ForEachAsync(
    source: oneHundredUrls.Take(0..49), 
    body: (url, cancellationToken) => GetAsync(client, url, cancellationToken));

var floodFiftyThroughOneHundredTask = Parallel.ForEachAsync(
    source: oneHundredUrls.Take(^50..),
    body: (url, cancellationToken) => GetAsync(client, url, cancellationToken));

await Task.WhenAll(
    floodOneThroughFortyNineTask,
    floodFiftyThroughOneHundredTask);

static async ValueTask GetAsync(
    HttpClient client, string url, CancellationToken cancellationToken)
{
    using var response =
        await client.GetAsync(url, cancellationToken);

    Console.WriteLine(
        $"URL: {url}, HTTP status code: {response.StatusCode} ({(int)response.StatusCode})");
}

在前面的控制台应用中:

  • TokenBucketRateLimiterOptions 配置令牌限制为 8,队列处理顺序为 OldestFirst,队列限制为 3,补货周期为 1 毫秒,每个周期的令牌值为 2,自动补充值为 true
  • 使用 ClientSideRateLimitedHandler(配置有 TokenBucketRateLimiter)创建了一个 HttpClient
  • 为了模拟 100 个请求,Enumerable.Range 创建了 100 个 URL,每个 URL 都有一个唯一的查询字符串参数。
  • Parallel.ForEachAsync 方法分配了两个 Task 对象,将 URL 分成两组。
  • HttpClient 用于向每个 URL 发送 GET 请求,并将响应写入控制台。
  • Task.WhenAll 等待两个任务完成。

由于 HttpClient 配置了 ClientSideRateLimitedHandler,因此并非所有请求都会到达服务器资源。 可以通过运行控制台应用来测试此断言。 你将看到只有一小部分请求发送到服务器,其余请求被拒绝并返回 HTTP 状态代码 429。 尝试更改用于创建 TokenBucketRateLimiteroptions 对象,以查看发送到服务器的请求数如何变化。

请参考以下示例输出:

URL: https://example.com?iteration=06, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=60, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=55, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=59, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=57, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=11, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=63, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=13, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=62, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=65, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=64, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=67, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=14, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=68, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=16, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=69, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=70, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=71, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=17, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=18, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=72, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=73, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=74, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=19, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=75, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=76, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=79, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=77, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=21, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=78, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=81, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=22, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=80, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=20, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=82, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=83, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=23, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=84, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=24, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=85, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=86, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=25, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=87, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=26, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=88, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=89, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=27, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=90, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=28, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=91, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=94, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=29, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=93, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=96, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=92, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=95, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=31, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=30, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=97, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=98, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=99, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=32, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=33, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=34, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=35, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=36, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=37, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=38, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=39, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=40, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=41, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=42, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=43, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=44, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=45, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=46, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=47, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=48, HTTP status code: TooManyRequests (429)
URL: https://example.com?iteration=15, HTTP status code: OK (200)
URL: https://example.com?iteration=04, HTTP status code: OK (200)
URL: https://example.com?iteration=54, HTTP status code: OK (200)
URL: https://example.com?iteration=08, HTTP status code: OK (200)
URL: https://example.com?iteration=00, HTTP status code: OK (200)
URL: https://example.com?iteration=51, HTTP status code: OK (200)
URL: https://example.com?iteration=10, HTTP status code: OK (200)
URL: https://example.com?iteration=66, HTTP status code: OK (200)
URL: https://example.com?iteration=56, HTTP status code: OK (200)
URL: https://example.com?iteration=52, HTTP status code: OK (200)
URL: https://example.com?iteration=12, HTTP status code: OK (200)
URL: https://example.com?iteration=53, HTTP status code: OK (200)
URL: https://example.com?iteration=07, HTTP status code: OK (200)
URL: https://example.com?iteration=02, HTTP status code: OK (200)
URL: https://example.com?iteration=01, HTTP status code: OK (200)
URL: https://example.com?iteration=61, HTTP status code: OK (200)
URL: https://example.com?iteration=05, HTTP status code: OK (200)
URL: https://example.com?iteration=09, HTTP status code: OK (200)
URL: https://example.com?iteration=03, HTTP status code: OK (200)
URL: https://example.com?iteration=58, HTTP status code: OK (200)
URL: https://example.com?iteration=50, HTTP status code: OK (200)

你会注意到第一个记录的条目始终是立即返回 429 响应,最后一个条目始终是 200 响应。 这是因为速率限制是在客户端遇到的,并且避免了对服务器的 HTTP 调用。 这是一件好事,因为这意味着服务器不会请求泛滥。 这还意味着所有客户端上都一致地执行速率限制。

另请注意,每个 URL 的查询字符串都是唯一的:请检查 iteration 参数以查看它是否针对每个请求增加 1。 此参数帮助说明 429 响应不是来自第一个请求,而是来自达到速率限制后发出的请求。 200 响应的完成时间较晚,但这些请求在达到限制之前完成了。

若要更好地了解各种速率限制算法,请尝试重写此代码以接受不同的 RateLimiter 实现。 除了 TokenBucketRateLimiter,还可以尝试:

  • ConcurrencyLimiter
  • FixedWindowRateLimiter
  • PartitionedRateLimiter
  • SlidingWindowRateLimiter

总结

本文介绍了如何实现自定义 ClientSideRateLimitedHandler。 此模式可用于为已知具有 API 限制的资源实现速率限制的 HTTP 客户端。 通过这种方式,可以防止客户端应用向服务器发出不必要的请求,并且还可以防止应用被服务器阻止。 此外,通过使用元数据来存储重试计时值,还可以实现自动重试逻辑。

另请参阅