From a34684cd31363a21bfeaed4df1ec2c6de7ca30b2 Mon Sep 17 00:00:00 2001 From: Jkorf Date: Thu, 17 Sep 2026 11:33:47 +0200 Subject: [PATCH] Added WithRateLimitAdmissionAsync for REST and Socket clients --- CryptoExchange.Net/Clients/BaseApiClient.cs | 33 +++++++++++++- CryptoExchange.Net/Clients/RestApiClient.cs | 10 ++--- CryptoExchange.Net/Clients/SocketApiClient.cs | 3 +- CryptoExchange.Net/ExtensionMethods.cs | 23 +++++++++- .../Interfaces/Clients/IBaseApiClient.cs | 6 +-- .../Interfaces/IRateLimitAdmissionClient.cs | 22 +++++++++ .../Objects/Sockets/WebSocketParameters.cs | 7 ++- .../RateLimiting/Guards/SingleLimitGuard.cs | 2 +- .../RateLimiting/RateLimitAdmission.cs | 6 +-- .../RateLimiting/RateLimitGate.cs | 14 +++++- .../SharedApis/SharedApiBase.cs | 20 ++++++--- .../SharedApis/V2/ISharedApi.cs | 7 ++- .../Default/CryptoExchangeWebSocketClient.cs | 45 ++++++++++++++----- 13 files changed, 161 insertions(+), 37 deletions(-) create mode 100644 CryptoExchange.Net/Interfaces/IRateLimitAdmissionClient.cs diff --git a/CryptoExchange.Net/Clients/BaseApiClient.cs b/CryptoExchange.Net/Clients/BaseApiClient.cs index 0d6e6b52..1793f7f6 100644 --- a/CryptoExchange.Net/Clients/BaseApiClient.cs +++ b/CryptoExchange.Net/Clients/BaseApiClient.cs @@ -1,10 +1,13 @@ -using System; using CryptoExchange.Net.Interfaces.Clients; using CryptoExchange.Net.Objects.Errors; using CryptoExchange.Net.Objects.Options; +using CryptoExchange.Net.RateLimiting; using CryptoExchange.Net.SharedApis; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Threading; +using System.Threading.Tasks; namespace CryptoExchange.Net.Clients { @@ -78,6 +81,11 @@ namespace CryptoExchange.Net.Clients /// protected internal virtual ErrorMapping ErrorMapping { get; } = new ErrorMapping([]); + /// + /// Current rate limit admission override + /// + protected readonly AsyncLocal AdmissionOverride = new(); + /// /// ctor /// @@ -122,6 +130,29 @@ namespace CryptoExchange.Net.Clients /// public ErrorInfo GetErrorInfo(string code, string? message = null) => ErrorMapping.GetErrorInfo(code.ToString(), message); + /// + public async Task WithRateLimitAdmissionAsync( + RateLimitAdmission admission, + Func> operation) + { + if (admission == null) + throw new ArgumentNullException(nameof(admission)); + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + + var previous = AdmissionOverride.Value; + AdmissionOverride.Value = admission; + + try + { + return await operation().ConfigureAwait(false); + } + finally + { + AdmissionOverride.Value = previous; + } + } + /// /// Dispose /// diff --git a/CryptoExchange.Net/Clients/RestApiClient.cs b/CryptoExchange.Net/Clients/RestApiClient.cs index 5ef88e58..b6973641 100644 --- a/CryptoExchange.Net/Clients/RestApiClient.cs +++ b/CryptoExchange.Net/Clients/RestApiClient.cs @@ -350,9 +350,8 @@ namespace CryptoExchange.Net.Clients if (ClientOptions.RateLimiterEnabled) { - var rateRatio = 1.0; - if (ClientOptions.RateLimitAdmission != null) - rateRatio = ClientOptions.RateLimitAdmission(definition, requestWeight).MaxUtilization; + var admissionOverride = AdmissionOverride.Value ?? ClientOptions.RateLimitAdmission?.Invoke(definition, requestWeight); + var rateRatio = admissionOverride?.MaxUtilizationRatio ?? 1.0; var limitResult = await definition.RateLimitGate.ProcessAsync( _logger, @@ -379,9 +378,8 @@ 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 admissionOverride = AdmissionOverride.Value ?? ClientOptions.RateLimitAdmission?.Invoke(definition, singleRequestWeight); + var rateRatio = admissionOverride?.MaxUtilizationRatio ?? 1.0; var limitResult = await definition.RateLimitGate.ProcessSingleAsync( _logger, diff --git a/CryptoExchange.Net/Clients/SocketApiClient.cs b/CryptoExchange.Net/Clients/SocketApiClient.cs index e0b14aaf..70f862b8 100644 --- a/CryptoExchange.Net/Clients/SocketApiClient.cs +++ b/CryptoExchange.Net/Clients/SocketApiClient.cs @@ -858,7 +858,8 @@ namespace CryptoExchange.Net.Clients ReconnectInterval = ClientOptions.ReconnectInterval, RateLimiter = ClientOptions.RateLimiterEnabled ? RateLimiter : null, RateLimitingBehavior = ClientOptions.RateLimitingBehaviour, - RateLimitAdmission = ClientOptions.RateLimitAdmission, + RateLimitAdmissionCallbackOptions = (def, weight) => ClientOptions.RateLimitAdmission?.Invoke(def, weight), + RateLimitAdmissionCallbackRequest = () => AdmissionOverride.Value, Proxy = ClientOptions.Proxy, Timeout = ApiOptions.SocketNoDataTimeout ?? ClientOptions.SocketNoDataTimeout, ReceiveBufferSize = ClientOptions.ReceiveBufferSize, diff --git a/CryptoExchange.Net/ExtensionMethods.cs b/CryptoExchange.Net/ExtensionMethods.cs index 38db8177..62643872 100644 --- a/CryptoExchange.Net/ExtensionMethods.cs +++ b/CryptoExchange.Net/ExtensionMethods.cs @@ -1,5 +1,8 @@ -using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.Interfaces.Clients; using CryptoExchange.Net.Objects; +using CryptoExchange.Net.RateLimiting; using CryptoExchange.Net.SharedApis; using Microsoft.Extensions.DependencyInjection; using System; @@ -11,6 +14,7 @@ using System.IO.Compression; using System.Linq; using System.Runtime.InteropServices; using System.Text; +using System.Threading.Tasks; using System.Web; namespace CryptoExchange.Net @@ -510,6 +514,23 @@ namespace CryptoExchange.Net return bytes; } + + /// + /// Execute an operation with a specific rate limit admission rule + /// + /// Client + /// Rate limit admission rule + /// Operation to execute + public static Task WithRateLimitAdmissionAsync( + this TClient client, + RateLimitAdmission admission, + Func> operation) + where TClient : IRateLimitAdmissionClient + { + return client.WithRateLimitAdmissionAsync( + admission, + () => operation(client)); + } } } diff --git a/CryptoExchange.Net/Interfaces/Clients/IBaseApiClient.cs b/CryptoExchange.Net/Interfaces/Clients/IBaseApiClient.cs index de5f88e9..ed9c80b6 100644 --- a/CryptoExchange.Net/Interfaces/Clients/IBaseApiClient.cs +++ b/CryptoExchange.Net/Interfaces/Clients/IBaseApiClient.cs @@ -1,6 +1,4 @@ -using CryptoExchange.Net.Authentication; -using CryptoExchange.Net.Objects.Options; -using CryptoExchange.Net.SharedApis; +using CryptoExchange.Net.SharedApis; using System; namespace CryptoExchange.Net.Interfaces.Clients @@ -8,7 +6,7 @@ namespace CryptoExchange.Net.Interfaces.Clients /// /// Base api client /// - public interface IBaseApiClient + public interface IBaseApiClient : IRateLimitAdmissionClient { /// /// Exchange name diff --git a/CryptoExchange.Net/Interfaces/IRateLimitAdmissionClient.cs b/CryptoExchange.Net/Interfaces/IRateLimitAdmissionClient.cs new file mode 100644 index 00000000..81eacae0 --- /dev/null +++ b/CryptoExchange.Net/Interfaces/IRateLimitAdmissionClient.cs @@ -0,0 +1,22 @@ +using CryptoExchange.Net.RateLimiting; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace CryptoExchange.Net.Interfaces +{ + /// + /// A client supporting rate limit admission rules + /// + public interface IRateLimitAdmissionClient + { + /// + /// Execute an operation with a specific rate limit admission rule + /// + Task WithRateLimitAdmissionAsync( + RateLimitAdmission admission, + Func> operation); + } + +} diff --git a/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs b/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs index 621a3662..bce85e6d 100644 --- a/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs +++ b/CryptoExchange.Net/Objects/Sockets/WebSocketParameters.cs @@ -69,7 +69,12 @@ namespace CryptoExchange.Net.Objects.Sockets /// 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 Func? RateLimitAdmissionCallbackOptions { get; set; } + + /// + /// A delegate for retrieving the current rate limit admission decision for a specific request. This allows for custom rate limiting logic to be implemented. + /// + public Func? RateLimitAdmissionCallbackRequest { get; set; } /// /// Encoding for sending/receiving data diff --git a/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs b/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs index 028455e4..5c3553c6 100644 --- a/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Guards/SingleLimitGuard.cs @@ -32,7 +32,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards public string Name => "EndpointLimitGuard"; /// - public string Description => $"Limit requests to endpoint"; + public string Description => _windowType == RateLimitWindowType.Decay ? $"Endpoint limit of {_limit} with a decay rate of {_decayRate}" : $"Limit of {_limit} per {_period}"; /// /// ctor diff --git a/CryptoExchange.Net/RateLimiting/RateLimitAdmission.cs b/CryptoExchange.Net/RateLimiting/RateLimitAdmission.cs index 3f69f24c..272308d0 100644 --- a/CryptoExchange.Net/RateLimiting/RateLimitAdmission.cs +++ b/CryptoExchange.Net/RateLimiting/RateLimitAdmission.cs @@ -12,13 +12,13 @@ namespace CryptoExchange.Net.RateLimiting /// /// Value ratio between 0 and 1 /// - public double MaxUtilization { get; } + public double MaxUtilizationRatio { 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; + MaxUtilizationRatio = maxUtilizationValue; } /// @@ -26,7 +26,7 @@ namespace CryptoExchange.Net.RateLimiting /// 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) + public static RateLimitAdmission WithMaxUtilizationRatio(double value) => new RateLimitAdmission(value); } } diff --git a/CryptoExchange.Net/RateLimiting/RateLimitGate.cs b/CryptoExchange.Net/RateLimiting/RateLimitGate.cs index ac4c7558..aa9b99e0 100644 --- a/CryptoExchange.Net/RateLimiting/RateLimitGate.cs +++ b/CryptoExchange.Net/RateLimiting/RateLimitGate.cs @@ -131,7 +131,10 @@ namespace CryptoExchange.Net.RateLimiting logger.RateLimitRequestFailed(itemId, definition.Path, guard.Name, guard.Description); RateLimitTriggered?.Invoke(new RateLimitEvent(itemId, _name, guard.Description, definition, result.Current, requestWeight, result.Limit, result.Period, result.Delay, rateLimitingBehaviour)); - return CallResult.Fail(new ClientRateLimitError($"Rate limit check failed on guard {guard.Name}; {guard.Description}")); + var errorMsg = $"Rate limit check failed on guard {guard.Name}; {guard.Description}"; + if (allowedRateRatio < 1) + errorMsg += $" with allowed ratio {allowedRateRatio}"; + return CallResult.Fail(new ClientRateLimitError(errorMsg)); } if (result.Delay != TimeSpan.Zero) @@ -139,7 +142,14 @@ namespace CryptoExchange.Net.RateLimiting // Delay is needed and limit behaviour is to wait for the request to be under the limit _semaphore.Release(); - var description = result.Limit == null ? guard.Description : $"{guard.Description}, Request weight: {requestWeight}, Current: {result.Current}, Limit: {result.Limit}, requests now being limited: {_waitingCount}"; + var description = + result.Limit == null + ? guard.Description + : $"{guard.Description}, Request weight: {requestWeight}," + + $" Current: {result.Current}" + + $", Limit: {result.Limit}" + + (allowedRateRatio < 1 ? $", Allowed ratio: {allowedRateRatio}" : "") + + $", requests now being limited: {_waitingCount}"; if (type == RateLimitItemType.Connection) logger.RateLimitDelayingConnection(itemId, result.Delay, guard.Name, description); else diff --git a/CryptoExchange.Net/SharedApis/SharedApiBase.cs b/CryptoExchange.Net/SharedApis/SharedApiBase.cs index d3ca516d..42192d2e 100644 --- a/CryptoExchange.Net/SharedApis/SharedApiBase.cs +++ b/CryptoExchange.Net/SharedApis/SharedApiBase.cs @@ -1,7 +1,10 @@ using CryptoExchange.Net.Clients; +using CryptoExchange.Net.Interfaces.Clients; +using CryptoExchange.Net.RateLimiting; using System; using System.Collections.Generic; using System.Text; +using System.Threading.Tasks; namespace CryptoExchange.Net.SharedApis { @@ -12,7 +15,7 @@ namespace CryptoExchange.Net.SharedApis { private readonly Func _symbolFormatter; private readonly Func _authDelegate; - + private readonly IBaseApiClient _apiClient; private IReadOnlyCollection _capabilities = Array.Empty(); IReadOnlyCollection ISharedApi.Capabilities => _capabilities; @@ -33,16 +36,17 @@ namespace CryptoExchange.Net.SharedApis /// public SharedApiBase( SharedTransport transport, - string exchange, + IBaseApiClient apiClient, TradingMode[] supportedTradingModes, Func authenticated, Func formatSymbol) { - Transport = transport; - Exchange = exchange; - SupportedTradingModes = supportedTradingModes; + _apiClient = apiClient; _authDelegate = authenticated; _symbolFormatter = formatSymbol; + Transport = transport; + Exchange = apiClient.Exchange; + SupportedTradingModes = supportedTradingModes; } /// @@ -64,6 +68,12 @@ namespace CryptoExchange.Net.SharedApis /// public void ResetDefaultExchangeParameters() => ExchangeParameters.ResetStaticExchangeParameters(Exchange); + /// + public Task WithRateLimitAdmissionAsync( + RateLimitAdmission admission, + Func> operation) + => _apiClient.WithRateLimitAdmissionAsync(admission, operation); + /// public abstract SharedClientInfo Discover(); } diff --git a/CryptoExchange.Net/SharedApis/V2/ISharedApi.cs b/CryptoExchange.Net/SharedApis/V2/ISharedApi.cs index d3958fad..d32c7de9 100644 --- a/CryptoExchange.Net/SharedApis/V2/ISharedApi.cs +++ b/CryptoExchange.Net/SharedApis/V2/ISharedApi.cs @@ -1,12 +1,15 @@ -using System; +using CryptoExchange.Net.Interfaces; +using CryptoExchange.Net.RateLimiting; +using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace CryptoExchange.Net.SharedApis { /// /// Shared API /// - public interface ISharedApi + public interface ISharedApi : IRateLimitAdmissionClient { /// /// Name of the exchange diff --git a/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs b/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs index d28b27f5..ce119f62 100644 --- a/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs +++ b/CryptoExchange.Net/Sockets/Default/CryptoExchangeWebSocketClient.cs @@ -208,11 +208,17 @@ namespace CryptoExchange.Net.Sockets.Default { if (Parameters.RateLimiter != null) { - 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); + var limitResult = await Parameters.RateLimiter.ProcessAsync( + _logger, + Id, + RateLimitItemType.Connection, + _requestDefinition, + null, + 1, + Parameters.RateLimitingBehavior, + null, + 1.0, + _ctsSource.Token).ConfigureAwait(false); if (!limitResult.Success) return CallResult.Fail(new ClientRateLimitError("Connection limit reached")); } @@ -386,7 +392,15 @@ namespace CryptoExchange.Net.Sockets.Default var bytes = Parameters.Encoding.GetBytes(data); _logger.SocketAddingBytesToSendBuffer(Id, id, bytes); - _sendBuffer.Enqueue(new SendItem { Id = id, Type = WebSocketMessageType.Text, Weight = weight, Bytes = bytes }); + _sendBuffer.Enqueue( + new SendItem + { + Id = id, + Type = WebSocketMessageType.Text, + Weight = weight, + Bytes = bytes, + RateLimitAdmission = Parameters.RateLimitAdmissionCallbackRequest?.Invoke() + }); _sendEvent.Set(); return true; } @@ -398,7 +412,14 @@ namespace CryptoExchange.Net.Sockets.Default return false; _logger.SocketAddingBytesToSendBuffer(Id, id, data); - _sendBuffer.Enqueue(new SendItem { Id = id, Type = WebSocketMessageType.Binary, Weight = weight, Bytes = data }); + _sendBuffer.Enqueue( + new SendItem { + Id = id, + Type = WebSocketMessageType.Binary, + Weight = weight, + Bytes = data, + RateLimitAdmission = Parameters.RateLimitAdmissionCallbackRequest?.Invoke() + }); _sendEvent.Set(); return true; } @@ -534,9 +555,8 @@ namespace CryptoExchange.Net.Sockets.Default { if (Parameters.RateLimiter != null) { - var rateRatio = 1.0; - if (Parameters.RateLimitAdmission != null) - rateRatio = Parameters.RateLimitAdmission(_requestDefinition, data.Weight).MaxUtilization; + var admission = data.RateLimitAdmission ?? Parameters.RateLimitAdmissionCallbackOptions?.Invoke(_requestDefinition, data.Weight); + var rateRatio = admission?.MaxUtilizationRatio ?? 1.0; try { @@ -1035,6 +1055,11 @@ namespace CryptoExchange.Net.Sockets.Default /// public WebSocketMessageType Type { get; set; } + /// + /// Rate limit admission override + /// + public RateLimitAdmission? RateLimitAdmission { get; set; } + /// /// The bytes to send ///