diff --git a/CryptoExchange.Net.Protobuf/CryptoExchange.Net.Protobuf.csproj b/CryptoExchange.Net.Protobuf/CryptoExchange.Net.Protobuf.csproj index d80f1a1..a299f13 100644 --- a/CryptoExchange.Net.Protobuf/CryptoExchange.Net.Protobuf.csproj +++ b/CryptoExchange.Net.Protobuf/CryptoExchange.Net.Protobuf.csproj @@ -6,9 +6,9 @@ CryptoExchange.Net.Protobuf JKorf Protobuf support for CryptoExchange.Net - 9.5.0 - 9.5.0 - 9.5.0 + 9.6.0 + 9.6.0 + 9.6.0 false CryptoExchange;CryptoExchange.Net git @@ -41,7 +41,7 @@ CryptoExchange.Net.Protobuf.xml - + \ No newline at end of file diff --git a/CryptoExchange.Net.Protobuf/README.md b/CryptoExchange.Net.Protobuf/README.md index 1492551..9fa3bd9 100644 --- a/CryptoExchange.Net.Protobuf/README.md +++ b/CryptoExchange.Net.Protobuf/README.md @@ -5,6 +5,9 @@ Protobuf support for CryptoExchange.Net. ## Release notes +* Version 9.6.0 - 25 Aug 2025 + * Updated CryptoExchange.Net version to 9.6.0 + * Version 9.5.0 - 19 Aug 2025 * Updated CryptoExchange.Net version to 9.5.0 diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscription.cs b/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscription.cs index dd6007b..f9cb121 100644 --- a/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscription.cs +++ b/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscription.cs @@ -28,7 +28,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets return new CallResult(null); } - public override Query GetSubQuery(SocketConnection connection) => new TestQuery("sub", new object(), false, 1); - public override Query GetUnsubQuery() => new TestQuery("unsub", new object(), false, 1); + protected override Query GetSubQuery(SocketConnection connection) => new TestQuery("sub", new object(), false, 1); + protected override Query GetUnsubQuery(SocketConnection connection) => new TestQuery("unsub", new object(), false, 1); } } diff --git a/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscriptionWithResponseCheck.cs b/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscriptionWithResponseCheck.cs index c7a975c..6c1e616 100644 --- a/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscriptionWithResponseCheck.cs +++ b/CryptoExchange.Net.UnitTests/TestImplementations/Sockets/TestSubscriptionWithResponseCheck.cs @@ -28,7 +28,7 @@ namespace CryptoExchange.Net.UnitTests.TestImplementations.Sockets return new CallResult(null); } - public override Query GetSubQuery(SocketConnection connection) => new TestChannelQuery(_channel, "subscribe", false, 1); - public override Query GetUnsubQuery() => new TestChannelQuery(_channel, "unsubscribe", false, 1); + protected override Query GetSubQuery(SocketConnection connection) => new TestChannelQuery(_channel, "subscribe", false, 1); + protected override Query GetUnsubQuery(SocketConnection connection) => new TestChannelQuery(_channel, "unsubscribe", false, 1); } } diff --git a/CryptoExchange.Net/Clients/RestApiClient.cs b/CryptoExchange.Net/Clients/RestApiClient.cs index 88f74e9..aa98487 100644 --- a/CryptoExchange.Net/Clients/RestApiClient.cs +++ b/CryptoExchange.Net/Clients/RestApiClient.cs @@ -18,707 +18,711 @@ using CryptoExchange.Net.RateLimiting.Interfaces; using CryptoExchange.Net.Requests; using Microsoft.Extensions.Logging; -namespace CryptoExchange.Net.Clients; - -/// -/// Base rest API client for interacting with a REST API -/// -public abstract class RestApiClient : BaseApiClient, IRestApiClient +namespace CryptoExchange.Net.Clients { - /// - public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); - - /// - public abstract TimeSyncInfo? GetTimeSyncInfo(); - - /// - public abstract TimeSpan? GetTimeOffset(); - - /// - public int TotalRequestsMade { get; set; } - /// - /// Request body content type + /// Base rest API client for interacting with a REST API /// - protected internal RequestBodyFormat RequestBodyFormat = RequestBodyFormat.Json; - - /// - /// How to serialize array parameters when making requests - /// - protected internal ArrayParametersSerialization ArraySerialization = ArrayParametersSerialization.Array; - - /// - /// What request body should be set when no data is send (only used in combination with postParametersPosition.InBody) - /// - protected internal string RequestBodyEmptyContent = "{}"; - - /// - /// Request headers to be sent with each request - /// - protected Dictionary StandardRequestHeaders { get; set; } = []; - - /// - /// Whether parameters need to be ordered - /// - protected internal bool OrderParameters { get; set; } = true; - - /// - /// Parameter order comparer - /// - protected IComparer ParameterOrderComparer { get; } = new OrderedStringComparer(); - - /// - /// Where to put the parameters for requests with different Http methods - /// - public Dictionary ParameterPositions { get; set; } = new Dictionary + public abstract class RestApiClient : BaseApiClient, IRestApiClient { - { HttpMethod.Get, HttpMethodParameterPosition.InUri }, - { HttpMethod.Post, HttpMethodParameterPosition.InBody }, - { HttpMethod.Delete, HttpMethodParameterPosition.InBody }, - { HttpMethod.Put, HttpMethodParameterPosition.InBody }, - { new HttpMethod("Patch"), HttpMethodParameterPosition.InBody }, - }; + /// + public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); - /// - public new RestExchangeOptions ClientOptions => (RestExchangeOptions)base.ClientOptions; + /// + public abstract TimeSyncInfo? GetTimeSyncInfo(); - /// - public new RestApiOptions ApiOptions => (RestApiOptions)base.ApiOptions; + /// + public abstract TimeSpan? GetTimeOffset(); - /// - /// Memory cache - /// - private readonly static MemoryCache _cache = new MemoryCache(); + /// + public int TotalRequestsMade { get; set; } - /// - /// ctor - /// - /// Logger - /// HttpClient to use - /// Base address for this API client - /// The base client options - /// The Api client options - public RestApiClient(ILogger logger, HttpClient? httpClient, string baseAddress, RestExchangeOptions options, RestApiOptions apiOptions) - : base(logger, - apiOptions.OutputOriginalData ?? options.OutputOriginalData, - apiOptions.ApiCredentials ?? options.ApiCredentials, - baseAddress, - options, - apiOptions) - { - RequestFactory.Configure(options.Proxy, options.RequestTimeout, httpClient); - } + /// + /// Request body content type + /// + protected internal RequestBodyFormat RequestBodyFormat = RequestBodyFormat.Json; - /// - /// Create a message accessor instance - /// - /// - protected abstract IStreamMessageAccessor CreateAccessor(); + /// + /// How to serialize array parameters when making requests + /// + protected internal ArrayParametersSerialization ArraySerialization = ArrayParametersSerialization.Array; - /// - /// Create a serializer instance - /// - /// - protected abstract IMessageSerializer CreateSerializer(); + /// + /// What request body should be set when no data is send (only used in combination with postParametersPosition.InBody) + /// + protected internal string RequestBodyEmptyContent = "{}"; - /// - /// Send a request to the base address based on the request definition - /// - /// Host and schema - /// Request definition - /// Request parameters - /// Cancellation token - /// Additional headers for this request - /// Override the request weight for this request definition, for example when the weight depends on the parameters - /// - protected virtual async Task SendAsync( - string baseAddress, - RequestDefinition definition, - ParameterCollection? parameters, - CancellationToken cancellationToken, - Dictionary? additionalHeaders = null, - int? weight = null) - { - var result = await SendAsync(baseAddress, definition, parameters, cancellationToken, additionalHeaders, weight).ConfigureAwait(false); - return result.AsDataless(); - } + /// + /// Request headers to be sent with each request + /// + protected Dictionary StandardRequestHeaders { get; set; } = []; - /// - /// Send a request to the base address based on the request definition - /// - /// Response type - /// Host and schema - /// Request definition - /// Request parameters - /// Cancellation token - /// Additional headers for this request - /// Override the request weight for this request definition, for example when the weight depends on the parameters - /// Specify the weight to apply to the individual rate limit guard for this request - /// An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters. - /// - protected virtual Task> SendAsync( - string baseAddress, - RequestDefinition definition, - ParameterCollection? parameters, - CancellationToken cancellationToken, - Dictionary? additionalHeaders = null, - int? weight = null, - int? weightSingleLimiter = null, - string? rateLimitKeySuffix = null) - { - var parameterPosition = definition.ParameterPosition ?? ParameterPositions[definition.Method]; - return SendAsync( - baseAddress, - definition, - parameterPosition == HttpMethodParameterPosition.InUri ? parameters : null, - parameterPosition == HttpMethodParameterPosition.InBody ? parameters : null, - cancellationToken, - additionalHeaders, - weight, - weightSingleLimiter, - rateLimitKeySuffix); - } + /// + /// Whether parameters need to be ordered + /// + protected internal bool OrderParameters { get; set; } = true; - /// - /// Send a request to the base address based on the request definition - /// - /// Response type - /// Host and schema - /// Request definition - /// Request query parameters - /// Request body parameters - /// Cancellation token - /// Additional headers for this request - /// Override the request weight for this request definition, for example when the weight depends on the parameters - /// Specify the weight to apply to the individual rate limit guard for this request - /// An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters. - /// - protected virtual async Task> SendAsync( - string baseAddress, - RequestDefinition definition, - ParameterCollection? uriParameters, - ParameterCollection? bodyParameters, - CancellationToken cancellationToken, - Dictionary? additionalHeaders = null, - int? weight = null, - int? weightSingleLimiter = null, - string? rateLimitKeySuffix = null) - { - string? cacheKey = null; - if (ShouldCache(definition)) + /// + /// Parameter order comparer + /// + protected IComparer ParameterOrderComparer { get; } = new OrderedStringComparer(); + + /// + /// Where to put the parameters for requests with different Http methods + /// + public Dictionary ParameterPositions { get; set; } = new Dictionary { - cacheKey = baseAddress + definition + uriParameters?.ToFormData(); - _logger.CheckingCache(cacheKey); - var cachedValue = _cache.Get(cacheKey, ClientOptions.CachingMaxAge); - if (cachedValue != null) - { - _logger.CacheHit(cacheKey); - var original = (WebCallResult)cachedValue; - return original.Cached(); - } + { HttpMethod.Get, HttpMethodParameterPosition.InUri }, + { HttpMethod.Post, HttpMethodParameterPosition.InBody }, + { HttpMethod.Delete, HttpMethodParameterPosition.InBody }, + { HttpMethod.Put, HttpMethodParameterPosition.InBody }, + { new HttpMethod("Patch"), HttpMethodParameterPosition.InBody }, + }; - _logger.CacheNotHit(cacheKey); + /// + public new RestExchangeOptions ClientOptions => (RestExchangeOptions)base.ClientOptions; + + /// + public new RestApiOptions ApiOptions => (RestApiOptions)base.ApiOptions; + + /// + /// Memory cache + /// + private readonly static MemoryCache _cache = new MemoryCache(); + + /// + /// ctor + /// + /// Logger + /// HttpClient to use + /// Base address for this API client + /// The base client options + /// The Api client options + public RestApiClient(ILogger logger, HttpClient? httpClient, string baseAddress, RestExchangeOptions options, RestApiOptions apiOptions) + : base(logger, + apiOptions.OutputOriginalData ?? options.OutputOriginalData, + apiOptions.ApiCredentials ?? options.ApiCredentials, + baseAddress, + options, + apiOptions) + { + RequestFactory.Configure(options.Proxy, options.RequestTimeout, httpClient); } - int currentTry = 0; - while (true) + /// + /// Create a message accessor instance + /// + /// + protected abstract IStreamMessageAccessor CreateAccessor(); + + /// + /// Create a serializer instance + /// + /// + protected abstract IMessageSerializer CreateSerializer(); + + /// + /// Send a request to the base address based on the request definition + /// + /// Host and schema + /// Request definition + /// Request parameters + /// Cancellation token + /// Additional headers for this request + /// Override the request weight for this request definition, for example when the weight depends on the parameters + /// + protected virtual async Task SendAsync( + string baseAddress, + RequestDefinition definition, + ParameterCollection? parameters, + CancellationToken cancellationToken, + Dictionary? additionalHeaders = null, + int? weight = null) + { + var result = await SendAsync(baseAddress, definition, parameters, cancellationToken, additionalHeaders, weight).ConfigureAwait(false); + return result.AsDataless(); + } + + /// + /// Send a request to the base address based on the request definition + /// + /// Response type + /// Host and schema + /// Request definition + /// Request parameters + /// Cancellation token + /// Additional headers for this request + /// Override the request weight for this request definition, for example when the weight depends on the parameters + /// Specify the weight to apply to the individual rate limit guard for this request + /// An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters. + /// + protected virtual Task> SendAsync( + string baseAddress, + RequestDefinition definition, + ParameterCollection? parameters, + CancellationToken cancellationToken, + Dictionary? additionalHeaders = null, + int? weight = null, + int? weightSingleLimiter = null, + string? rateLimitKeySuffix = null) { - currentTry++; - var requestId = ExchangeHelpers.NextId(); - - var prepareResult = await PrepareAsync(requestId, baseAddress, definition, cancellationToken, additionalHeaders, weight, weightSingleLimiter, rateLimitKeySuffix).ConfigureAwait(false); - if (!prepareResult) - return new WebCallResult(prepareResult.Error!); - - var request = CreateRequest( - requestId, + var parameterPosition = definition.ParameterPosition ?? ParameterPositions[definition.Method]; + return SendAsync( baseAddress, definition, - uriParameters, - bodyParameters, - additionalHeaders); - _logger.RestApiSendRequest(request.RequestId, definition, request.Content, string.IsNullOrEmpty(request.Uri.Query) ? "-" : request.Uri.Query, string.Join(", ", request.GetHeaders().Select(h => h.Key + $"=[{string.Join(",", h.Value)}]"))); - TotalRequestsMade++; - var result = await GetResponseAsync(request, definition.RateLimitGate, cancellationToken).ConfigureAwait(false); - if (result.Error is not CancellationRequestedError) - { - var originalData = OutputOriginalData ? result.OriginalData : "[Data only available when OutputOriginal = true]"; - if (!result) - _logger.RestApiErrorReceived(result.RequestId, result.ResponseStatusCode, (long)Math.Floor(result.ResponseTime!.Value.TotalMilliseconds), result.Error?.ToString(), originalData, result.Error?.Exception); - else - _logger.RestApiResponseReceived(result.RequestId, result.ResponseStatusCode, (long)Math.Floor(result.ResponseTime!.Value.TotalMilliseconds), originalData); - } - else - { - _logger.RestApiCancellationRequested(result.RequestId); - } - - if (await ShouldRetryRequestAsync(definition.RateLimitGate, result, currentTry).ConfigureAwait(false)) - continue; - - if (result.Success && - ShouldCache(definition)) - { - _cache.Add(cacheKey!, result); - } - - return result; + parameterPosition == HttpMethodParameterPosition.InUri ? parameters : null, + parameterPosition == HttpMethodParameterPosition.InBody ? parameters : null, + cancellationToken, + additionalHeaders, + weight, + weightSingleLimiter, + rateLimitKeySuffix); } - } - /// - /// Prepare before sending a request. Sync time between client and server and check rate limits - /// - /// Request id - /// Host and schema - /// Request definition - /// Cancellation token - /// Additional headers for this request - /// Override the request weight for this request - /// Specify the weight to apply to the individual rate limit guard for this request - /// An additional optional suffix for the key selector - /// - /// - protected virtual async Task PrepareAsync( - int requestId, - string baseAddress, - RequestDefinition definition, - CancellationToken cancellationToken, - Dictionary? additionalHeaders = null, - int? weight = null, - int? weightSingleLimiter = null, - string? rateLimitKeySuffix = null) - { - // Time sync - if (definition.Authenticated) + /// + /// Send a request to the base address based on the request definition + /// + /// Response type + /// Host and schema + /// Request definition + /// Request query parameters + /// Request body parameters + /// Cancellation token + /// Additional headers for this request + /// Override the request weight for this request definition, for example when the weight depends on the parameters + /// Specify the weight to apply to the individual rate limit guard for this request + /// An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters. + /// + protected virtual async Task> SendAsync( + string baseAddress, + RequestDefinition definition, + ParameterCollection? uriParameters, + ParameterCollection? bodyParameters, + CancellationToken cancellationToken, + Dictionary? additionalHeaders = null, + int? weight = null, + int? weightSingleLimiter = null, + string? rateLimitKeySuffix = null) { - if (AuthenticationProvider == null) + string? cacheKey = null; + if (ShouldCache(definition)) { - _logger.RestApiNoApiCredentials(requestId, definition.Path); - return new CallResult(new NoApiCredentialsError()); - } - - var syncTask = SyncTimeAsync(); - var timeSyncInfo = GetTimeSyncInfo(); - - if (timeSyncInfo != null && timeSyncInfo.TimeSyncState.LastSyncTime == default) - { - // Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue - var syncTimeResult = await syncTask.ConfigureAwait(false); - if (!syncTimeResult) + cacheKey = baseAddress + definition + uriParameters?.ToFormData(); + _logger.CheckingCache(cacheKey); + var cachedValue = _cache.Get(cacheKey, ClientOptions.CachingMaxAge); + if (cachedValue != null) { - _logger.RestApiFailedToSyncTime(requestId, syncTimeResult.Error!.ToString()); - return syncTimeResult.AsDataless(); + _logger.CacheHit(cacheKey); + var original = (WebCallResult)cachedValue; + return original.Cached(); } + + _logger.CacheNotHit(cacheKey); } - } - // Rate limiting - var requestWeight = weight ?? definition.Weight; - if (requestWeight != 0) - { - if (definition.RateLimitGate == null) - throw new Exception("Ratelimit gate not set when request weight is not 0"); - - if (ClientOptions.RateLimiterEnabled) + int currentTry = 0; + while (true) { - var limitResult = await definition.RateLimitGate.ProcessAsync(_logger, requestId, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, requestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false); - if (!limitResult) - return new CallResult(limitResult.Error!); - } - } + currentTry++; + var requestId = ExchangeHelpers.NextId(); - // Endpoint specific rate limiting - if (definition.LimitGuard != null && ClientOptions.RateLimiterEnabled) - { - if (definition.RateLimitGate == null) - throw new Exception("Ratelimit gate not set when endpoint limit is specified"); + var prepareResult = await PrepareAsync(requestId, baseAddress, definition, cancellationToken, additionalHeaders, weight, weightSingleLimiter, rateLimitKeySuffix).ConfigureAwait(false); + if (!prepareResult) + return new WebCallResult(prepareResult.Error!); - if (ClientOptions.RateLimiterEnabled) - { - var singleRequestWeight = weightSingleLimiter ?? 1; - var limitResult = await definition.RateLimitGate.ProcessSingleAsync(_logger, requestId, definition.LimitGuard, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, singleRequestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false); - if (!limitResult) - return new CallResult(limitResult.Error!); - } - } - - return CallResult.SuccessResult; - } - - /// - /// Creates a request object - /// - /// Id of the request - /// Host and schema - /// Request definition - /// The query parameters of the request - /// The body parameters of the request - /// Additional headers to send with the request - /// - protected virtual IRequest CreateRequest( - int requestId, - string baseAddress, - RequestDefinition definition, - ParameterCollection? uriParameters, - ParameterCollection? bodyParameters, - Dictionary? additionalHeaders) - { - var requestConfiguration = new RestRequestConfiguration( - definition, - baseAddress, - uriParameters == null ? new Dictionary() : CreateParameterDictionary(uriParameters), - bodyParameters == null ? new Dictionary() : CreateParameterDictionary(bodyParameters), - new Dictionary(additionalHeaders ?? []), - definition.ArraySerialization ?? ArraySerialization, - definition.ParameterPosition ?? ParameterPositions[definition.Method], - definition.RequestBodyFormat ?? RequestBodyFormat); - - try - { - AuthenticationProvider?.ProcessRequest(this, requestConfiguration); - } - catch (Exception ex) - { - throw new Exception("Failed to authenticate request, make sure your API credentials are correct", ex); - } - - var queryString = requestConfiguration.GetQueryString(true); - if (!string.IsNullOrEmpty(queryString) && !queryString.StartsWith("?")) - queryString = $"?{queryString}"; - - var uri = new Uri(baseAddress.AppendPath(definition.Path) + queryString); - var request = RequestFactory.Create(definition.Method, uri, requestId); - request.Accept = Constants.JsonContentHeader; - - foreach (var header in requestConfiguration.Headers) - request.AddHeader(header.Key, header.Value); - - foreach (var header in StandardRequestHeaders) - { - // Only add it if it isn't overwritten - if (!requestConfiguration.Headers.ContainsKey(header.Key)) - request.AddHeader(header.Key, header.Value); - } - - if (requestConfiguration.ParameterPosition == HttpMethodParameterPosition.InBody) - { - var contentType = requestConfiguration.BodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader; - var bodyContent = requestConfiguration.GetBodyContent(); - if (bodyContent != null) - { - request.SetContent(bodyContent, contentType); - } - else - { - if (requestConfiguration.BodyParameters != null && requestConfiguration.BodyParameters.Count != 0) - WriteParamBody(request, requestConfiguration.BodyParameters, contentType); - else - request.SetContent(RequestBodyEmptyContent, contentType); - } - } - - return request; - } - - /// - /// Executes the request and returns the result deserialized into the type parameter class - /// - /// The request object to execute - /// The ratelimit gate used - /// Cancellation token - /// - protected virtual async Task> GetResponseAsync( - IRequest request, - IRateLimitGate? gate, - CancellationToken cancellationToken) - { - var sw = Stopwatch.StartNew(); - Stream? responseStream = null; - IResponse? response = null; - IStreamMessageAccessor? accessor = null; - try - { - response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false); - sw.Stop(); - var statusCode = response.StatusCode; - var headers = response.ResponseHeaders; - var responseLength = response.ContentLength; - responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false); - var outputOriginalData = ApiOptions.OutputOriginalData ?? ClientOptions.OutputOriginalData; - - accessor = CreateAccessor(); - if (!response.IsSuccessStatusCode) - { - // Error response - var readResult = await accessor.Read(responseStream, true).ConfigureAwait(false); - - Error error; - if (response.StatusCode == (HttpStatusCode)418 || response.StatusCode == (HttpStatusCode)429) + var request = CreateRequest( + requestId, + baseAddress, + definition, + uriParameters, + bodyParameters, + additionalHeaders); + _logger.RestApiSendRequest(request.RequestId, definition, request.Content, string.IsNullOrEmpty(request.Uri.Query) ? "-" : request.Uri.Query, string.Join(", ", request.GetHeaders().Select(h => h.Key + $"=[{string.Join(",", h.Value)}]"))); + TotalRequestsMade++; + var result = await GetResponseAsync(definition, request, definition.RateLimitGate, cancellationToken).ConfigureAwait(false); + if (result.Error is not CancellationRequestedError) { - var rateError = ParseRateLimitResponse((int)response.StatusCode, response.ResponseHeaders, accessor); - if (rateError.RetryAfter != null && gate != null && ClientOptions.RateLimiterEnabled) + var originalData = OutputOriginalData ? result.OriginalData : "[Data only available when OutputOriginal = true]"; + if (!result) + _logger.RestApiErrorReceived(result.RequestId, result.ResponseStatusCode, (long)Math.Floor(result.ResponseTime!.Value.TotalMilliseconds), result.Error?.ToString(), originalData, result.Error?.Exception); + else + _logger.RestApiResponseReceived(result.RequestId, result.ResponseStatusCode, (long)Math.Floor(result.ResponseTime!.Value.TotalMilliseconds), originalData); + } + else + { + _logger.RestApiCancellationRequested(result.RequestId); + } + + if (await ShouldRetryRequestAsync(definition.RateLimitGate, result, currentTry).ConfigureAwait(false)) + continue; + + if (result.Success && + ShouldCache(definition)) + { + _cache.Add(cacheKey!, result); + } + + return result; + } + } + + /// + /// Prepare before sending a request. Sync time between client and server and check rate limits + /// + /// Request id + /// Host and schema + /// Request definition + /// Cancellation token + /// Additional headers for this request + /// Override the request weight for this request + /// Specify the weight to apply to the individual rate limit guard for this request + /// An additional optional suffix for the key selector + /// + /// + protected virtual async Task PrepareAsync( + int requestId, + string baseAddress, + RequestDefinition definition, + CancellationToken cancellationToken, + Dictionary? additionalHeaders = null, + int? weight = null, + int? weightSingleLimiter = null, + string? rateLimitKeySuffix = null) + { + // Time sync + if (definition.Authenticated) + { + if (AuthenticationProvider == null) + { + _logger.RestApiNoApiCredentials(requestId, definition.Path); + return new CallResult(new NoApiCredentialsError()); + } + + var syncTask = SyncTimeAsync(); + var timeSyncInfo = GetTimeSyncInfo(); + + if (timeSyncInfo != null && timeSyncInfo.TimeSyncState.LastSyncTime == default) + { + // Initially with first request we'll need to wait for the time syncing, if it's not the first request we can just continue + var syncTimeResult = await syncTask.ConfigureAwait(false); + if (!syncTimeResult) { - _logger.RestApiRateLimitPauseUntil(request.RequestId, rateError.RetryAfter.Value); - await gate.SetRetryAfterGuardAsync(rateError.RetryAfter.Value).ConfigureAwait(false); - } - - error = rateError; - } - else - { - error = ParseErrorResponse((int)response.StatusCode, response.ResponseHeaders, accessor, readResult.Error?.Exception); - } - - if (error.Code == null || error.Code == 0) - error.Code = (int)response.StatusCode; - - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error!); - } - - var valid = await accessor.Read(responseStream, outputOriginalData).ConfigureAwait(false); - if (typeof(T) == typeof(object)) - // Success status code and expected empty response, assume it's correct - return new WebCallResult(statusCode, headers, sw.Elapsed, 0, accessor.OriginalDataAvailable ? accessor.GetOriginalString() : "[Data only available when OutputOriginal = true in client options]", request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, null); - - if (!valid) - { - // Invalid json - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, valid.Error); - } - - // Json response received - var parsedError = TryParseError(response.ResponseHeaders, accessor); - if (parsedError != null) - { - if (parsedError is ServerRateLimitError rateError) - { - if (rateError.RetryAfter != null && gate != null && ClientOptions.RateLimiterEnabled) - { - _logger.RestApiRateLimitPauseUntil(request.RequestId, rateError.RetryAfter.Value); - await gate.SetRetryAfterGuardAsync(rateError.RetryAfter.Value).ConfigureAwait(false); + _logger.RestApiFailedToSyncTime(requestId, syncTimeResult.Error!.ToString()); + return syncTimeResult.AsDataless(); } } + } - // Success status code, but TryParseError determined it was an error response - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, parsedError); + // Rate limiting + var requestWeight = weight ?? definition.Weight; + if (requestWeight != 0) + { + if (definition.RateLimitGate == null) + throw new Exception("Ratelimit gate not set when request weight is not 0"); + + if (ClientOptions.RateLimiterEnabled) + { + var limitResult = await definition.RateLimitGate.ProcessAsync(_logger, requestId, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, requestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false); + if (!limitResult) + return new CallResult(limitResult.Error!); + } } - var deserializeResult = accessor.Deserialize(); - return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult.Data, deserializeResult.Error); - } - catch (HttpRequestException requestException) - { - // Request exception, can't reach server for instance - var error = new WebError(requestException.Message, requestException); - return new WebCallResult(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error); - } - catch (OperationCanceledException canceledException) - { - if (cancellationToken != default && canceledException.CancellationToken == cancellationToken) + // Endpoint specific rate limiting + if (definition.LimitGuard != null && ClientOptions.RateLimiterEnabled) { - // Cancellation token canceled by caller - return new WebCallResult(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new CancellationRequestedError(canceledException)); + if (definition.RateLimitGate == null) + throw new Exception("Ratelimit gate not set when endpoint limit is specified"); + + if (ClientOptions.RateLimiterEnabled) + { + var singleRequestWeight = weightSingleLimiter ?? 1; + var limitResult = await definition.RateLimitGate.ProcessSingleAsync(_logger, requestId, definition.LimitGuard, RateLimitItemType.Request, definition, baseAddress, AuthenticationProvider?._credentials.Key, singleRequestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false); + if (!limitResult) + return new CallResult(limitResult.Error!); + } } - else + + return CallResult.SuccessResult; + } + + /// + /// Creates a request object + /// + /// Id of the request + /// Host and schema + /// Request definition + /// The query parameters of the request + /// The body parameters of the request + /// Additional headers to send with the request + /// + protected virtual IRequest CreateRequest( + int requestId, + string baseAddress, + RequestDefinition definition, + ParameterCollection? uriParameters, + ParameterCollection? bodyParameters, + Dictionary? additionalHeaders) + { + var requestConfiguration = new RestRequestConfiguration( + definition, + baseAddress, + uriParameters == null ? new Dictionary() : CreateParameterDictionary(uriParameters), + bodyParameters == null ? new Dictionary() : CreateParameterDictionary(bodyParameters), + new Dictionary(additionalHeaders ?? []), + definition.ArraySerialization ?? ArraySerialization, + definition.ParameterPosition ?? ParameterPositions[definition.Method], + definition.RequestBodyFormat ?? RequestBodyFormat); + + try { - // Request timed out - var error = new WebError($"Request timed out", exception: canceledException); - error.ErrorType = ErrorType.Timeout; + AuthenticationProvider?.ProcessRequest(this, requestConfiguration); + } + catch (Exception ex) + { + throw new Exception("Failed to authenticate request, make sure your API credentials are correct", ex); + } + + var queryString = requestConfiguration.GetQueryString(true); + if (!string.IsNullOrEmpty(queryString) && !queryString.StartsWith("?")) + queryString = $"?{queryString}"; + + var uri = new Uri(baseAddress.AppendPath(definition.Path) + queryString); + var request = RequestFactory.Create(definition.Method, uri, requestId); + request.Accept = Constants.JsonContentHeader; + + foreach (var header in requestConfiguration.Headers) + request.AddHeader(header.Key, header.Value); + + foreach (var header in StandardRequestHeaders) + { + // Only add it if it isn't overwritten + if (!requestConfiguration.Headers.ContainsKey(header.Key)) + request.AddHeader(header.Key, header.Value); + } + + if (requestConfiguration.ParameterPosition == HttpMethodParameterPosition.InBody) + { + var contentType = requestConfiguration.BodyFormat == RequestBodyFormat.Json ? Constants.JsonContentHeader : Constants.FormContentHeader; + var bodyContent = requestConfiguration.GetBodyContent(); + if (bodyContent != null) + { + request.SetContent(bodyContent, contentType); + } + else + { + if (requestConfiguration.BodyParameters != null && requestConfiguration.BodyParameters.Count != 0) + WriteParamBody(request, requestConfiguration.BodyParameters, contentType); + else + request.SetContent(RequestBodyEmptyContent, contentType); + } + } + + return request; + } + + /// + /// Executes the request and returns the result deserialized into the type parameter class + /// + /// The request definition + /// The request object to execute + /// The ratelimit gate used + /// Cancellation token + /// + protected virtual async Task> GetResponseAsync( + RequestDefinition requestDefinition, + IRequest request, + IRateLimitGate? gate, + CancellationToken cancellationToken) + { + var sw = Stopwatch.StartNew(); + Stream? responseStream = null; + IResponse? response = null; + IStreamMessageAccessor? accessor = null; + try + { + response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false); + sw.Stop(); + var statusCode = response.StatusCode; + var headers = response.ResponseHeaders; + var responseLength = response.ContentLength; + responseStream = await response.GetResponseStreamAsync().ConfigureAwait(false); + var outputOriginalData = ApiOptions.OutputOriginalData ?? ClientOptions.OutputOriginalData; + + accessor = CreateAccessor(); + if (!response.IsSuccessStatusCode && !requestDefinition.TryParseOnNonSuccess) + { + // Error response + var readResult = await accessor.Read(responseStream, true).ConfigureAwait(false); + + Error error; + if (response.StatusCode == (HttpStatusCode)418 || response.StatusCode == (HttpStatusCode)429) + { + var rateError = ParseRateLimitResponse((int)response.StatusCode, response.ResponseHeaders, accessor); + if (rateError.RetryAfter != null && gate != null && ClientOptions.RateLimiterEnabled) + { + _logger.RestApiRateLimitPauseUntil(request.RequestId, rateError.RetryAfter.Value); + await gate.SetRetryAfterGuardAsync(rateError.RetryAfter.Value).ConfigureAwait(false); + } + + error = rateError; + } + else + { + error = ParseErrorResponse((int)response.StatusCode, response.ResponseHeaders, accessor, readResult.Error?.Exception); + } + + if (error.Code == null || error.Code == 0) + error.Code = (int)response.StatusCode; + + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error!); + } + + var valid = await accessor.Read(responseStream, outputOriginalData).ConfigureAwait(false); + if (typeof(T) == typeof(object)) + // Success status code and expected empty response, assume it's correct + return new WebCallResult(statusCode, headers, sw.Elapsed, 0, accessor.OriginalDataAvailable ? accessor.GetOriginalString() : "[Data only available when OutputOriginal = true in client options]", request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, null); + + if (!valid) + { + // Invalid json + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, valid.Error); + } + + // Json response received + var parsedError = TryParseError(requestDefinition, response.ResponseHeaders, accessor); + if (parsedError != null) + { + if (parsedError is ServerRateLimitError rateError) + { + if (rateError.RetryAfter != null && gate != null && ClientOptions.RateLimiterEnabled) + { + _logger.RestApiRateLimitPauseUntil(request.RequestId, rateError.RetryAfter.Value); + await gate.SetRetryAfterGuardAsync(rateError.RetryAfter.Value).ConfigureAwait(false); + } + } + + // Success status code, but TryParseError determined it was an error response + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, parsedError); + } + + var deserializeResult = accessor.Deserialize(); + return new WebCallResult(response.StatusCode, response.ResponseHeaders, sw.Elapsed, responseLength, OutputOriginalData ? accessor.GetOriginalString() : null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult.Data, deserializeResult.Error); + } + catch (HttpRequestException requestException) + { + // Request exception, can't reach server for instance + var error = new WebError(requestException.Message, requestException); return new WebCallResult(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error); } + catch (OperationCanceledException canceledException) + { + if (cancellationToken != default && canceledException.CancellationToken == cancellationToken) + { + // Cancellation token canceled by caller + return new WebCallResult(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, new CancellationRequestedError(canceledException)); + } + else + { + // Request timed out + var error = new WebError($"Request timed out", exception: canceledException); + error.ErrorType = ErrorType.Timeout; + return new WebCallResult(null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error); + } + } + finally + { + accessor?.Clear(); + responseStream?.Close(); + response?.Close(); + } } - finally + + /// + /// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error. + /// This method will be called for each response to be able to check if the response is an error or not. + /// If the response is an error this method should return the parsed error, else it should return null + /// + /// Request definition + /// Data accessor + /// The response headers + /// Null if not an error, Error otherwise + protected virtual Error? TryParseError(RequestDefinition requestDefinition, KeyValuePair[] responseHeaders, IMessageAccessor accessor) => null; + + /// + /// Can be used to indicate that a request should be retried. Defaults to false. Make sure to retry a max number of times (based on the the tries parameter) or the request will retry forever. + /// Note that this is always called; even when the request might be successful + /// + /// WebCallResult type parameter + /// The rate limit gate the call used + /// The result of the call + /// The current try number + /// True if call should retry, false if the call should return + protected virtual async Task ShouldRetryRequestAsync(IRateLimitGate? gate, WebCallResult callResult, int tries) { - accessor?.Clear(); - responseStream?.Close(); - response?.Close(); - } - } - - /// - /// Can be used to parse an error even though response status indicates success. Some apis always return 200 OK, even though there is an error. - /// This method will be called for each response to be able to check if the response is an error or not. - /// If the response is an error this method should return the parsed error, else it should return null - /// - /// Data accessor - /// The response headers - /// Null if not an error, Error otherwise - protected virtual Error? TryParseError(KeyValuePair[] responseHeaders, IMessageAccessor accessor) => null; - - /// - /// Can be used to indicate that a request should be retried. Defaults to false. Make sure to retry a max number of times (based on the the tries parameter) or the request will retry forever. - /// Note that this is always called; even when the request might be successful - /// - /// WebCallResult type parameter - /// The rate limit gate the call used - /// The result of the call - /// The current try number - /// True if call should retry, false if the call should return - protected virtual async Task ShouldRetryRequestAsync(IRateLimitGate? gate, WebCallResult callResult, int tries) - { - if (tries >= 2) - // Only retry once - return false; - - if (callResult.Error is ServerRateLimitError - && ClientOptions.RateLimiterEnabled - && ClientOptions.RateLimitingBehaviour != RateLimitingBehaviour.Fail - && gate != null) - { - var retryTime = await gate.GetRetryAfterTime().ConfigureAwait(false); - if (retryTime == null) + if (tries >= 2) + // Only retry once return false; - if (retryTime.Value - DateTime.UtcNow < TimeSpan.FromSeconds(60)) + if (callResult.Error is ServerRateLimitError + && ClientOptions.RateLimiterEnabled + && ClientOptions.RateLimitingBehaviour != RateLimitingBehaviour.Fail + && gate != null) { - _logger.RestApiRateLimitRetry(callResult.RequestId!.Value, retryTime.Value); - return true; + var retryTime = await gate.GetRetryAfterTime().ConfigureAwait(false); + if (retryTime == null) + return false; + + if (retryTime.Value - DateTime.UtcNow < TimeSpan.FromSeconds(60)) + { + _logger.RestApiRateLimitRetry(callResult.RequestId!.Value, retryTime.Value); + return true; + } + } + + return false; + } + + /// + /// Writes the parameters of the request to the request object body + /// + /// The request to set the parameters on + /// The parameters to set + /// The content type of the data + protected virtual void WriteParamBody(IRequest request, IDictionary parameters, string contentType) + { + if (contentType == Constants.JsonContentHeader) + { + var serializer = CreateSerializer(); + if (serializer is not IStringMessageSerializer stringSerializer) + throw new InvalidOperationException("Non-string message serializer can't get serialized request body"); + + // Write the parameters as json in the body + string stringData; + if (parameters.Count == 1 && parameters.TryGetValue(Constants.BodyPlaceHolderKey, out object? value)) + stringData = stringSerializer.Serialize(value); + else + stringData = stringSerializer.Serialize(parameters); + request.SetContent(stringData, contentType); + } + else if (contentType == Constants.FormContentHeader) + { + // Write the parameters as form data in the body + var stringData = parameters.ToFormData(); + request.SetContent(stringData, contentType); } } - return false; - } - - /// - /// Writes the parameters of the request to the request object body - /// - /// The request to set the parameters on - /// The parameters to set - /// The content type of the data - protected virtual void WriteParamBody(IRequest request, IDictionary parameters, string contentType) - { - if (contentType == Constants.JsonContentHeader) + /// + /// Parse an error response from the server. Only used when server returns a status other than Success(200) or ratelimit error (429 or 418) + /// + /// The response status code + /// The response headers + /// Data accessor + /// Exception + /// + protected virtual Error ParseErrorResponse(int httpStatusCode, KeyValuePair[] responseHeaders, IMessageAccessor accessor, Exception? exception) { - var serializer = CreateSerializer(); - if (serializer is not IStringMessageSerializer stringSerializer) - throw new InvalidOperationException("Non-string message serializer can't get serialized request body"); - - // Write the parameters as json in the body - string stringData; - if (parameters.Count == 1 && parameters.TryGetValue(Constants.BodyPlaceHolderKey, out object? value)) - stringData = stringSerializer.Serialize(value); - else - stringData = stringSerializer.Serialize(parameters); - request.SetContent(stringData, contentType); + return new ServerError(ErrorInfo.Unknown, exception); } - else if (contentType == Constants.FormContentHeader) + + /// + /// Parse a rate limit error response from the server. Only used when server returns http status 429 or 418 + /// + /// The response status code + /// The response headers + /// Data accessor + /// + protected virtual ServerRateLimitError ParseRateLimitResponse(int httpStatusCode, KeyValuePair[] responseHeaders, IMessageAccessor accessor) { - // Write the parameters as form data in the body - var stringData = parameters.ToFormData(); - request.SetContent(stringData, contentType); - } - } + // Handle retry after header + var retryAfterHeader = responseHeaders.SingleOrDefault(r => r.Key.Equals("Retry-After", StringComparison.InvariantCultureIgnoreCase)); + if (retryAfterHeader.Value?.Any() != true) + return new ServerRateLimitError(); - /// - /// Parse an error response from the server. Only used when server returns a status other than Success(200) or ratelimit error (429 or 418) - /// - /// The response status code - /// The response headers - /// Data accessor - /// Exception - /// - protected virtual Error ParseErrorResponse(int httpStatusCode, KeyValuePair[] responseHeaders, IMessageAccessor accessor, Exception? exception) - { - return new ServerError(ErrorInfo.Unknown, exception); - } + var value = retryAfterHeader.Value.First(); + if (int.TryParse(value, out var seconds)) + return new ServerRateLimitError() { RetryAfter = DateTime.UtcNow.AddSeconds(seconds) }; + + if (DateTime.TryParse(value, out var datetime)) + return new ServerRateLimitError() { RetryAfter = datetime }; - /// - /// Parse a rate limit error response from the server. Only used when server returns http status 429 or 418 - /// - /// The response status code - /// The response headers - /// Data accessor - /// - protected virtual ServerRateLimitError ParseRateLimitResponse(int httpStatusCode, KeyValuePair[] responseHeaders, IMessageAccessor accessor) - { - // Handle retry after header - var retryAfterHeader = responseHeaders.SingleOrDefault(r => r.Key.Equals("Retry-After", StringComparison.InvariantCultureIgnoreCase)); - if (retryAfterHeader.Value?.Any() != true) return new ServerRateLimitError(); + } - var value = retryAfterHeader.Value.First(); - if (int.TryParse(value, out var seconds)) - return new ServerRateLimitError() { RetryAfter = DateTime.UtcNow.AddSeconds(seconds) }; - - if (DateTime.TryParse(value, out var datetime)) - return new ServerRateLimitError() { RetryAfter = datetime }; - - return new ServerRateLimitError(); - } - - /// - /// Create the parameter IDictionary - /// - /// - /// - protected internal IDictionary CreateParameterDictionary(IDictionary parameters) - { - if (!OrderParameters) - return parameters; - - return new SortedDictionary(parameters, ParameterOrderComparer); - } - - /// - /// Retrieve the server time for the purpose of syncing time between client and server to prevent authentication issues - /// - /// Server time - protected virtual Task> GetServerTimestampAsync() => throw new NotImplementedException(); - - /// - public override void SetOptions(UpdateOptions options) - { - base.SetOptions(options); - - RequestFactory.UpdateSettings(options.Proxy, options.RequestTimeout ?? ClientOptions.RequestTimeout); - } - - internal async Task> SyncTimeAsync() - { - var timeSyncParams = GetTimeSyncInfo(); - if (timeSyncParams == null) - return new WebCallResult(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null); - - if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false)) + /// + /// Create the parameter IDictionary + /// + /// + /// + protected internal IDictionary CreateParameterDictionary(IDictionary parameters) { - if (!timeSyncParams.SyncTime || DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval) - { - timeSyncParams.TimeSyncState.Semaphore.Release(); + if (!OrderParameters) + return parameters; + + return new SortedDictionary(parameters, ParameterOrderComparer); + } + + /// + /// Retrieve the server time for the purpose of syncing time between client and server to prevent authentication issues + /// + /// Server time + protected virtual Task> GetServerTimestampAsync() => throw new NotImplementedException(); + + /// + public override void SetOptions(UpdateOptions options) + { + base.SetOptions(options); + + RequestFactory.UpdateSettings(options.Proxy, options.RequestTimeout ?? ClientOptions.RequestTimeout); + } + + internal async Task> SyncTimeAsync() + { + var timeSyncParams = GetTimeSyncInfo(); + if (timeSyncParams == null) return new WebCallResult(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null); - } - var localTime = DateTime.UtcNow; - var result = await GetServerTimestampAsync().ConfigureAwait(false); - if (!result) + if (await timeSyncParams.TimeSyncState.Semaphore.WaitAsync(0).ConfigureAwait(false)) { - timeSyncParams.TimeSyncState.Semaphore.Release(); - return result.As(false); - } + if (!timeSyncParams.SyncTime || DateTime.UtcNow - timeSyncParams.TimeSyncState.LastSyncTime < timeSyncParams.RecalculationInterval) + { + timeSyncParams.TimeSyncState.Semaphore.Release(); + return new WebCallResult(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null); + } - if (TotalRequestsMade == 1) - { - // If this was the first request make another one to calculate the offset since the first one can be slower - localTime = DateTime.UtcNow; - result = await GetServerTimestampAsync().ConfigureAwait(false); + var localTime = DateTime.UtcNow; + var result = await GetServerTimestampAsync().ConfigureAwait(false); if (!result) { timeSyncParams.TimeSyncState.Semaphore.Release(); return result.As(false); } + + if (TotalRequestsMade == 1) + { + // If this was the first request make another one to calculate the offset since the first one can be slower + localTime = DateTime.UtcNow; + result = await GetServerTimestampAsync().ConfigureAwait(false); + if (!result) + { + timeSyncParams.TimeSyncState.Semaphore.Release(); + return result.As(false); + } + } + + // Calculate time offset between local and server + var offset = result.Data - localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2); + timeSyncParams.UpdateTimeOffset(offset); + timeSyncParams.TimeSyncState.Semaphore.Release(); } - // Calculate time offset between local and server - var offset = result.Data - localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2); - timeSyncParams.UpdateTimeOffset(offset); - timeSyncParams.TimeSyncState.Semaphore.Release(); + return new WebCallResult(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null); } - return new WebCallResult(null, null, null, null, null, null, null, null, null, null, ResultDataSource.Server, true, null); + private bool ShouldCache(RequestDefinition definition) + => ClientOptions.CachingEnabled + && definition.Method == HttpMethod.Get + && !definition.PreventCaching; } - - private bool ShouldCache(RequestDefinition definition) - => ClientOptions.CachingEnabled - && definition.Method == HttpMethod.Get - && !definition.PreventCaching; } diff --git a/CryptoExchange.Net/CryptoExchange.Net.csproj b/CryptoExchange.Net/CryptoExchange.Net.csproj index 50a370f..4ac7746 100644 --- a/CryptoExchange.Net/CryptoExchange.Net.csproj +++ b/CryptoExchange.Net/CryptoExchange.Net.csproj @@ -6,9 +6,9 @@ CryptoExchange.Net JKorf CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations. - 9.5.0 - 9.5.0 - 9.5.0 + 9.6.0 + 9.6.0 + 9.6.0 false OKX;OKX.Net;Mexc;Mexc.Net;Kucoin;Kucoin.Net;Kraken;Kraken.Net;Huobi;Huobi.Net;CoinEx;CoinEx.Net;Bybit;Bybit.Net;Bitget;Bitget.Net;Bitfinex;Bitfinex.Net;Binance;Binance.Net;CryptoCurrency;CryptoCurrency Exchange;CryptoExchange.Net git diff --git a/CryptoExchange.Net/Objects/RequestDefinition.cs b/CryptoExchange.Net/Objects/RequestDefinition.cs index 93857b9..429100d 100644 --- a/CryptoExchange.Net/Objects/RequestDefinition.cs +++ b/CryptoExchange.Net/Objects/RequestDefinition.cs @@ -1,89 +1,95 @@ -using CryptoExchange.Net.RateLimiting.Interfaces; +using CryptoExchange.Net.RateLimiting.Interfaces; using System.Net.Http; -namespace CryptoExchange.Net.Objects; - -/// -/// The definition of a rest request -/// -public class RequestDefinition +namespace CryptoExchange.Net.Objects { - private string? _stringRep; - - // Basics - /// - /// Path of the request + /// The definition of a rest request /// - public string Path { get; set; } - /// - /// Http method of the request - /// - public HttpMethod Method { get; set; } - /// - /// Is the request authenticated - /// - public bool Authenticated { get; set; } - - // Formatting - - /// - /// The body format for this request - /// - public RequestBodyFormat? RequestBodyFormat { get; set; } - /// - /// The position of parameters for this request - /// - public HttpMethodParameterPosition? ParameterPosition { get; set; } - /// - /// The array serialization type for this request - /// - public ArrayParametersSerialization? ArraySerialization { get; set; } - - // Rate limiting - - /// - /// Request weight - /// - public int Weight { get; set; } = 1; - - /// - /// Rate limit gate to use - /// - public IRateLimitGate? RateLimitGate { get; set; } - - /// - /// Individual endpoint rate limit guard to use - /// - public IRateLimitGuard? LimitGuard { get; set; } - - /// - /// Whether this request should never be cached - /// - public bool PreventCaching { get; set; } - - /// - /// Connection id - /// - public int? ConnectionId { get; set; } - - /// - /// ctor - /// - /// - /// - public RequestDefinition(string path, HttpMethod method) + public class RequestDefinition { - Path = path; - Method = method; + private string? _stringRep; - if (!Path.StartsWith("/")) - Path = $"/{Path}"; - } + // Basics - /// - public override string ToString() - { - return _stringRep ??= $"{Method} {Path}{(Authenticated ? " authenticated" : "")}"; + /// + /// Path of the request + /// + public string Path { get; set; } + /// + /// Http method of the request + /// + public HttpMethod Method { get; set; } + /// + /// Is the request authenticated + /// + public bool Authenticated { get; set; } + + // Formatting + + /// + /// The body format for this request + /// + public RequestBodyFormat? RequestBodyFormat { get; set; } + /// + /// The position of parameters for this request + /// + public HttpMethodParameterPosition? ParameterPosition { get; set; } + /// + /// The array serialization type for this request + /// + public ArrayParametersSerialization? ArraySerialization { get; set; } + + // Rate limiting + + /// + /// Request weight + /// + public int Weight { get; set; } = 1; + + /// + /// Rate limit gate to use + /// + public IRateLimitGate? RateLimitGate { get; set; } + + /// + /// Individual endpoint rate limit guard to use + /// + public IRateLimitGuard? LimitGuard { get; set; } + + /// + /// Whether this request should never be cached + /// + public bool PreventCaching { get; set; } + + /// + /// Whether the response to this requests should attempted to be parsed even when the status indicates failure + /// + public bool TryParseOnNonSuccess { get; set; } + + /// + /// Connection id + /// + public int? ConnectionId { get; set; } + + /// + /// ctor + /// + /// + /// + public RequestDefinition(string path, HttpMethod method) + { + Path = path; + Method = method; + + if (!Path.StartsWith("/")) + Path = $"/{Path}"; + } + + /// + public override string ToString() + { + return _stringRep ??= $"{Method} {Path}{(Authenticated ? " authenticated" : "")}"; + } } } diff --git a/CryptoExchange.Net/Objects/RequestDefinitionCache.cs b/CryptoExchange.Net/Objects/RequestDefinitionCache.cs index f4fa791..1fac34b 100644 --- a/CryptoExchange.Net/Objects/RequestDefinitionCache.cs +++ b/CryptoExchange.Net/Objects/RequestDefinitionCache.cs @@ -1,110 +1,116 @@ -using CryptoExchange.Net.RateLimiting.Interfaces; +using CryptoExchange.Net.RateLimiting.Interfaces; using System.Collections.Concurrent; using System.Net.Http; -namespace CryptoExchange.Net.Objects; - -/// -/// Request definitions cache -/// -public class RequestDefinitionCache +namespace CryptoExchange.Net.Objects { - private readonly ConcurrentDictionary _definitions = new(); - /// - /// Get a definition if it is already in the cache or create a new definition and add it to the cache + /// Request definitions cache /// - /// The HttpMethod - /// Endpoint path - /// Endpoint is authenticated - /// - public RequestDefinition GetOrCreate(HttpMethod method, string path, bool authenticated = false) - => GetOrCreate(method, path, null, 0, authenticated, null, null, null, null, null); - - /// - /// Get a definition if it is already in the cache or create a new definition and add it to the cache - /// - /// The HttpMethod - /// Endpoint path - /// The rate limit gate - /// Request weight - /// Endpoint is authenticated - /// - public RequestDefinition GetOrCreate(HttpMethod method, string path, IRateLimitGate rateLimitGate, int weight = 1, bool authenticated = false) - => GetOrCreate(method, path, rateLimitGate, weight, authenticated, null, null, null, null, null); - - /// - /// Get a definition if it is already in the cache or create a new definition and add it to the cache - /// - /// The HttpMethod - /// Endpoint path - /// The rate limit gate - /// The rate limit guard for this specific endpoint - /// Request weight - /// Endpoint is authenticated - /// Request body format - /// Parameter position - /// Array serialization type - /// Prevent request caching - /// - public RequestDefinition GetOrCreate( - HttpMethod method, - string path, - IRateLimitGate? rateLimitGate, - int weight, - bool authenticated, - IRateLimitGuard? limitGuard = null, - RequestBodyFormat? requestBodyFormat = null, - HttpMethodParameterPosition? parameterPosition = null, - ArrayParametersSerialization? arraySerialization = null, - bool? preventCaching = null) - => GetOrCreate(method + path, method, path, rateLimitGate, weight, authenticated, limitGuard, requestBodyFormat, parameterPosition, arraySerialization, preventCaching); - - /// - /// Get a definition if it is already in the cache or create a new definition and add it to the cache - /// - /// Request identifier - /// The HttpMethod - /// Endpoint path - /// The rate limit gate - /// The rate limit guard for this specific endpoint - /// Request weight - /// Endpoint is authenticated - /// Request body format - /// Parameter position - /// Array serialization type - /// Prevent request caching - /// - public RequestDefinition GetOrCreate( - string identifier, - HttpMethod method, - string path, - IRateLimitGate? rateLimitGate, - int weight, - bool authenticated, - IRateLimitGuard? limitGuard = null, - RequestBodyFormat? requestBodyFormat = null, - HttpMethodParameterPosition? parameterPosition = null, - ArrayParametersSerialization? arraySerialization = null, - bool? preventCaching = null) + public class RequestDefinitionCache { + private readonly ConcurrentDictionary _definitions = new(); - if (!_definitions.TryGetValue(identifier, out var def)) + /// + /// Get a definition if it is already in the cache or create a new definition and add it to the cache + /// + /// The HttpMethod + /// Endpoint path + /// Endpoint is authenticated + /// + public RequestDefinition GetOrCreate(HttpMethod method, string path, bool authenticated = false) + => GetOrCreate(method, path, null, 0, authenticated, null, null, null, null, null); + + /// + /// Get a definition if it is already in the cache or create a new definition and add it to the cache + /// + /// The HttpMethod + /// Endpoint path + /// The rate limit gate + /// Request weight + /// Endpoint is authenticated + /// + public RequestDefinition GetOrCreate(HttpMethod method, string path, IRateLimitGate rateLimitGate, int weight = 1, bool authenticated = false) + => GetOrCreate(method, path, rateLimitGate, weight, authenticated, null, null, null, null, null); + + /// + /// Get a definition if it is already in the cache or create a new definition and add it to the cache + /// + /// The HttpMethod + /// Endpoint path + /// The rate limit gate + /// The rate limit guard for this specific endpoint + /// Request weight + /// Endpoint is authenticated + /// Request body format + /// Parameter position + /// Array serialization type + /// Prevent request caching + /// Try parse the response even when status is not success + /// + public RequestDefinition GetOrCreate( + HttpMethod method, + string path, + IRateLimitGate? rateLimitGate, + int weight, + bool authenticated, + IRateLimitGuard? limitGuard = null, + RequestBodyFormat? requestBodyFormat = null, + HttpMethodParameterPosition? parameterPosition = null, + ArrayParametersSerialization? arraySerialization = null, + bool? preventCaching = null, + bool? tryParseOnNonSuccess = null) + => GetOrCreate(method + path, method, path, rateLimitGate, weight, authenticated, limitGuard, requestBodyFormat, parameterPosition, arraySerialization, preventCaching, tryParseOnNonSuccess); + + /// + /// Get a definition if it is already in the cache or create a new definition and add it to the cache + /// + /// Request identifier + /// The HttpMethod + /// Endpoint path + /// The rate limit gate + /// The rate limit guard for this specific endpoint + /// Request weight + /// Endpoint is authenticated + /// Request body format + /// Parameter position + /// Array serialization type + /// Prevent request caching + /// Try parse the response even when status is not success + /// + public RequestDefinition GetOrCreate( + string identifier, + HttpMethod method, + string path, + IRateLimitGate? rateLimitGate, + int weight, + bool authenticated, + IRateLimitGuard? limitGuard = null, + RequestBodyFormat? requestBodyFormat = null, + HttpMethodParameterPosition? parameterPosition = null, + ArrayParametersSerialization? arraySerialization = null, + bool? preventCaching = null, + bool? tryParseOnNonSuccess = null) { - def = new RequestDefinition(path, method) - { - Authenticated = authenticated, - LimitGuard = limitGuard, - RateLimitGate = rateLimitGate, - Weight = weight, - ArraySerialization = arraySerialization, - RequestBodyFormat = requestBodyFormat, - ParameterPosition = parameterPosition, - PreventCaching = preventCaching ?? false - }; - _definitions.TryAdd(identifier, def); - } - return def; + if (!_definitions.TryGetValue(identifier, out var def)) + { + def = new RequestDefinition(path, method) + { + Authenticated = authenticated, + LimitGuard = limitGuard, + RateLimitGate = rateLimitGate, + Weight = weight, + ArraySerialization = arraySerialization, + RequestBodyFormat = requestBodyFormat, + ParameterPosition = parameterPosition, + PreventCaching = preventCaching ?? false, + TryParseOnNonSuccess = tryParseOnNonSuccess ?? false + }; + _definitions.TryAdd(identifier, def); + } + + return def; + } } } diff --git a/README.md b/README.md index 532dd89..5c15e19 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ Make a one time donation in a crypto currency of your choice. If you prefer to d Alternatively, sponsor me on Github using [Github Sponsors](https://github.com/sponsors/JKorf). ## Release notes +* Version 9.6.0 - 25 Aug 2025 + * Added support for parsing REST response even though status indicates error + * Added better support for subscriptions without subscribe confirmation + * Added check in websocket for receiving 401 unauthorized http response status when 101 was expected + * Removed obsolete attribute on Error.Code property, updated the description + * Version 9.5.0 - 19 Aug 2025 * Added better error handling support * Added ErrorDescription, ErrorType and IsTransient to Error object