diff --git a/CryptoExchange.Net.UnitTests/RateLimitTests.cs b/CryptoExchange.Net.UnitTests/RateLimitTests.cs index a1c8a09c..568255d9 100644 --- a/CryptoExchange.Net.UnitTests/RateLimitTests.cs +++ b/CryptoExchange.Net.UnitTests/RateLimitTests.cs @@ -33,12 +33,12 @@ namespace CryptoExchange.Net.UnitTests for (var i = 0; i < requests + 1; i++) { - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(i == requests ? triggered : !triggered); } triggered = false; await Task.Delay((int)Math.Round(perSeconds * 1000) + 10); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(!triggered); } @@ -58,7 +58,7 @@ namespace CryptoExchange.Net.UnitTests rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } @@ -79,9 +79,9 @@ namespace CryptoExchange.Net.UnitTests RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(evnt == null); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(expectLimiting ? evnt != null : evnt == null); } @@ -100,12 +100,12 @@ namespace CryptoExchange.Net.UnitTests for (var i = 0; i < requests + 1; i++) { - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(i == requests ? triggered : !triggered); } triggered = false; await Task.Delay((int)Math.Round(perSeconds * 1000) + 10); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(!triggered); } @@ -123,7 +123,7 @@ namespace CryptoExchange.Net.UnitTests rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } @@ -143,7 +143,7 @@ namespace CryptoExchange.Net.UnitTests rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; for (var i = 0; i < 2; i++) { - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null; Assert.That(expected); } @@ -166,9 +166,9 @@ namespace CryptoExchange.Net.UnitTests RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, key1, 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, key1, 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(evnt == null); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, key2, 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, key2, 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(expectLimited ? evnt != null : evnt == null); } @@ -185,9 +185,9 @@ namespace CryptoExchange.Net.UnitTests RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(evnt == null); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, null, 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, null, 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(expectLimited ? evnt != null : evnt == null); } @@ -205,9 +205,9 @@ namespace CryptoExchange.Net.UnitTests RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(evnt == null); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(expectLimited ? evnt != null : evnt == null); } @@ -222,9 +222,9 @@ namespace CryptoExchange.Net.UnitTests RateLimitEvent? evnt = null; rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host1, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host1, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(evnt == null); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host2, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host2, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, default); Assert.That(expectLimited ? evnt != null : evnt == null); } @@ -238,8 +238,8 @@ namespace CryptoExchange.Net.UnitTests rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2)); - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, ct.Token); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, ct.Token); Assert.That(result2.Error, Is.TypeOf()); } @@ -257,9 +257,9 @@ namespace CryptoExchange.Net.UnitTests var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2)); // act - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, 1, ct.Token); await rateLimiter.ResetAsync(RateLimitItemType.Request, definition, null, null, null, default); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, 1, ct.Token); // assert Assert.That(evnt, Is.Null); @@ -279,10 +279,10 @@ namespace CryptoExchange.Net.UnitTests rateLimiter.RateLimitTriggered += (x) => { evnt = x; }; // act - var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, null, 1, RateLimitingBehaviour.Fail, null, default); - var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default); + var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, null, 1, RateLimitingBehaviour.Fail, null, 1, default); + var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, 1, default); await rateLimiter.ResetAsync(RateLimitItemType.Request, definition1, null, null, null, default); - var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default); + var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, 1, default); // assert Assert.That(evnt, Is.Not.Null); @@ -321,5 +321,48 @@ namespace CryptoExchange.Net.UnitTests // assert Assert.That(evnt != null, Is.EqualTo(expectLimited)); } + + [TestCase(RateLimitWindowType.Fixed)] + [TestCase(RateLimitWindowType.FixedAfterFirst)] + [TestCase(RateLimitWindowType.Sliding)] + [TestCase(RateLimitWindowType.Decay)] + public async Task LowerThreshold_ReservesCapacityForFullThreshold(RateLimitWindowType windowType) + { + var gate = new RateLimitGate("Test"); + gate.AddGuard(new RateLimitGuard( + RateLimitGuard.PerHost, + new LimitItemTypeFilter(RateLimitItemType.Request), + 10, + TimeSpan.FromHours(1), + windowType, + decayPerTimeSpan: 1)); + + var definition = new RequestDefinition( + "https://test.com", "/ticker", HttpMethod.Get); + var logger = new TraceLogger(); + + for (var i = 0; i < 8; i++) + { + var result = await gate.ProcessAsync( + logger, i, RateLimitItemType.Request, definition, null, 1, + RateLimitingBehaviour.Fail, null, 0.8, default); + Assert.That(result.Success, Is.True); + } + + var marketData = await gate.ProcessAsync( + logger, 9, RateLimitItemType.Request, definition, null, 1, + RateLimitingBehaviour.Fail, null, 0.8, default); + Assert.That(marketData.Error, Is.TypeOf()); + + var order = await gate.ProcessAsync( + logger, 10, RateLimitItemType.Request, definition, null, 2, + RateLimitingBehaviour.Fail, null, 1.0, default); + Assert.That(order.Success, Is.True); + + var overHardLimit = await gate.ProcessAsync( + logger, 11, RateLimitItemType.Request, definition, null, 1, + RateLimitingBehaviour.Fail, null, 1.0, default); + Assert.That(overHardLimit.Error, Is.TypeOf()); + } } } diff --git a/CryptoExchange.Net/Clients/RestApiClient.cs b/CryptoExchange.Net/Clients/RestApiClient.cs index 8924291b..5ef88e58 100644 --- a/CryptoExchange.Net/Clients/RestApiClient.cs +++ b/CryptoExchange.Net/Clients/RestApiClient.cs @@ -350,6 +350,10 @@ namespace CryptoExchange.Net.Clients if (ClientOptions.RateLimiterEnabled) { + var rateRatio = 1.0; + if (ClientOptions.RateLimitAdmission != null) + rateRatio = ClientOptions.RateLimitAdmission(definition, requestWeight).MaxUtilization; + var limitResult = await definition.RateLimitGate.ProcessAsync( _logger, requestId, @@ -359,6 +363,7 @@ namespace CryptoExchange.Net.Clients requestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix + ClientOptions.RateLimitGroup, + rateRatio, cancellationToken).ConfigureAwait(false); if (!limitResult.Success) return limitResult.Error!; @@ -374,6 +379,10 @@ namespace CryptoExchange.Net.Clients if (ClientOptions.RateLimiterEnabled) { var singleRequestWeight = weightSingleLimiter ?? 1; + var rateRatio = 1.0; + if (ClientOptions.RateLimitAdmission != null) + rateRatio = ClientOptions.RateLimitAdmission(definition, singleRequestWeight).MaxUtilization; + var limitResult = await definition.RateLimitGate.ProcessSingleAsync( _logger, requestId, @@ -384,6 +393,7 @@ namespace CryptoExchange.Net.Clients singleRequestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, + rateRatio, cancellationToken).ConfigureAwait(false); if (!limitResult.Success) return limitResult.Error!; diff --git a/CryptoExchange.Net/Clients/SocketApiClient.cs b/CryptoExchange.Net/Clients/SocketApiClient.cs index e9c533b2..e0b14aaf 100644 --- a/CryptoExchange.Net/Clients/SocketApiClient.cs +++ b/CryptoExchange.Net/Clients/SocketApiClient.cs @@ -858,6 +858,7 @@ namespace CryptoExchange.Net.Clients ReconnectInterval = ClientOptions.ReconnectInterval, RateLimiter = ClientOptions.RateLimiterEnabled ? RateLimiter : null, RateLimitingBehavior = ClientOptions.RateLimitingBehaviour, + RateLimitAdmission = ClientOptions.RateLimitAdmission, Proxy = ClientOptions.Proxy, Timeout = ApiOptions.SocketNoDataTimeout ?? ClientOptions.SocketNoDataTimeout, ReceiveBufferSize = ClientOptions.ReceiveBufferSize, diff --git a/CryptoExchange.Net/Objects/Options/ExchangeOptions.cs b/CryptoExchange.Net/Objects/Options/ExchangeOptions.cs index 5c062bdb..99e20d0b 100644 --- a/CryptoExchange.Net/Objects/Options/ExchangeOptions.cs +++ b/CryptoExchange.Net/Objects/Options/ExchangeOptions.cs @@ -1,4 +1,5 @@ using CryptoExchange.Net.Authentication; +using CryptoExchange.Net.RateLimiting; using System; namespace CryptoExchange.Net.Objects.Options @@ -41,6 +42,12 @@ namespace CryptoExchange.Net.Objects.Options /// public RateLimitingBehaviour RateLimitingBehaviour { get; set; } = RateLimitingBehaviour.Wait; + /// + /// A delegate receiving the request definition and the request weight that can be used to determine whether a request should be admitted or rejected based + /// on the request definition and the current rate limit usage ratio. This allows for custom rate limiting logic to be implemented. + /// + public Func? RateLimitAdmission { get; set; } + /// public override string ToString() { diff --git a/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs b/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs index ea0e1155..e6f32eab 100644 --- a/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs +++ b/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs @@ -75,6 +75,7 @@ namespace CryptoExchange.Net.Objects.Options item.RateLimiterEnabled = RateLimiterEnabled; item.RateLimitingBehaviour = RateLimitingBehaviour; item.RateLimitGroup = RateLimitGroup; + item.RateLimitAdmission = RateLimitAdmission; item.CachingEnabled = CachingEnabled; item.CachingMaxAge = CachingMaxAge; item.HttpVersion = HttpVersion; diff --git a/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs b/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs index c01bad9c..db5e1aeb 100644 --- a/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs +++ b/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs @@ -105,6 +105,7 @@ namespace CryptoExchange.Net.Objects.Options item.RateLimitingBehaviour = RateLimitingBehaviour; item.RateLimiterEnabled = RateLimiterEnabled; item.RateLimitGroup = RateLimitGroup; + item.RateLimitAdmission = RateLimitAdmission; item.ReceiveBufferSize = ReceiveBufferSize; return item; } diff --git a/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs b/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs index 5ce4c1ab..621a3662 100644 --- a/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs +++ b/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs @@ -1,4 +1,5 @@ -using CryptoExchange.Net.RateLimiting.Interfaces; +using CryptoExchange.Net.RateLimiting; +using CryptoExchange.Net.RateLimiting.Interfaces; using System; using System.Collections.Generic; using System.Text; @@ -64,6 +65,12 @@ namespace CryptoExchange.Net.Objects.Sockets /// public RateLimitingBehaviour RateLimitingBehavior { get; set; } + /// + /// A delegate receiving the request definition and the request weight that can be used to determine whether a request should be admitted or rejected based + /// on the request definition and the current rate limit usage ratio. This allows for custom rate limiting logic to be implemented. + /// + public Func? RateLimitAdmission { get; set; } + /// /// Encoding for sending/receiving data /// diff --git a/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs b/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs index 4ef0ca09..e25b21d5 100644 --- a/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Guards/RateLimitGuard.cs @@ -104,7 +104,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards } /// - public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix) + public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio) { foreach (var filter in _filters) { @@ -127,7 +127,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards _trackers.Add(key, tracker); } - var delay = tracker.GetWaitTime(requestWeight); + var delay = tracker.GetWaitTime(requestWeight, allowedRateRatio); if (delay == default) return LimitCheck.NotNeeded(Limit, TimeSpan, tracker.Current); diff --git a/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs b/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs index c6116813..3160fc07 100644 --- a/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs @@ -42,7 +42,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards } /// - public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix) + public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio) { if (type != Type) return LimitCheck.NotApplicable; diff --git a/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs b/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs index adb60fc3..028455e4 100644 --- a/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs @@ -53,7 +53,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards } /// - public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix) + public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio) { var key = _keySelector(definition, apiKey) + keySuffix; if (!_trackers.TryGetValue(key, out var tracker)) @@ -62,7 +62,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards _trackers.Add(key, tracker); } - var delay = tracker.GetWaitTime(requestWeight); + var delay = tracker.GetWaitTime(requestWeight, allowedRateRatio); if (delay == default) return LimitCheck.NotNeeded(_limit, _period, tracker.Current); diff --git a/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs b/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs index e0feafde..5c6155af 100644 --- a/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs +++ b/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs @@ -53,9 +53,20 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces /// Request weight /// Behaviour when rate limit is hit /// An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters. - /// Cancelation token + /// The allowed rate ratio + /// Cancellation token /// Error if RateLimitingBehaviour is Fail and rate limit is hit - ValueTask ProcessAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour behaviour, string? keySuffix, CancellationToken ct); + ValueTask ProcessAsync( + ILogger logger, + int itemId, + RateLimitItemType type, + RequestDefinition definition, + string? apiKey, + int requestWeight, + RateLimitingBehaviour behaviour, + string? keySuffix, + double allowedRateRatio, + CancellationToken ct); /// /// Enforces the rate limit as defined in the request definition. When a rate limit is hit will wait for the rate limit to pass if RateLimitingBehaviour is Wait, or return an error if it is set to Fail @@ -69,9 +80,21 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces /// Behaviour when rate limit is hit /// The weight to apply to the limit guard /// An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters. + /// The allowed rate ratio /// Cancelation token /// Error if RateLimitingBehaviour is Fail and rate limit is hit - ValueTask ProcessSingleAsync(ILogger logger, int itemId, IRateLimitGuard guard, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour behaviour, string? keySuffix, CancellationToken ct); + ValueTask ProcessSingleAsync( + ILogger logger, + int itemId, + IRateLimitGuard guard, + RateLimitItemType type, + RequestDefinition definition, + string? apiKey, + int requestWeight, + RateLimitingBehaviour behaviour, + string? keySuffix, + double allowedRateRatio, + CancellationToken ct); /// /// Reset the limit for the specified parameters diff --git a/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGuard.cs b/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGuard.cs index ccfec758..b4e8118c 100644 --- a/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGuard.cs @@ -25,8 +25,9 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces /// The API key /// The request weight /// An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters. + /// The allowed rate ratio /// - LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix); + LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio); /// /// Apply the request to this guard with the specified weight diff --git a/CryptoExchange.Net/RateLimiting/Interfaces/IWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Interfaces/IWindowTracker.cs index a61e6971..b5f01507 100644 --- a/CryptoExchange.Net/RateLimiting/Interfaces/IWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Interfaces/IWindowTracker.cs @@ -22,9 +22,7 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces /// /// Get the time to wait to fit the weight /// - /// - /// - TimeSpan GetWaitTime(int weight); + TimeSpan GetWaitTime(int weight, double allowedRateRatio); /// /// Register the weight in this window /// diff --git a/CryptoExchange.Net/RateLimiting/RateLimitAdmission.cs b/CryptoExchange.Net/RateLimiting/RateLimitAdmission.cs new file mode 100644 index 00000000..3f69f24c --- /dev/null +++ b/CryptoExchange.Net/RateLimiting/RateLimitAdmission.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CryptoExchange.Net.RateLimiting +{ + /// + /// Rate limit admission decision result + /// + public record RateLimitAdmission + { + /// + /// Value ratio between 0 and 1 + /// + public double MaxUtilization { get; } + + private RateLimitAdmission(double maxUtilizationValue) { + if (maxUtilizationValue <= 0 || maxUtilizationValue > 1) + throw new ArgumentOutOfRangeException(nameof(maxUtilizationValue), "Max utilization value must be bigger than 0 and less than or equal to 1"); + + MaxUtilization = maxUtilizationValue; + } + + /// + /// Only allow the request when below a certain ratio of the rate limit, for example 0.5 means it can use a max of 50% of the rate limit, + /// 1 means it's allowed to use the full rate limit. + /// + /// 0.5 means a max use 50% of the rate limit, 1 means the request is allowed to use the full rate limit + public static RateLimitAdmission WithMaxUtilization(double value) + => new RateLimitAdmission(value); + } +} diff --git a/CryptoExchange.Net/RateLimiting/RateLimitGate.cs b/CryptoExchange.Net/RateLimiting/RateLimitGate.cs index f7b7ca7f..ac4c7558 100644 --- a/CryptoExchange.Net/RateLimiting/RateLimitGate.cs +++ b/CryptoExchange.Net/RateLimiting/RateLimitGate.cs @@ -37,14 +37,24 @@ namespace CryptoExchange.Net.RateLimiting } /// - public async ValueTask ProcessAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, string? keySuffix, CancellationToken ct) + public async ValueTask ProcessAsync( + ILogger logger, + int itemId, + RateLimitItemType type, + RequestDefinition definition, + string? apiKey, + int requestWeight, + RateLimitingBehaviour rateLimitingBehaviour, + string? keySuffix, + double allowedRateRatio, + CancellationToken ct) { await _semaphore.WaitAsync(ct).ConfigureAwait(false); bool release = true; _waitingCount++; try { - return await CheckGuardsAsync(_guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, ct).ConfigureAwait(false); + return await CheckGuardsAsync(_guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, allowedRateRatio, ct).ConfigureAwait(false); } catch (TaskCanceledException tce) { @@ -71,6 +81,7 @@ namespace CryptoExchange.Net.RateLimiting int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, string? keySuffix, + double allowedRateRatio, CancellationToken ct) { await _semaphore.WaitAsync(ct).ConfigureAwait(false); @@ -78,7 +89,7 @@ namespace CryptoExchange.Net.RateLimiting _waitingCount++; try { - return await CheckGuardsAsync(new IRateLimitGuard[] { guard }, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, ct).ConfigureAwait(false); + return await CheckGuardsAsync(new IRateLimitGuard[] { guard }, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, allowedRateRatio, ct).ConfigureAwait(false); } catch (TaskCanceledException tce) { @@ -94,12 +105,23 @@ namespace CryptoExchange.Net.RateLimiting } } - private async ValueTask CheckGuardsAsync(IEnumerable guards, ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, string? keySuffix, CancellationToken ct) + private async ValueTask CheckGuardsAsync( + IEnumerable guards, + ILogger logger, + int itemId, + RateLimitItemType type, + RequestDefinition definition, + string? apiKey, + int requestWeight, + RateLimitingBehaviour rateLimitingBehaviour, + string? keySuffix, + double allowedRateRatio, + CancellationToken ct) { foreach (var guard in guards) { // Check if a wait is needed for this guard - var result = guard.Check(type, definition, apiKey, requestWeight, keySuffix); + var result = guard.Check(type, definition, apiKey, requestWeight, keySuffix, allowedRateRatio); if (result.Delay != TimeSpan.Zero && rateLimitingBehaviour == RateLimitingBehaviour.Fail) { // Delay is needed and limit behaviour is to fail the request @@ -126,7 +148,7 @@ namespace CryptoExchange.Net.RateLimiting RateLimitTriggered?.Invoke(new RateLimitEvent(itemId, _name, guard.Description, definition, result.Current, requestWeight, result.Limit, result.Period, result.Delay, rateLimitingBehaviour)); await Task.Delay((int)result.Delay.TotalMilliseconds + 1, ct).ConfigureAwait(false); await _semaphore.WaitAsync(ct).ConfigureAwait(false); - return await CheckGuardsAsync(guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, ct).ConfigureAwait(false); + return await CheckGuardsAsync(guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, allowedRateRatio, ct).ConfigureAwait(false); } } diff --git a/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs index 09218ae4..e512d8ca 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/DecayWindowTracker.cs @@ -41,22 +41,31 @@ namespace CryptoExchange.Net.RateLimiting.Trackers } /// - public TimeSpan GetWaitTime(int weight) + public TimeSpan GetWaitTime(int weight, double allowedRateRatio) { // Decrease the counter based on the last update time and decay rate DecreaseCounter(DateTime.UtcNow); - if (Current + weight > Limit) + if ((Current + weight) / (double)Limit > allowedRateRatio) { // The weight would cause the rate limit to be passed if (Current == 0) { - 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}"); + 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}"); + } } // Determine the time to wait before this weight can be applied without going over the rate limit - return DetermineWaitTime(weight); + return DetermineWaitTime(weight, allowedRateRatio); } // Weight can fit without going over limit @@ -90,10 +99,11 @@ namespace CryptoExchange.Net.RateLimiting.Trackers /// Determine the time to wait before the weight would fit /// /// + /// /// - private TimeSpan DetermineWaitTime(int requestWeight) + private TimeSpan DetermineWaitTime(int requestWeight, double allowedRateRatio) { - var weightToRemove = Math.Max(Current - (Limit - requestWeight), 0); + 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; diff --git a/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs index 02e5d74c..12825fd9 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/FixedAfterStartWindowTracker.cs @@ -53,7 +53,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers } } - public TimeSpan GetWaitTime(int weight) + public TimeSpan GetWaitTime(int weight, double allowedRateRatio) { // Remove requests no longer in time period from the history var checkTime = DateTime.UtcNow; @@ -63,13 +63,22 @@ namespace CryptoExchange.Net.RateLimiting.Trackers if (Current == 0) _nextReset = null; - if (Current + weight > Limit) + if ((Current + weight) / (double)Limit > allowedRateRatio) { // The weight would cause the rate limit to be passed if (Current == 0) { - 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}"); + 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}"); + } } // Determine the time to wait before this weight can be applied without going over the rate limit diff --git a/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs index a45c68c2..51251063 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/FixedWindowTracker.cs @@ -52,19 +52,28 @@ namespace CryptoExchange.Net.RateLimiting.Trackers } /// - public TimeSpan GetWaitTime(int weight) + public TimeSpan GetWaitTime(int weight, double allowedRateRatio) { // Remove requests no longer in time period from the history var checkTime = DateTime.UtcNow; RemoveBefore(checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks))); - if (Current + weight > Limit) + if ((Current + weight) / (double)Limit > allowedRateRatio) { // The weight would cause the rate limit to be passed if (Current == 0) { - 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}"); + 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}"); + } } // Determine the time to wait before this weight can be applied without going over the rate limit diff --git a/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs b/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs index c37ac23e..78abb29b 100644 --- a/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs +++ b/CryptoExchange.Net/RateLimiting/Trackers/SlidingWindowTracker.cs @@ -53,22 +53,31 @@ namespace CryptoExchange.Net.RateLimiting.Trackers } /// - public TimeSpan GetWaitTime(int weight) + public TimeSpan GetWaitTime(int weight, double allowedRateRatio) { // Remove requests no longer in time period from the history RemoveBefore(DateTime.UtcNow - TimePeriod); - if (Current + weight > Limit) + if ((Current + weight) / (double)Limit > allowedRateRatio) { // The weight would cause the rate limit to be passed if (Current == 0) { - 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}"); + 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}"); + } } // Determine the time to wait before this weight can be applied without going over the rate limit - return DetermineWaitTime(weight); + return DetermineWaitTime(weight, allowedRateRatio); } // Weight can fit without going over limit @@ -108,9 +117,9 @@ namespace CryptoExchange.Net.RateLimiting.Trackers /// Determine the time to wait before the weight would fit /// /// - private TimeSpan DetermineWaitTime(int requestWeight) + private TimeSpan DetermineWaitTime(int requestWeight, double allowedRateRatio) { - var weightToRemove = Math.Max(Current - (Limit - requestWeight), 0); + var weightToRemove = Math.Max(Current + requestWeight - Limit * allowedRateRatio, 0); var removedWeight = 0; for (var i = 0; i < _entries.Count; i++) { diff --git a/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs b/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs index cb4399a5..d28b27f5 100644 --- a/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs +++ b/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs @@ -208,7 +208,11 @@ namespace CryptoExchange.Net.Sockets.Default { if (Parameters.RateLimiter != null) { - var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, Id, RateLimitItemType.Connection, _requestDefinition, null, 1, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false); + var rateRatio = 1.0; + if (Parameters.RateLimitAdmission != null) + rateRatio = Parameters.RateLimitAdmission(_requestDefinition, 1).MaxUtilization; + + var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, Id, RateLimitItemType.Connection, _requestDefinition, null, 1, Parameters.RateLimitingBehavior, null, rateRatio, _ctsSource.Token).ConfigureAwait(false); if (!limitResult.Success) return CallResult.Fail(new ClientRateLimitError("Connection limit reached")); } @@ -530,9 +534,14 @@ namespace CryptoExchange.Net.Sockets.Default { if (Parameters.RateLimiter != null) { + var rateRatio = 1.0; + if (Parameters.RateLimitAdmission != null) + rateRatio = Parameters.RateLimitAdmission(_requestDefinition, data.Weight).MaxUtilization; + try { - var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, data.Id, RateLimitItemType.Request, _requestDefinition, null, data.Weight, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false); + var limitResult = await Parameters.RateLimiter.ProcessAsync( + _logger, data.Id, RateLimitItemType.Request, _requestDefinition, null, data.Weight, Parameters.RateLimitingBehavior, null, rateRatio, _ctsSource.Token).ConfigureAwait(false); if (!limitResult.Success) { await (OnRequestRateLimited?.Invoke(data.Id) ?? Task.CompletedTask).ConfigureAwait(false);