From b8baaf55b56b8798be475cd8de0d3b6e443f567f Mon Sep 17 00:00:00 2001 From: Jkorf Date: Mon, 21 Sep 2026 13:43:05 +0200 Subject: [PATCH] Fixed issues in DecayWindowTracker calculations --- .../Trackers/DecayWindowTracker.cs | 144 ++++++++++++------ 1 file changed, 94 insertions(+), 50 deletions(-) diff --git a/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs index e512d8ca..938d00b5 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs @@ -18,9 +18,18 @@ namespace CryptoExchange.Net.RateLimiting.Trackers private int _currentWeight = 0; private DateTime _lastDecrease = DateTime.UtcNow; + private double _decayProgress; - public DecayWindowTracker(int limit, TimeSpan period, double decayRate) + public DecayWindowTracker( + int limit, + TimeSpan period, + double decayRate) { + if (period <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(period)); + + if (decayRate <= 0) + throw new ArgumentOutOfRangeException(nameof(decayRate)); Limit = limit; TimePeriod = period; DecreaseRate = decayRate; @@ -31,83 +40,118 @@ namespace CryptoExchange.Net.RateLimiting.Trackers { if (amount == null) { - _lastDecrease = DateTime.UtcNow; - _currentWeight = 0; - } - else - { - _currentWeight = Math.Max(0, _currentWeight - amount.Value); + ResetState(); + return; } + + _currentWeight = Math.Max(0, _currentWeight - amount.Value); + if (_currentWeight == 0) + ResetState(); } /// public TimeSpan GetWaitTime(int weight, double allowedRateRatio) { - // Decrease the counter based on the last update time and decay rate - DecreaseCounter(DateTime.UtcNow); + var now = DateTime.UtcNow; + DecreaseCounter(now); - if ((Current + weight) / (double)Limit > allowedRateRatio) + if ((Current + weight) / (double)Limit <= allowedRateRatio) + return TimeSpan.Zero; + + if (Current == 0) { - // The weight would cause the rate limit to be passed - if (Current == 0) + if (allowedRateRatio < 1) { - if (allowedRateRatio < 1) - { - throw new Exception("Request limit reached max utilization. " + - $"This request can never execute with the current rate limiter configuration. Request weight: {weight}, RateLimit: {Limit}, " + - $"Request ratio: {(Current + weight) / (double)Limit}, AllowedRateRatio: {allowedRateRatio}"); - } - else - { - throw new Exception("Request limit reached without any prior request. " + - $"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}"); - } + throw new Exception( + "Request limit reached max utilization. " + + "This request can never execute with the current rate limiter configuration. " + + $"Request weight: {weight}, RateLimit: {Limit}, " + + $"Request ratio: {(Current + weight) / (double)Limit}, " + + $"AllowedRateRatio: {allowedRateRatio}"); } - // Determine the time to wait before this weight can be applied without going over the rate limit - return DetermineWaitTime(weight, allowedRateRatio); + throw new Exception( + "Request limit reached without any prior request. " + + "This request can never execute with the current rate limiter. " + + $"Request weight: {weight}, RateLimit: {Limit}"); } - // Weight can fit without going over limit - return TimeSpan.Zero; + return DetermineWaitTime( + weight, + allowedRateRatio); } - /// public void ApplyWeight(int weight) { if (_currentWeight == 0) + { _lastDecrease = DateTime.UtcNow; + _decayProgress = 0; + } + _currentWeight += weight; } - /// - /// Decrease the counter based on time passed since last update and the decay rate - /// - /// - protected void DecreaseCounter(DateTime time) + private void DecreaseCounter(DateTime now) { - var dif = (time - _lastDecrease).TotalMilliseconds / TimePeriod.TotalMilliseconds * DecreaseRate; - var decrease = (int)Math.Floor(dif); - if (decrease >= 1) + if (_currentWeight == 0) { - _currentWeight = Math.Max(0, _currentWeight - (int)Math.Floor(dif)); - _lastDecrease = time; + _lastDecrease = now; + _decayProgress = 0; + return; + } + + var elapsed = now - _lastDecrease; + if (elapsed <= TimeSpan.Zero) + return; + + var elapsedDecay = elapsed.Ticks / (double)TimePeriod.Ticks * DecreaseRate; + + var totalDecay = _decayProgress + elapsedDecay; + var completedDecay = (int)Math.Floor(totalDecay); + + _lastDecrease = now; + + if (completedDecay == 0) + { + _decayProgress = totalDecay; + return; + } + + _currentWeight = Math.Max(0, _currentWeight - completedDecay); + if (_currentWeight == 0) + { + // Decay cannot accumulate as credit while the counter is empty. + _decayProgress = 0; + } + else + { + _decayProgress = totalDecay - completedDecay; } } - /// - /// Determine the time to wait before the weight would fit - /// - /// - /// - /// - private TimeSpan DetermineWaitTime(int requestWeight, double allowedRateRatio) + private TimeSpan DetermineWaitTime( + int requestWeight, + double allowedRateRatio) { - var weightToRemove = Math.Max(Current + requestWeight - Limit * allowedRateRatio, 0); - var result = TimeSpan.FromMilliseconds(Math.Ceiling(weightToRemove / DecreaseRate) * TimePeriod.TotalMilliseconds); - if (result < TimeSpan.Zero) - return TimeSpan.Zero; - return result; + var weightToRemove = Current + requestWeight - Limit * allowedRateRatio; + + // The counter is integer-valued, so enough whole weight units + // must decay before the request can be admitted. + var requiredDecay = Math.Ceiling(weightToRemove); + var remainingDecay = Math.Max(0, requiredDecay - _decayProgress); + + var waitTicks = Math.Ceiling(remainingDecay / DecreaseRate * TimePeriod.Ticks); + return waitTicks <= 0 + ? TimeSpan.Zero + : TimeSpan.FromTicks((long)waitTicks); + } + + private void ResetState() + { + _currentWeight = 0; + _decayProgress = 0; + _lastDecrease = DateTime.UtcNow; } } }