.NET での HTTP ハンドラーのレート制限

この記事では、送信する要求の数をレート制限するクライアント側 HTTP ハンドラーを作成する方法について学習します。 "www.example.com" リソースにアクセスする HttpClient が示されます。 リソースは、それらに依存するアプリによって消費され、アプリで 1 つのリソースに対して行われる要求が多すぎると、"リソースの競合" が発生する可能性があります。 リソースの競合は、多すぎるアプリでリソースが消費され、そのリソースで、それを要求しているすべてのアプリにサービスを提供できない場合に発生します。 これにより、ユーザー エクスペリエンスが低下する可能性があり、場合によってはサービス拒否 (DoS) 攻撃につながる可能性もあります。 DoS の詳細については、OWASP: サービス拒否に関するページを参照してください。

レート制限とは何ですか?

レート制限は、リソースにアクセスできる量を制限する概念です。 たとえば、アプリからアクセスされるデータベースでは 1 分あたり 1,000 件の要求を安全に処理できるものの、それ以上は処理されない場合があることがわかっているとします。 1 分ごとに 1,000 件の要求のみを許可し、それ以上の要求を拒否してデータベースにアクセスできるようにするレート リミッターをアプリに配置できます。 したがって、データベースをレート制限し、アプリで安全な数の要求を処理できるようにします。 これは分散システムの一般的なパターンであり、アプリの複数のインスタンスが実行されていて、それらがすべて同時にデータベースへのアクセスを試行しないようにする必要があります。 要求のフローを制御するための複数の異なるレート制限アルゴリズムがあります。

.NET でレート制限を使用する場合は、System.Threading.RateLimiting NuGet パッケージを参照します。

DelegatingHandler サブクラスを実装する

要求のフローを制御するには、カスタム DelegatingHandler サブクラスを実装します。 これは、サーバーに送信される前に要求をインターセプトして処理できる HttpMessageHandler の一種です。 また、呼び出し元に返される前に応答をインターセプトして処理することもできます。 この例では、1 つのリソースに送信できる要求の数を制限するカスタム 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 の場合、要求はサーバーに送信されます。
  • それ以外の場合、HttpResponseMessage429 状態コードと共に返され、leaseRetryAfter 値が含まれている場合、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の自動補充値で構成されます。
  • TokenBucketRateLimiter で構成された ClientSideRateLimitedHandler を使用して HttpClient が作成されます。
  • 100 件の要求をエミュレートするために、Enumerable.Range では、それぞれ一意のクエリ文字列パラメーターを持つ 100 個の URL を作成します。
  • Parallel.ForEachAsync メソッドから 2 つの Task オブジェクトが割り当てられ、URL が 2 つのグループに分割されます。
  • HttpClient は各 URL に GET 要求を送信するために使用され、応答はコンソールに書き込まれます。
  • Task.WhenAll では両方のタスクが完了するまで待機します。

HttpClientClientSideRateLimitedHandler で構成されているため、すべての要求がサーバー リソースに対して行われるわけではありません。 このアサーションは、コンソール アプリを実行してテストできます。 サーバーに送信される要求の総数のごく一部のみが表示され、残りは 429 の HTTP 状態コードで拒否されます。 TokenBucketRateLimiter の作成に使用される options オブジェクトを変更し、サーバーに送信される要求の数がどのように変化するかを確認してみてください。

次の出力例を考えてみましょう。

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 クライアントを実装できます。 この方法では、クライアント アプリがサーバーに不要な要求を行うことを防ぎ、サーバーによってアプリがブロックされるのを防ぎます。 さらに、メタデータを使用して再試行のタイミング値を格納する場合は、自動再試行ロジックを実装することもできます。

関連項目