API/APIRatelimitController.cs

API rate limit controller for LichessNET. It defines a token-bucket Bucket type, tracks per-endpoint buckets plus a default bucket, queues piped requests count, and blocks/awaits when global rate-limited or when buckets have no tokens.

NetworkingFile Access
#nullable enable annotations

using LichessNET.Internal;

namespace LichessNET.API;

/// <summary>
/// The APIRatelimitController class manages and enforces rate limiting
/// for API requests to prevent abuse and ensure fair usage of system resources.
/// </summary>
public class ApiRatelimitController
{
    private sealed class Bucket
    {
        private readonly int _capacity;
        private readonly int _refillAmount;
        private readonly TimeSpan _interval;
        private int _tokens;
        private DateTime _lastRefill;

        public Bucket(int capacity, int refillAmount, TimeSpan interval)
        {
            _capacity = Math.Max(1, capacity);
            _refillAmount = Math.Max(1, refillAmount);
            _interval = interval;
            _tokens = _capacity;
            _lastRefill = DateTime.UtcNow;
        }

        public async Task Consume()
        {
            while (true)
            {
                Refill();
                if (_tokens > 0)
                {
                    _tokens--;
                    return;
                }

                var wait = _interval - (DateTime.UtcNow - _lastRefill);
                await Task.Delay(wait > TimeSpan.Zero ? wait : TimeSpan.FromMilliseconds(100));
            }
        }

        private void Refill()
        {
            var now = DateTime.UtcNow;
            if (now - _lastRefill < _interval)
                return;

            var intervals = Math.Max(1, (int)((now - _lastRefill).Ticks / _interval.Ticks));
            _tokens = Math.Min(_capacity, _tokens + intervals * _refillAmount);
            _lastRefill = now;
        }
    }

    private static readonly LichessLog Logger = new("APIRateLimitController");
    private readonly Dictionary<string, Bucket> _buckets = new();
    private readonly Bucket _defaultBucket = new(5, 5, TimeSpan.FromSeconds(15));
    private int _pipedRequests;
    private DateTime _rateLimitedUntil = DateTime.MinValue;

    public int PipedRequests
    {
        get => _pipedRequests;
        internal set
        {
            _pipedRequests = value;
            if (PipedRequests > 5)
                Logger.Warning($"Currently there are {PipedRequests} requests in queue. Either the API is blocking requests, or the client is sending too many requests.");
        }
    }

    public void ReportBlock(int seconds = 60)
    {
        Logger.Warning("API call reported ratelimit block for " + seconds + " seconds");
        _rateLimitedUntil = DateTime.UtcNow.AddSeconds(seconds);
    }

    public void RegisterBucket(string endpointUrl, int capacity, int refillAmount, TimeSpan interval)
    {
        _buckets[endpointUrl] = new Bucket(capacity, refillAmount, interval);
    }

    public Task Consume()
    {
        return Consume(string.Empty, true);
    }

    public async Task Consume(string endpointUrl, bool consumeDefaultBucket)
    {
        PipedRequests++;
        try
        {
            if (_rateLimitedUntil > DateTime.UtcNow)
            {
                var wait = _rateLimitedUntil - DateTime.UtcNow;
                Logger.Warning($"Endpoint call to {endpointUrl} blocked due to ratelimit. Waiting for {wait.TotalMilliseconds:0} ms.");
                await Task.Delay(wait);
            }

            if (consumeDefaultBucket)
                await _defaultBucket.Consume();

            if (!string.IsNullOrWhiteSpace(endpointUrl) && _buckets.TryGetValue(endpointUrl.TrimStart('/'), out var bucket))
                await bucket.Consume();
        }
        finally
        {
            PipedRequests--;
        }
    }
}