1
0
mirror of https://github.com/JKorf/CryptoExchange.Net.git synced 2026-10-14 23:21:48 +00:00

Updated rate limiting margins

This commit is contained in:
Jkorf
2026-09-21 16:17:34 +02:00
parent b8baaf55b5
commit ab17a88e33
9 changed files with 115 additions and 32 deletions
@@ -3,6 +3,7 @@ using CryptoExchange.Net.RateLimiting;
using CryptoExchange.Net.RateLimiting.Filters; using CryptoExchange.Net.RateLimiting.Filters;
using CryptoExchange.Net.RateLimiting.Guards; using CryptoExchange.Net.RateLimiting.Guards;
using CryptoExchange.Net.RateLimiting.Interfaces; using CryptoExchange.Net.RateLimiting.Interfaces;
using CryptoExchange.Net.RateLimiting.Trackers;
using CryptoExchange.Net.UnitTests.Implementations; using CryptoExchange.Net.UnitTests.Implementations;
using NUnit.Framework; using NUnit.Framework;
using System; using System;
@@ -364,5 +365,30 @@ namespace CryptoExchange.Net.UnitTests
RateLimitingBehaviour.Fail, null, 1.0, default); RateLimitingBehaviour.Fail, null, 1.0, default);
Assert.That(overHardLimit.Error, Is.TypeOf<ClientRateLimitError>()); Assert.That(overHardLimit.Error, Is.TypeOf<ClientRateLimitError>());
} }
[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));
}
} }
} }
@@ -45,11 +45,18 @@ public interface ICallResult<T> : ICallResult
/// <summary> /// <summary>
/// Exchange call result /// Exchange call result
/// </summary> /// </summary>
/// <typeparam name="T">Result data type</typeparam> public interface IExchangeCallResult : ICallResult
public interface IExchangeCallResult<T> : ICallResult, ICallResult<T>
{ {
/// <summary> /// <summary>
/// Exchange name /// Exchange name
/// </summary> /// </summary>
public string Exchange { get; set; } public string Exchange { get; }
}
/// <summary>
/// Exchange call result
/// </summary>
/// <typeparam name="T">Result data type</typeparam>
public interface IExchangeCallResult<T> : IExchangeCallResult, ICallResult<T>
{
} }
@@ -36,6 +36,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
private readonly Dictionary<string, IWindowTracker> _trackers; private readonly Dictionary<string, IWindowTracker> _trackers;
private readonly RateLimitWindowType _windowType; private readonly RateLimitWindowType _windowType;
private readonly double? _decayRate; private readonly double? _decayRate;
private readonly TimeSpan _safetyMargin;
private readonly int? _connectionWeight; private readonly int? _connectionWeight;
private readonly Func<RequestDefinition, string?, string> _keySelector; private readonly Func<RequestDefinition, string?, string> _keySelector;
private readonly SemaphoreSlim? _sharedGuardSemaphore; private readonly SemaphoreSlim? _sharedGuardSemaphore;
@@ -54,6 +55,10 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// The time period for the limit /// The time period for the limit
/// </summary> /// </summary>
public TimeSpan TimeSpan { get; } public TimeSpan TimeSpan { get; }
/// <summary>
/// Additional time to wait after a rate limit window expires to account for latency and timing differences
/// </summary>
public TimeSpan SafetyMargin => _safetyMargin;
/// <summary> /// <summary>
/// Whether this guard is shared between multiple gates /// Whether this guard is shared between multiple gates
@@ -68,11 +73,12 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// <param name="limit">Limit per period</param> /// <param name="limit">Limit per period</param>
/// <param name="timeSpan">Timespan for the period</param> /// <param name="timeSpan">Timespan for the period</param>
/// <param name="windowType">Type of rate limit window</param> /// <param name="windowType">Type of rate limit window</param>
/// <param name="safetyMargin">Additional time to wait after a window expires, or null to use the default margin</param>
/// <param name="decayPerTimeSpan">The decay per timespan if windowType is DecayWindowTracker</param> /// <param name="decayPerTimeSpan">The decay per timespan if windowType is DecayWindowTracker</param>
/// <param name="connectionWeight">The weight of a new connection</param> /// <param name="connectionWeight">The weight of a new connection</param>
/// <param name="shared">Whether this guard is shared between multiple gates</param> /// <param name="shared">Whether this guard is shared between multiple gates</param>
public RateLimitGuard(Func<RequestDefinition, string?, string> keySelector, IGuardFilter filter, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, double? decayPerTimeSpan = null, int? connectionWeight = null, bool shared = false) public RateLimitGuard(Func<RequestDefinition, string?, string> 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, decayPerTimeSpan, connectionWeight, shared) : this(keySelector, new[] { filter }, limit, timeSpan, windowType, safetyMargin, decayPerTimeSpan, connectionWeight, shared)
{ {
} }
@@ -84,10 +90,11 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// <param name="limit">Limit per period</param> /// <param name="limit">Limit per period</param>
/// <param name="timeSpan">Timespan for the period</param> /// <param name="timeSpan">Timespan for the period</param>
/// <param name="windowType">Type of rate limit window</param> /// <param name="windowType">Type of rate limit window</param>
/// <param name="safetyMargin">Additional time to wait after a window expires, or null to use the default margin</param>
/// <param name="decayPerTimeSpan">The decay per timespan if windowType is DecayWindowTracker</param> /// <param name="decayPerTimeSpan">The decay per timespan if windowType is DecayWindowTracker</param>
/// <param name="connectionWeight">The weight of a new connection</param> /// <param name="connectionWeight">The weight of a new connection</param>
/// <param name="shared">Whether this guard is shared between multiple gates</param> /// <param name="shared">Whether this guard is shared between multiple gates</param>
public RateLimitGuard(Func<RequestDefinition, string?, string> keySelector, IEnumerable<IGuardFilter> filters, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, double? decayPerTimeSpan = null, int? connectionWeight = null, bool shared = false) public RateLimitGuard(Func<RequestDefinition, string?, string> keySelector, IEnumerable<IGuardFilter> filters, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, TimeSpan? safetyMargin = null, double? decayPerTimeSpan = null, int? connectionWeight = null, bool shared = false)
{ {
_filters = filters; _filters = filters;
_trackers = new Dictionary<string, IWindowTracker>(); _trackers = new Dictionary<string, IWindowTracker>();
@@ -97,6 +104,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
SharedGuard = shared; SharedGuard = shared;
_keySelector = keySelector; _keySelector = keySelector;
_decayRate = decayPerTimeSpan; _decayRate = decayPerTimeSpan;
_safetyMargin = safetyMargin ?? WindowTrackerHelpers.GetDefaultSafetyMargin(timeSpan); ;
_connectionWeight = connectionWeight; _connectionWeight = connectionWeight;
if (SharedGuard) if (SharedGuard)
@@ -205,9 +213,9 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// <returns></returns> /// <returns></returns>
protected IWindowTracker CreateTracker() protected IWindowTracker CreateTracker()
{ {
return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(Limit, TimeSpan) return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(Limit, TimeSpan, _safetyMargin)
: _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(Limit, TimeSpan) : _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(Limit, TimeSpan, _safetyMargin)
: _windowType == RateLimitWindowType.FixedAfterFirst ? new FixedAfterStartWindowTracker(Limit, TimeSpan) : : _windowType == RateLimitWindowType.FixedAfterFirst ? new FixedAfterStartWindowTracker(Limit, TimeSpan, _safetyMargin) :
new DecayWindowTracker(Limit, TimeSpan, _decayRate ?? throw new InvalidOperationException("Decay rate not provided")); new DecayWindowTracker(Limit, TimeSpan, _decayRate ?? throw new InvalidOperationException("Decay rate not provided"));
} }
} }
@@ -24,6 +24,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
private readonly Dictionary<string, IWindowTracker> _trackers; private readonly Dictionary<string, IWindowTracker> _trackers;
private readonly RateLimitWindowType _windowType; private readonly RateLimitWindowType _windowType;
private readonly double? _decayRate; private readonly double? _decayRate;
private readonly TimeSpan _safetyMargin;
private readonly int _limit; private readonly int _limit;
private readonly TimeSpan _period; private readonly TimeSpan _period;
private readonly Func<RequestDefinition, string?, string> _keySelector; private readonly Func<RequestDefinition, string?, string> _keySelector;
@@ -34,6 +35,11 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// <inheritdoc /> /// <inheritdoc />
public string Description => _windowType == RateLimitWindowType.Decay ? $"Endpoint limit of {_limit} with a decay rate of {_decayRate}" : $"Limit of {_limit} per {_period}"; public string Description => _windowType == RateLimitWindowType.Decay ? $"Endpoint limit of {_limit} with a decay rate of {_decayRate}" : $"Limit of {_limit} per {_period}";
/// <summary>
/// Additional time to wait after a rate limit window expires to account for latency and timing differences
/// </summary>
public TimeSpan SafetyMargin => _safetyMargin;
/// <summary> /// <summary>
/// ctor /// ctor
/// </summary> /// </summary>
@@ -41,6 +47,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
int limit, int limit,
TimeSpan period, TimeSpan period,
RateLimitWindowType windowType, RateLimitWindowType windowType,
TimeSpan? safetyMargin = null,
double? decayRate = null, double? decayRate = null,
Func<RequestDefinition, string?, string>? keySelector = null) Func<RequestDefinition, string?, string>? keySelector = null)
{ {
@@ -48,6 +55,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
_period = period; _period = period;
_windowType = windowType; _windowType = windowType;
_decayRate = decayRate; _decayRate = decayRate;
_safetyMargin = safetyMargin ?? WindowTrackerHelpers.GetDefaultSafetyMargin(period);
_keySelector = keySelector ?? Default; _keySelector = keySelector ?? Default;
_trackers = new Dictionary<string, IWindowTracker>(); _trackers = new Dictionary<string, IWindowTracker>();
} }
@@ -84,8 +92,8 @@ namespace CryptoExchange.Net.RateLimiting.Guards
/// <returns></returns> /// <returns></returns>
protected IWindowTracker CreateTracker() protected IWindowTracker CreateTracker()
{ {
return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(_limit, _period) return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(_limit, _period, _safetyMargin)
: _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(_limit, _period) : : _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(_limit, _period, _safetyMargin) :
new DecayWindowTracker(_limit, _period, _decayRate ?? throw new InvalidOperationException("Decay rate not provided")); new DecayWindowTracker(_limit, _period, _decayRate ?? throw new InvalidOperationException("Decay rate not provided"));
} }
@@ -17,15 +17,18 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
private int _currentWeight = 0; private int _currentWeight = 0;
private DateTime? _nextReset; private DateTime? _nextReset;
/// <summary> private readonly TimeSpan _safetyMargin;
/// Additional wait time to apply to account for time offset between server and client
/// </summary>
private static TimeSpan _fixedWindowBuffer = TimeSpan.FromMilliseconds(1000);
public FixedAfterStartWindowTracker(int limit, TimeSpan period) public FixedAfterStartWindowTracker(int limit, TimeSpan period)
: this(limit, period, WindowTrackerHelpers.GetDefaultSafetyMargin(period))
{
}
public FixedAfterStartWindowTracker(int limit, TimeSpan period, TimeSpan safetyMargin)
{ {
Limit = limit; Limit = limit;
TimePeriod = period; TimePeriod = period;
_safetyMargin = safetyMargin;
_entries = new Queue<LimitEntry>(); _entries = new Queue<LimitEntry>();
} }
@@ -57,7 +60,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
{ {
// Remove requests no longer in time period from the history // Remove requests no longer in time period from the history
var checkTime = DateTime.UtcNow; var checkTime = DateTime.UtcNow;
if (_nextReset != null && checkTime > _nextReset) if (_nextReset != null && checkTime >= _nextReset + _safetyMargin)
RemoveBefore(_nextReset.Value); RemoveBefore(_nextReset.Value);
if (Current == 0) if (Current == 0)
@@ -130,7 +133,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
private TimeSpan DetermineWaitTime() private TimeSpan DetermineWaitTime()
{ {
var checkTime = DateTime.UtcNow; var checkTime = DateTime.UtcNow;
var result = (_nextReset!.Value - checkTime) + _fixedWindowBuffer; var result = _nextReset!.Value + _safetyMargin - checkTime;
if (result < TimeSpan.Zero) if (result < TimeSpan.Zero)
return TimeSpan.Zero; return TimeSpan.Zero;
return result; return result;
@@ -16,15 +16,18 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
private readonly Queue<LimitEntry> _entries; private readonly Queue<LimitEntry> _entries;
private int _currentWeight = 0; private int _currentWeight = 0;
/// <summary> private readonly TimeSpan _safetyMargin;
/// Additional wait time to apply to account for time offset between server and client
/// </summary>
private static readonly TimeSpan _fixedWindowBuffer = TimeSpan.FromMilliseconds(1000);
public FixedWindowTracker(int limit, TimeSpan period) public FixedWindowTracker(int limit, TimeSpan period)
: this(limit, period, WindowTrackerHelpers.GetDefaultSafetyMargin(period))
{
}
public FixedWindowTracker(int limit, TimeSpan period, TimeSpan safetyMargin)
{ {
Limit = limit; Limit = limit;
TimePeriod = period; TimePeriod = period;
_safetyMargin = safetyMargin;
_entries = new Queue<LimitEntry>(); _entries = new Queue<LimitEntry>();
} }
@@ -56,7 +59,11 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
{ {
// Remove requests no longer in time period from the history // Remove requests no longer in time period from the history
var checkTime = DateTime.UtcNow; 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) if ((Current + weight) / (double)Limit > allowedRateRatio)
{ {
@@ -124,8 +131,9 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
{ {
var checkTime = DateTime.UtcNow; var checkTime = DateTime.UtcNow;
var startCurrentWindow = checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks)); var startCurrentWindow = checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks));
var wait = startCurrentWindow.Add(TimePeriod) - checkTime; var result = checkTime < startCurrentWindow + _safetyMargin
var result = wait.Add(_fixedWindowBuffer); ? startCurrentWindow + _safetyMargin - checkTime
: startCurrentWindow + TimePeriod + _safetyMargin - checkTime;
if (result < TimeSpan.Zero) if (result < TimeSpan.Zero)
return TimeSpan.Zero; return TimeSpan.Zero;
return result; return result;
@@ -16,15 +16,18 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
private readonly List<LimitEntry> _entries; private readonly List<LimitEntry> _entries;
private int _currentWeight = 0; private int _currentWeight = 0;
/// <summary> private readonly TimeSpan _safetyMargin;
/// Additional wait time to apply to account for fluctuating request times
/// </summary>
private static readonly TimeSpan _slidingWindowBuffer = TimeSpan.FromMilliseconds(1000);
public SlidingWindowTracker(int limit, TimeSpan period) public SlidingWindowTracker(int limit, TimeSpan period)
: this(limit, period, WindowTrackerHelpers.GetDefaultSafetyMargin(period))
{
}
public SlidingWindowTracker(int limit, TimeSpan period, TimeSpan safetyMargin)
{ {
Limit = limit; Limit = limit;
TimePeriod = period; TimePeriod = period;
_safetyMargin = safetyMargin;
_entries = new List<LimitEntry>(); _entries = new List<LimitEntry>();
} }
@@ -56,7 +59,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
public TimeSpan GetWaitTime(int weight, double allowedRateRatio) public TimeSpan GetWaitTime(int weight, double allowedRateRatio)
{ {
// Remove requests no longer in time period from the history // 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) if ((Current + weight) / (double)Limit > allowedRateRatio)
{ {
@@ -99,7 +102,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
{ {
for (var i = 0; i < _entries.Count; i++) for (var i = 0; i < _entries.Count; i++)
{ {
if (_entries[i].Timestamp < time) if (_entries[i].Timestamp <= time)
{ {
var entry = _entries[i]; var entry = _entries[i];
_entries.Remove(entry); _entries.Remove(entry);
@@ -127,7 +130,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
removedWeight += entry.Weight; removedWeight += entry.Weight;
if (removedWeight >= weightToRemove) if (removedWeight >= weightToRemove)
{ {
var result = entry.Timestamp + TimePeriod + _slidingWindowBuffer - DateTime.UtcNow; var result = entry.Timestamp + TimePeriod + _safetyMargin - DateTime.UtcNow;
if (result < TimeSpan.Zero) if (result < TimeSpan.Zero)
return TimeSpan.Zero; return TimeSpan.Zero;
return result; return result;
@@ -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;
}
}
}
@@ -23,7 +23,7 @@ namespace CryptoExchange.Net.SharedApis
/// </summary> /// </summary>
/// <param name="request">Request info</param> /// <param name="request">Request info</param>
/// <param name="ct">Cancellation token</param> /// <param name="ct">Cancellation token</param>
Task<IExchangeCallResultSharedFuturesTriggerOrder>> GetFuturesTriggerOrderAsync(GetOrderRequest request, CancellationToken ct = default); Task<IExchangeCallResult<SharedFuturesTriggerOrder>> GetFuturesTriggerOrderAsync(GetOrderRequest request, CancellationToken ct = default);
} }
/// <summary> /// <summary>