using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace Jellyfin.Plugin.RedditComments.Services; /// /// Sliding-window rate limiter. Guarantees no more than N requests per rolling 60 seconds, /// no matter how many callers are waiting. /// public class RateLimiter { private readonly object _lock = new object(); private readonly Queue _requestTimestamps = new Queue(); private readonly int? _maxPerMinuteOverride; /// /// Initializes a new instance of the class. /// /// Optional fixed limit (used by tests); otherwise the plugin configuration is used. public RateLimiter(int? maxPerMinuteOverride = null) { _maxPerMinuteOverride = maxPerMinuteOverride; } private int MaxPerMinute { get { if (_maxPerMinuteOverride.HasValue) { return _maxPerMinuteOverride.Value; } return ReadConfiguredLimit(); } } private static int ReadConfiguredLimit() { var configured = Plugin.Instance?.Configuration.MaxRequestsPerMinute ?? 60; // Reddit's own hard limit for OAuth clients is 100 req/min; never exceed it. return Math.Clamp(configured, 1, 100); } /// /// Waits until a request slot is available, then reserves it. /// /// Cancellation token. /// A task that completes when the caller may perform one request. public async Task WaitAsync(CancellationToken cancellationToken = default) { while (true) { TimeSpan delay; lock (_lock) { var now = DateTime.UtcNow; while (_requestTimestamps.Count > 0 && now - _requestTimestamps.Peek() >= TimeSpan.FromMinutes(1)) { _requestTimestamps.Dequeue(); } if (_requestTimestamps.Count < MaxPerMinute) { _requestTimestamps.Enqueue(now); return; } // Window is full: wait until the oldest request falls out of the window. delay = TimeSpan.FromMinutes(1) - (now - _requestTimestamps.Peek()) + TimeSpan.FromMilliseconds(100); } await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } }