From ab17a88e33559b90e600b55d97d010c2488476a2 Mon Sep 17 00:00:00 2001 From: Jkorf Date: Mon, 21 Sep 2026 16:17:34 +0200 Subject: [PATCH] Updated rate limiting margins --- .../RateLimitTests.cs | 26 +++++++++++++++++++ .../Objects/Results/ICallResult.cs | 13 +++++++--- .../RateLimiting/Guards/RateLimitGuard.cs | 20 +++++++++----- .../RateLimiting/Guards/SingleLimitGuard.cs | 12 +++++++-- .../Trackers/FixedAfterStartWindowTracker.cs | 15 ++++++----- .../Trackers/FixedWindowTracker.cs | 22 +++++++++++----- .../Trackers/SlidingWindowTracker.cs | 17 +++++++----- .../Trackers/WindowTrackerHelpers.cs | 20 ++++++++++++++ .../IGetFuturesTriggerOrder.cs | 2 +- 9 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 CryptoExchange.Net/RateLimiting/Trackers/WindowTrackerHelpers.cs diff --git a/CryptoExchange.Net.UnitTests/RateLimitTests.cs b/CryptoExchange.Net.UnitTests/RateLimitTests.cs index 568255d9..f27fbaa4 100644 --- a/CryptoExchange.Net.UnitTests/RateLimitTests.cs +++ b/CryptoExchange.Net.UnitTests/RateLimitTests.cs @@ -3,6 +3,7 @@ using CryptoExchange.Net.RateLimiting; using CryptoExchange.Net.RateLimiting.Filters; using CryptoExchange.Net.RateLimiting.Guards; using CryptoExchange.Net.RateLimiting.Interfaces; +using CryptoExchange.Net.RateLimiting.Trackers; using CryptoExchange.Net.UnitTests.Implementations; using NUnit.Framework; using System; @@ -364,5 +365,30 @@ namespace CryptoExchange.Net.UnitTests RateLimitingBehaviour.Fail, null, 1.0, default); Assert.That(overHardLimit.Error, Is.TypeOf()); } + + [TestCase(0.1, 10)] + [TestCase(1, 50)] + [TestCase(5, 250)] + [TestCase(60, 250)] + public void RateLimitSafetyMargin_DefaultIsProportionalAndCapped(double periodSeconds, int expectedMarginMilliseconds) + { + var margin = WindowTrackerHelpers.GetDefaultSafetyMargin(TimeSpan.FromSeconds(periodSeconds)); + + Assert.That(margin, Is.EqualTo(TimeSpan.FromMilliseconds(expectedMarginMilliseconds))); + } + + [Test] + public void RateLimitGuard_ExplicitSafetyMarginIsUsed() + { + var guard = new RateLimitGuard( + RateLimitGuard.PerHost, + new LimitItemTypeFilter(RateLimitItemType.Request), + 1, + TimeSpan.FromSeconds(1), + RateLimitWindowType.Sliding, + safetyMargin: TimeSpan.Zero); + + Assert.That(guard.SafetyMargin, Is.EqualTo(TimeSpan.Zero)); + } } } diff --git a/CryptoExchange.Net/Objects/Results/ICallResult.cs b/CryptoExchange.Net/Objects/Results/ICallResult.cs index 2a5b0627..1f83d4a3 100644 --- a/CryptoExchange.Net/Objects/Results/ICallResult.cs +++ b/CryptoExchange.Net/Objects/Results/ICallResult.cs @@ -45,11 +45,18 @@ public interface ICallResult : ICallResult /// /// Exchange call result /// -/// Result data type -public interface IExchangeCallResult : ICallResult, ICallResult +public interface IExchangeCallResult : ICallResult { /// /// Exchange name /// - public string Exchange { get; set; } + public string Exchange { get; } +} + +/// +/// Exchange call result +/// +/// Result data type +public interface IExchangeCallResult : IExchangeCallResult, ICallResult +{ } \ No newline at end of file diff --git a/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs b/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs index e25b21d5..4cf050c1 100644 --- a/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs @@ -36,6 +36,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards private readonly Dictionary _trackers; private readonly RateLimitWindowType _windowType; private readonly double? _decayRate; + private readonly TimeSpan _safetyMargin; private readonly int? _connectionWeight; private readonly Func _keySelector; private readonly SemaphoreSlim? _sharedGuardSemaphore; @@ -54,6 +55,10 @@ namespace CryptoExchange.Net.RateLimiting.Guards /// The time period for the limit /// public TimeSpan TimeSpan { get; } + /// + /// Additional time to wait after a rate limit window expires to account for latency and timing differences + /// + public TimeSpan SafetyMargin => _safetyMargin; /// /// Whether this guard is shared between multiple gates @@ -68,11 +73,12 @@ namespace CryptoExchange.Net.RateLimiting.Guards /// Limit per period /// Timespan for the period /// Type of rate limit window + /// Additional time to wait after a window expires, or null to use the default margin /// The decay per timespan if windowType is DecayWindowTracker /// The weight of a new connection /// Whether this guard is shared between multiple gates - public RateLimitGuard(Func keySelector, IGuardFilter filter, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, double? decayPerTimeSpan = null, int? connectionWeight = null, bool shared = false) - : this(keySelector, new[] { filter }, limit, timeSpan, windowType, decayPerTimeSpan, connectionWeight, shared) + public RateLimitGuard(Func keySelector, IGuardFilter filter, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, TimeSpan? safetyMargin = null, double? decayPerTimeSpan = null, int? connectionWeight = null, bool shared = false) + : this(keySelector, new[] { filter }, limit, timeSpan, windowType, safetyMargin, decayPerTimeSpan, connectionWeight, shared) { } @@ -84,10 +90,11 @@ namespace CryptoExchange.Net.RateLimiting.Guards /// Limit per period /// Timespan for the period /// Type of rate limit window + /// Additional time to wait after a window expires, or null to use the default margin /// The decay per timespan if windowType is DecayWindowTracker /// The weight of a new connection /// Whether this guard is shared between multiple gates - public RateLimitGuard(Func keySelector, IEnumerable filters, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, double? decayPerTimeSpan = null, int? connectionWeight = null, bool shared = false) + public RateLimitGuard(Func keySelector, IEnumerable filters, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, TimeSpan? safetyMargin = null, double? decayPerTimeSpan = null, int? connectionWeight = null, bool shared = false) { _filters = filters; _trackers = new Dictionary(); @@ -97,6 +104,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards SharedGuard = shared; _keySelector = keySelector; _decayRate = decayPerTimeSpan; + _safetyMargin = safetyMargin ?? WindowTrackerHelpers.GetDefaultSafetyMargin(timeSpan); ; _connectionWeight = connectionWeight; if (SharedGuard) @@ -205,9 +213,9 @@ namespace CryptoExchange.Net.RateLimiting.Guards /// protected IWindowTracker CreateTracker() { - return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(Limit, TimeSpan) - : _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(Limit, TimeSpan) - : _windowType == RateLimitWindowType.FixedAfterFirst ? new FixedAfterStartWindowTracker(Limit, TimeSpan) : + return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(Limit, TimeSpan, _safetyMargin) + : _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(Limit, TimeSpan, _safetyMargin) + : _windowType == RateLimitWindowType.FixedAfterFirst ? new FixedAfterStartWindowTracker(Limit, TimeSpan, _safetyMargin) : new DecayWindowTracker(Limit, TimeSpan, _decayRate ?? throw new InvalidOperationException("Decay rate not provided")); } } diff --git a/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs b/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs index 5c3553c6..e455c5f1 100644 --- a/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs @@ -24,6 +24,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards private readonly Dictionary _trackers; private readonly RateLimitWindowType _windowType; private readonly double? _decayRate; + private readonly TimeSpan _safetyMargin; private readonly int _limit; private readonly TimeSpan _period; private readonly Func _keySelector; @@ -34,6 +35,11 @@ namespace CryptoExchange.Net.RateLimiting.Guards /// public string Description => _windowType == RateLimitWindowType.Decay ? $"Endpoint limit of {_limit} with a decay rate of {_decayRate}" : $"Limit of {_limit} per {_period}"; + /// + /// Additional time to wait after a rate limit window expires to account for latency and timing differences + /// + public TimeSpan SafetyMargin => _safetyMargin; + /// /// ctor /// @@ -41,6 +47,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards int limit, TimeSpan period, RateLimitWindowType windowType, + TimeSpan? safetyMargin = null, double? decayRate = null, Func? keySelector = null) { @@ -48,6 +55,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards _period = period; _windowType = windowType; _decayRate = decayRate; + _safetyMargin = safetyMargin ?? WindowTrackerHelpers.GetDefaultSafetyMargin(period); _keySelector = keySelector ?? Default; _trackers = new Dictionary(); } @@ -84,8 +92,8 @@ namespace CryptoExchange.Net.RateLimiting.Guards /// protected IWindowTracker CreateTracker() { - return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(_limit, _period) - : _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(_limit, _period) : + return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(_limit, _period, _safetyMargin) + : _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(_limit, _period, _safetyMargin) : new DecayWindowTracker(_limit, _period, _decayRate ?? throw new InvalidOperationException("Decay rate not provided")); } diff --git a/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs index 12825fd9..524cc23e 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs @@ -17,15 +17,18 @@ namespace CryptoExchange.Net.RateLimiting.Trackers private int _currentWeight = 0; private DateTime? _nextReset; - /// - /// Additional wait time to apply to account for time offset between server and client - /// - private static TimeSpan _fixedWindowBuffer = TimeSpan.FromMilliseconds(1000); + private readonly TimeSpan _safetyMargin; public FixedAfterStartWindowTracker(int limit, TimeSpan period) + : this(limit, period, WindowTrackerHelpers.GetDefaultSafetyMargin(period)) + { + } + + public FixedAfterStartWindowTracker(int limit, TimeSpan period, TimeSpan safetyMargin) { Limit = limit; TimePeriod = period; + _safetyMargin = safetyMargin; _entries = new Queue(); } @@ -57,7 +60,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers { // Remove requests no longer in time period from the history var checkTime = DateTime.UtcNow; - if (_nextReset != null && checkTime > _nextReset) + if (_nextReset != null && checkTime >= _nextReset + _safetyMargin) RemoveBefore(_nextReset.Value); if (Current == 0) @@ -130,7 +133,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers private TimeSpan DetermineWaitTime() { var checkTime = DateTime.UtcNow; - var result = (_nextReset!.Value - checkTime) + _fixedWindowBuffer; + var result = _nextReset!.Value + _safetyMargin - checkTime; if (result < TimeSpan.Zero) return TimeSpan.Zero; return result; diff --git a/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs index 51251063..e91caa4e 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs @@ -16,15 +16,18 @@ namespace CryptoExchange.Net.RateLimiting.Trackers private readonly Queue _entries; private int _currentWeight = 0; - /// - /// Additional wait time to apply to account for time offset between server and client - /// - private static readonly TimeSpan _fixedWindowBuffer = TimeSpan.FromMilliseconds(1000); + private readonly TimeSpan _safetyMargin; public FixedWindowTracker(int limit, TimeSpan period) + : this(limit, period, WindowTrackerHelpers.GetDefaultSafetyMargin(period)) + { + } + + public FixedWindowTracker(int limit, TimeSpan period, TimeSpan safetyMargin) { Limit = limit; TimePeriod = period; + _safetyMargin = safetyMargin; _entries = new Queue(); } @@ -56,7 +59,11 @@ namespace CryptoExchange.Net.RateLimiting.Trackers { // Remove requests no longer in time period from the history var checkTime = DateTime.UtcNow; - RemoveBefore(checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks))); + var startCurrentWindow = checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks)); + var resetBoundary = checkTime >= startCurrentWindow + _safetyMargin + ? startCurrentWindow + : startCurrentWindow - TimePeriod; + RemoveBefore(resetBoundary); if ((Current + weight) / (double)Limit > allowedRateRatio) { @@ -124,8 +131,9 @@ namespace CryptoExchange.Net.RateLimiting.Trackers { var checkTime = DateTime.UtcNow; var startCurrentWindow = checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks)); - var wait = startCurrentWindow.Add(TimePeriod) - checkTime; - var result = wait.Add(_fixedWindowBuffer); + var result = checkTime < startCurrentWindow + _safetyMargin + ? startCurrentWindow + _safetyMargin - checkTime + : startCurrentWindow + TimePeriod + _safetyMargin - checkTime; if (result < TimeSpan.Zero) return TimeSpan.Zero; return result; diff --git a/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs index 78abb29b..d9605753 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs @@ -16,15 +16,18 @@ namespace CryptoExchange.Net.RateLimiting.Trackers private readonly List _entries; private int _currentWeight = 0; - /// - /// Additional wait time to apply to account for fluctuating request times - /// - private static readonly TimeSpan _slidingWindowBuffer = TimeSpan.FromMilliseconds(1000); + private readonly TimeSpan _safetyMargin; public SlidingWindowTracker(int limit, TimeSpan period) + : this(limit, period, WindowTrackerHelpers.GetDefaultSafetyMargin(period)) + { + } + + public SlidingWindowTracker(int limit, TimeSpan period, TimeSpan safetyMargin) { Limit = limit; TimePeriod = period; + _safetyMargin = safetyMargin; _entries = new List(); } @@ -56,7 +59,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers public TimeSpan GetWaitTime(int weight, double allowedRateRatio) { // Remove requests no longer in time period from the history - RemoveBefore(DateTime.UtcNow - TimePeriod); + RemoveBefore(DateTime.UtcNow - TimePeriod - _safetyMargin); if ((Current + weight) / (double)Limit > allowedRateRatio) { @@ -99,7 +102,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers { for (var i = 0; i < _entries.Count; i++) { - if (_entries[i].Timestamp < time) + if (_entries[i].Timestamp <= time) { var entry = _entries[i]; _entries.Remove(entry); @@ -127,7 +130,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers removedWeight += entry.Weight; if (removedWeight >= weightToRemove) { - var result = entry.Timestamp + TimePeriod + _slidingWindowBuffer - DateTime.UtcNow; + var result = entry.Timestamp + TimePeriod + _safetyMargin - DateTime.UtcNow; if (result < TimeSpan.Zero) return TimeSpan.Zero; return result; diff --git a/CryptoExchange.Net/RateLimiting/Trackers/WindowTrackerHelpers.cs b/CryptoExchange.Net/RateLimiting/Trackers/WindowTrackerHelpers.cs new file mode 100644 index 00000000..1c0da20d --- /dev/null +++ b/CryptoExchange.Net/RateLimiting/Trackers/WindowTrackerHelpers.cs @@ -0,0 +1,20 @@ +using System; + +namespace CryptoExchange.Net.RateLimiting.Trackers +{ + internal static class WindowTrackerHelpers + { + private static readonly TimeSpan _minimumSafetyMargin = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan _maximumSafetyMargin = TimeSpan.FromMilliseconds(250); + + public static TimeSpan GetDefaultSafetyMargin(TimeSpan period) + { + var margin = TimeSpan.FromTicks(period.Ticks / 20); + if (margin < _minimumSafetyMargin) + return _minimumSafetyMargin; + if (margin > _maximumSafetyMargin) + return _maximumSafetyMargin; + return margin; + } + } +} diff --git a/CryptoExchange.Net/SharedApis/V2/TriggerOrders/Futures/GetFuturesTriggerOrder/IGetFuturesTriggerOrder.cs b/CryptoExchange.Net/SharedApis/V2/TriggerOrders/Futures/GetFuturesTriggerOrder/IGetFuturesTriggerOrder.cs index 94bc4815..bfd66b8b 100644 --- a/CryptoExchange.Net/SharedApis/V2/TriggerOrders/Futures/GetFuturesTriggerOrder/IGetFuturesTriggerOrder.cs +++ b/CryptoExchange.Net/SharedApis/V2/TriggerOrders/Futures/GetFuturesTriggerOrder/IGetFuturesTriggerOrder.cs @@ -23,7 +23,7 @@ namespace CryptoExchange.Net.SharedApis /// /// Request info /// Cancellation token - Task> GetFuturesTriggerOrderAsync(GetOrderRequest request, CancellationToken ct = default); + Task> GetFuturesTriggerOrderAsync(GetOrderRequest request, CancellationToken ct = default); } ///