在 .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 時,要求會傳送至伺服器。
  • 否則,會傳回 HttpResponseMessage 和狀態碼 429,而且如果 lease 包含 RetryAfter 值,Retry-After 標頭就會設定為該值。

模擬多個並行要求

若要測試此自訂 DelegatingHandler 子類別,您可以一個主控台應用程式來模擬多個並行要求。 Program 類別會建立具有自訂 ClientSideRateLimitedHandlerHttpClient

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})");
}

在上述主控台應用程式中:

  • 會以 8 的權杖限制、OldestFirst 的佇列處理順序、3 的佇列限制,以及補充時間 1 毫秒、每單位時間權杖數 2 個和自動補充值 true 來設定 TokenBucketRateLimiterOptions
  • HttpClient 是由 TokenBucketRateLimiter 所設定的 ClientSideRateLimitedHandler 所建立的。
  • 若要模擬 100 個要求,Enumerable.Range 會建立 100 個 URL,其中每個 URL 都有獨一無二的查詢字串參數。
  • Parallel.ForEachAsync 方法會指派兩個 Task 物件,將 URL 分割成兩個群組。
  • HttpClient 可用來將 GET 要求傳送至每個 URL,並將回應寫入主控台。
  • 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 參數,可看到每個要求的該參數會遞增一。 此參數可以說明這 429 個回應不是來自最早的要求,而是來自達到速率限制之後所提出的要求。 200 個回應較晚送達,但其對應的要求是在達到速率限制之前提出的。

若要進一步了解各種速率限制演算法,請嘗試重寫此程式碼以接受不同的 RateLimiter 實作。 除了 TokenBucketRateLimiter 之外,您也可以嘗試:

  • ConcurrencyLimiter
  • FixedWindowRateLimiter
  • PartitionedRateLimiter
  • SlidingWindowRateLimiter

摘要

在本文中,您已了解如何實作自訂 ClientSideRateLimitedHandler。 此模式可用來針對已知有 API 限制的資源實作受速率限制的 HTTP 用戶端。 如此一來,您就能避免用戶端應用程式對伺服器提出不必要的要求,並且避免伺服器封鎖您的應用程式。 此外,您也可以利用中繼資料來儲存重試時間值以實作自動重試邏輯。

另請參閱