diff --git a/CryptoExchange.Net/Clients/SocketApiClient.cs b/CryptoExchange.Net/Clients/SocketApiClient.cs index c2ee56a..e92572c 100644 --- a/CryptoExchange.Net/Clients/SocketApiClient.cs +++ b/CryptoExchange.Net/Clients/SocketApiClient.cs @@ -519,6 +519,7 @@ namespace CryptoExchange.Net.Clients var socket = CreateSocket(connectionAddress.Data!); var socketConnection = new SocketConnection(_logger, this, socket, address); socketConnection.UnhandledMessage += HandleUnhandledMessage; + socketConnection.ConnectRateLimitedAsync += HandleConnectRateLimitedAsync; socketConnection.DedicatedRequestConnection = dedicatedRequestConnection; foreach (var ptg in PeriodicTaskRegistrations) @@ -538,6 +539,19 @@ namespace CryptoExchange.Net.Clients { } + /// + /// Process connect rate limited + /// + protected async virtual Task HandleConnectRateLimitedAsync() + { + if (ClientOptions.RateLimiterEnabled && RateLimiter is not null && ClientOptions.ConnectDelayAfterRateLimited is not null) + { + var retryAfter = DateTime.UtcNow.Add(ClientOptions.ConnectDelayAfterRateLimited.Value); + _logger.AddingRetryAfterGuard(retryAfter); + await RateLimiter.SetRetryAfterGuardAsync(retryAfter, RateLimiting.RateLimitItemType.Connection).ConfigureAwait(false); + } + } + /// /// Connect a socket /// diff --git a/CryptoExchange.Net/Interfaces/IWebsocket.cs b/CryptoExchange.Net/Interfaces/IWebsocket.cs index f38f917..b1e75cb 100644 --- a/CryptoExchange.Net/Interfaces/IWebsocket.cs +++ b/CryptoExchange.Net/Interfaces/IWebsocket.cs @@ -27,6 +27,10 @@ namespace CryptoExchange.Net.Interfaces /// event Func? OnRequestRateLimited; /// + /// Connection was ratelimited and couldn't be established + /// + event Func? OnConnectRateLimited; + /// /// Websocket error event /// event Func OnError; diff --git a/CryptoExchange.Net/Logging/Extensions/SocketApiClientLoggingExtension.cs b/CryptoExchange.Net/Logging/Extensions/SocketApiClientLoggingExtension.cs index 3590a17..c8e553a 100644 --- a/CryptoExchange.Net/Logging/Extensions/SocketApiClientLoggingExtension.cs +++ b/CryptoExchange.Net/Logging/Extensions/SocketApiClientLoggingExtension.cs @@ -22,6 +22,7 @@ namespace CryptoExchange.Net.Logging.Extensions private static readonly Action _disposingSocketClient; private static readonly Action _unsubscribingSubscription; private static readonly Action _reconnectingAllConnections; + private static readonly Action _addingRetryAfterGuard; static SocketApiClientLoggingExtension() { @@ -104,6 +105,11 @@ namespace CryptoExchange.Net.Logging.Extensions LogLevel.Information, new EventId(3017, "ReconnectingAll"), "Reconnecting all {ConnectionCount} connections"); + + _addingRetryAfterGuard = LoggerMessage.Define( + LogLevel.Warning, + new EventId(3018, "AddRetryAfterGuard"), + "Adding RetryAfterGuard ({RetryAfter}) because the connection attempt was rate limited"); } public static void FailedToAddSubscriptionRetryOnDifferentConnection(this ILogger logger, int socketId) @@ -185,5 +191,10 @@ namespace CryptoExchange.Net.Logging.Extensions { _reconnectingAllConnections(logger, connectionCount, null); } + + public static void AddingRetryAfterGuard(this ILogger logger, DateTime retryAfter) + { + _addingRetryAfterGuard(logger, retryAfter, null); + } } } diff --git a/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs b/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs index f67200f..5588bea 100644 --- a/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs +++ b/CryptoExchange.Net/Objects/Options/SocketExchangeOptions.cs @@ -47,6 +47,12 @@ namespace CryptoExchange.Net.Objects.Options /// public TimeSpan DelayAfterConnect { get; set; } = TimeSpan.Zero; + /// + /// This delay is used to set a RetryAfter guard on the connection after a rate limit is hit on the server. + /// This is used to prevent the client from reconnecting too quickly after a rate limit is hit. + /// + public TimeSpan? ConnectDelayAfterRateLimited { get; set; } + /// /// Create a copy of this options /// diff --git a/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs b/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs index b09763f..23a9fdc 100644 --- a/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs +++ b/CryptoExchange.Net/RateLimiting/Guards/RetryAfterGuard.cs @@ -21,25 +21,35 @@ namespace CryptoExchange.Net.RateLimiting.Guards public string Name => "RetryAfterGuard"; /// - public string Description => $"Pause requests until after {After}"; + public string Description => $"Pause {Type} until after {After}"; /// /// The timestamp after which requests are allowed again /// public DateTime After { get; private set; } + /// + /// The type of rate limit item this guard is for + /// + public RateLimitItemType Type { get; private set; } + /// /// ctor /// /// - public RetryAfterGuard(DateTime after) + /// + public RetryAfterGuard(DateTime after, RateLimitItemType type) { After = after; + Type = type; } /// public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string host, string? apiKey, int requestWeight) { + if (type != Type) + return LimitCheck.NotApplicable; + var dif = (After + _windowBuffer) - DateTime.UtcNow; if (dif <= TimeSpan.Zero) return LimitCheck.NotApplicable; diff --git a/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs b/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs index eb1d754..536ebd6 100644 --- a/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs +++ b/CryptoExchange.Net/RateLimiting/Interfaces/IRateLimitGate.cs @@ -29,8 +29,9 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces /// Set a RetryAfter guard, can be used when a server rate limit is hit and a RetryAfter header is specified /// /// The time after which requests can be send again + /// RateLimitType /// - Task SetRetryAfterGuardAsync(DateTime retryAfter); + Task SetRetryAfterGuardAsync(DateTime retryAfter, RateLimitItemType type = RateLimitItemType.Request); /// /// Returns the 'retry after' timestamp if set diff --git a/CryptoExchange.Net/RateLimiting/RateLimitGate.cs b/CryptoExchange.Net/RateLimiting/RateLimitGate.cs index ebde779..c73ea67 100644 --- a/CryptoExchange.Net/RateLimiting/RateLimitGate.cs +++ b/CryptoExchange.Net/RateLimiting/RateLimitGate.cs @@ -152,7 +152,7 @@ namespace CryptoExchange.Net.RateLimiting } /// - public async Task SetRetryAfterGuardAsync(DateTime retryAfter) + public async Task SetRetryAfterGuardAsync(DateTime retryAfter, RateLimitItemType type) { await _semaphore.WaitAsync().ConfigureAwait(false); @@ -160,7 +160,7 @@ namespace CryptoExchange.Net.RateLimiting { var retryAfterGuard = _guards.OfType().SingleOrDefault(); if (retryAfterGuard == null) - _guards.Add(new RetryAfterGuard(retryAfter)); + _guards.Add(new RetryAfterGuard(retryAfter, type)); else retryAfterGuard.UpdateAfter(retryAfter); } diff --git a/CryptoExchange.Net/Sockets/CryptoExchangeWebSocketClient.cs b/CryptoExchange.Net/Sockets/CryptoExchangeWebSocketClient.cs index cd96adb..3c0a445 100644 --- a/CryptoExchange.Net/Sockets/CryptoExchangeWebSocketClient.cs +++ b/CryptoExchange.Net/Sockets/CryptoExchangeWebSocketClient.cs @@ -116,6 +116,9 @@ namespace CryptoExchange.Net.Sockets /// public event Func? OnRequestRateLimited; + /// + public event Func? OnConnectRateLimited; + /// public event Func? OnError; @@ -186,6 +189,9 @@ namespace CryptoExchange.Net.Sockets socket.Options.SetBuffer(_receiveBufferSize, _sendBufferSize); if (Parameters.Proxy != null) SetProxy(socket, Parameters.Proxy); + #if NET6_0_OR_GREATER + socket.Options.CollectHttpResponseDetails = true; + #endif } catch (PlatformNotSupportedException) { @@ -220,6 +226,26 @@ namespace CryptoExchange.Net.Sockets // if _ctsSource was canceled this was already logged _logger.SocketConnectionFailed(Id, e.Message, e); } + + if (e is WebSocketException we) + { + #if (NET6_0_OR_GREATER) + if (_socket.HttpStatusCode == HttpStatusCode.TooManyRequests) + { + await (OnConnectRateLimited?.Invoke() ?? Task.CompletedTask).ConfigureAwait(false); + return new CallResult(new ServerRateLimitError(we.Message)); + } + #else + // ClientWebSocket.HttpStatusCode is only available in .NET6+ https://learn.microsoft.com/en-us/dotnet/api/system.net.websockets.clientwebsocket.httpstatuscode?view=net-8.0 + // Try to read 429 from the message instead + if (we.Message.Contains("429")) + { + await (OnConnectRateLimited?.Invoke() ?? Task.CompletedTask).ConfigureAwait(false); + return new CallResult(new ServerRateLimitError(we.Message)); + } + #endif + } + return new CallResult(new CantConnectError()); } diff --git a/CryptoExchange.Net/Sockets/SocketConnection.cs b/CryptoExchange.Net/Sockets/SocketConnection.cs index 20e2928..4018574 100644 --- a/CryptoExchange.Net/Sockets/SocketConnection.cs +++ b/CryptoExchange.Net/Sockets/SocketConnection.cs @@ -71,6 +71,11 @@ namespace CryptoExchange.Net.Sockets /// public event Action? UnhandledMessage; + /// + /// Connection was rate limited and couldn't be established + /// + public Func? ConnectRateLimitedAsync; + /// /// The amount of subscriptions on this connection /// @@ -222,6 +227,7 @@ namespace CryptoExchange.Net.Sockets _socket.OnStreamMessage += HandleStreamMessage; _socket.OnRequestSent += HandleRequestSentAsync; _socket.OnRequestRateLimited += HandleRequestRateLimitedAsync; + _socket.OnConnectRateLimited += HandleConnectRateLimitedAsync; _socket.OnOpen += HandleOpenAsync; _socket.OnClose += HandleCloseAsync; _socket.OnReconnecting += HandleReconnectingAsync; @@ -385,6 +391,16 @@ namespace CryptoExchange.Net.Sockets return Task.CompletedTask; } + /// + /// Handler for whenever a connection was rate limited and couldn't be established + /// + /// + protected async virtual Task HandleConnectRateLimitedAsync() + { + if (ConnectRateLimitedAsync is not null) + await ConnectRateLimitedAsync().ConfigureAwait(false); + } + /// /// Handler for whenever a request is sent over the websocket /// diff --git a/CryptoExchange.Net/Testing/Implementations/TestSocket.cs b/CryptoExchange.Net/Testing/Implementations/TestSocket.cs index f2806e6..b65cfa3 100644 --- a/CryptoExchange.Net/Testing/Implementations/TestSocket.cs +++ b/CryptoExchange.Net/Testing/Implementations/TestSocket.cs @@ -20,6 +20,7 @@ namespace CryptoExchange.Net.Testing.Implementations public event Func? OnReconnected; public event Func? OnReconnecting; public event Func? OnRequestRateLimited; + public event Func? OnConnectRateLimited; public event Func? OnError; #pragma warning restore 0067 public event Func? OnRequestSent;