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

本文介绍如何创建客户端 HTTP 处理程序,以限制发送的请求数。 你将看到访问 HttpClient 资源的 "www.example.com"。 资源由依赖它们的应用使用,当应用对单个资源发出过多请求时,可能会导致 资源争用。 资源争用发生在资源被过多应用程序消耗时,导致资源无法服务于所有请求它的应用。 这可能会导致用户体验不佳,在某些情况下,它甚至可能导致拒绝服务(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实例从RateLimitLease获取AcquireAsync
  • 当属性为lease.IsAcquiredtrue时,请求将发送到服务器。
  • 否则,将返回一个带有HttpResponseMessage状态代码的429,如果lease包含RetryAfter值,则Retry-After标头设置为该值。

模拟多个并发请求

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

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
  • 使用 HttpClient(配置有 ClientSideRateLimitedHandler)创建了一个 TokenBucketRateLimiter
  • 若要模拟 100 个请求, Enumerable.Range 请创建 100 个 URL,每个 URL 都具有唯一的查询字符串参数。
  • Task方法中分配两个Parallel.ForEachAsync对象,用来将 URL 拆分为两个组。
  • HttpClient 用于向每个 URL 发送 GET 请求,并将响应写入控制台。
  • Task.WhenAll 等待两个任务完成。

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

请考虑以下示例输出:

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 参数,可以看到该参数在每次请求时增加一个。 此参数有助于说明 429 个响应不是来自第一个请求,而是来自达到速率限制后发出的请求。 200 个响应稍后会到达,但这些请求是在达到限制之前发出的。

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

概要

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

另请参阅