using CryptoExchange.Net.Caching; using CryptoExchange.Net.Converters.MessageParsing.DynamicConverters; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Interfaces.Clients; using CryptoExchange.Net.Logging.Extensions; using CryptoExchange.Net.Objects; using CryptoExchange.Net.Objects.Errors; using CryptoExchange.Net.Objects.Options; using CryptoExchange.Net.RateLimiting; using CryptoExchange.Net.RateLimiting.Interfaces; using CryptoExchange.Net.Requests; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; namespace CryptoExchange.Net.Clients { /// /// Base rest API client for interacting with a REST API /// public abstract class RestApiClient : BaseApiClient, IRestApiClient { /// public IRequestFactory RequestFactory { get; set; } = new RequestFactory(); /// public int TotalRequestsMade { get; set; } /// /// Request body content type /// 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 { { HttpMethod.Get, HttpMethodParameterPosition.InUri }, { HttpMethod.Post, HttpMethodParameterPosition.InBody }, { HttpMethod.Delete, HttpMethodParameterPosition.InBody }, { HttpMethod.Put, HttpMethodParameterPosition.InBody }, { new HttpMethod("Patch"), HttpMethodParameterPosition.InBody }, }; /// public new RestExchangeOptions ClientOptions => (RestExchangeOptions)base.ClientOptions; /// public new RestApiOptions ApiOptions => (RestApiOptions)base.ApiOptions; /// /// Memory cache /// private readonly static MemoryCache _cache = new MemoryCache(); /// /// The message handler /// protected abstract IRestMessageHandler MessageHandler { get; } /// /// 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) { TimeOffsetManager.RegisterRestApi(ClientName); RequestFactory.Configure(options, httpClient); } /// /// 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) { 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); } /// /// 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) { var requestId = ExchangeHelpers.NextId(); if (definition.Authenticated && AuthenticationProvider == null) { _logger.RestApiNoApiCredentials(requestId, definition.Path); return new WebCallResult(new NoApiCredentialsError()); } string? cacheKey = null; if (ShouldCache(definition)) { 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(); } _logger.CacheNotHit(cacheKey); } int currentTry = 0; while (true) { currentTry++; await CheckTimeSync(requestId, definition).ConfigureAwait(false); var error = await RateLimitAsync( baseAddress, requestId, definition, weight ?? definition.Weight, cancellationToken, weightSingleLimiter, rateLimitKeySuffix).ConfigureAwait(false); if (error != null) return new WebCallResult(error); var request = CreateRequest( requestId, baseAddress, definition, uriParameters, bodyParameters, additionalHeaders); if (_logger.IsEnabled(LogLevel.Debug)) _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 GetResponseAsync2(definition, 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 { if (_logger.IsEnabled(LogLevel.Debug)) _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; } } /// /// Check rate limits for the request /// protected virtual async ValueTask RateLimitAsync( string host, int requestId, RequestDefinition definition, int weight, CancellationToken cancellationToken, int? weightSingleLimiter = null, string? rateLimitKeySuffix = null) { // Rate limiting var requestWeight = 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, host, AuthenticationProvider?._credentials.Key, requestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false); if (!limitResult) return limitResult.Error!; } } // 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"); if (ClientOptions.RateLimiterEnabled) { var singleRequestWeight = weightSingleLimiter ?? 1; var limitResult = await definition.RateLimitGate.ProcessSingleAsync(_logger, requestId, definition.LimitGuard, RateLimitItemType.Request, definition, host, AuthenticationProvider?._credentials.Key, singleRequestWeight, ClientOptions.RateLimitingBehaviour, rateLimitKeySuffix, cancellationToken).ConfigureAwait(false); if (!limitResult) return limitResult.Error!; } } return null; } /// /// 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 ? null : CreateParameterDictionary(uriParameters), bodyParameters == null ? null : CreateParameterDictionary(bodyParameters), 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(ClientOptions.HttpVersion, definition.Method, uri, requestId); request.Accept = MessageHandler.AcceptHeader; if (requestConfiguration.Headers != null) { 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 requestConfiguration.Headers ??= new Dictionary(); 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> GetResponseAsync2( RequestDefinition requestDefinition, IRequest request, IRateLimitGate? gate, CancellationToken cancellationToken) { var sw = Stopwatch.StartNew(); Stream? responseStream = null; IResponse? response = null; try { response = await request.GetResponseAsync(cancellationToken).ConfigureAwait(false); sw.Stop(); responseStream = await response.GetResponseStreamAsync(cancellationToken).ConfigureAwait(false); string? originalData = null; var outputOriginalData = ApiOptions.OutputOriginalData ?? ClientOptions.OutputOriginalData; if (outputOriginalData || MessageHandler.RequiresSeekableStream) { // If we want to return the original string data from the stream, but still want to process it // we'll need to copy it as the stream isn't seekable, and thus we can only read it once var memoryStream = new MemoryStream(); await responseStream.CopyToAsync(memoryStream).ConfigureAwait(false); using var reader = new StreamReader(memoryStream, Encoding.UTF8, false, 4096, true); if (outputOriginalData) { memoryStream.Position = 0; originalData = await reader.ReadToEndAsync().ConfigureAwait(false); } // Continue processing from the memory stream since the response stream is already read and we can't seek it responseStream.Close(); memoryStream.Position = 0; responseStream = memoryStream; } if (!response.IsSuccessStatusCode && !requestDefinition.TryParseOnNonSuccess) { // If the response status is not success it is an error by definition Error error; if (response.StatusCode == (HttpStatusCode)418 || response.StatusCode == (HttpStatusCode)429) { // Specifically handle rate limit errors var rateError = await MessageHandler.ParseErrorRateLimitResponse( (int)response.StatusCode, response.ResponseHeaders, responseStream).ConfigureAwait(false); 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 { // Handle a 'normal' error response. Can still be either a json error message or some random HTML or other string try { error = await MessageHandler.ParseErrorResponse( (int)response.StatusCode, response.ResponseHeaders, responseStream).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception when parsing error response: {Message}", ex.Message); var errorResult = new ServerError(ErrorInfo.Unknown with { Message = ex.Message }); return new WebCallResult(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, errorResult); } } return new WebCallResult(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error); } if (typeof(T) == typeof(object)) // Success status code and expected empty response, assume it's correct return new WebCallResult(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, 0, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, null); // Data response received, inspect the message and check if it is an error or not var parsedError = await MessageHandler.CheckForErrorResponse( requestDefinition, response.ResponseHeaders, responseStream).ConfigureAwait(false); 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.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, parsedError); } if (MessageHandler.RequiresSeekableStream) // Reset stream read position as it might not be at the start if `CheckForErrorResponse` has read from it responseStream.Position = 0; // Try deserialization into the expected type var (deserializeResult, deserializeError) = await MessageHandler.TryDeserializeAsync(responseStream, cancellationToken).ConfigureAwait(false); if (deserializeError != null) return new WebCallResult(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, deserializeError); ; try { // Check the deserialized response to see if it's an error or not var responseError = MessageHandler.CheckDeserializedResponse(response.ResponseHeaders, deserializeResult); if (responseError != null) return new WebCallResult(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, responseError); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception when checking deserialized response: {Message}", ex.Message); var error = new ServerError(ErrorInfo.Unknown with { Message = ex.Message }); return new WebCallResult(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, error); } return new WebCallResult(response.StatusCode, response.HttpVersion, response.ResponseHeaders, sw.Elapsed, response.ContentLength, originalData, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, deserializeResult, null); } catch (HttpRequestException requestException) { // Request exception, can't reach server for instance var error = new WebError(requestException.Message, requestException); return new WebCallResult(null, 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, 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, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error); } } catch (ArgumentException argumentException) { if (argumentException.Message.StartsWith("Only HTTP/")) { // Unsupported HTTP version error .net framework var error = ArgumentError.Invalid(nameof(RestExchangeOptions.HttpVersion), $"Invalid HTTP version {request.HttpVersion}: " + argumentException.Message); return new WebCallResult(null, null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error); } throw; } catch (NotSupportedException notSupportedException) { if (notSupportedException.Message.StartsWith("Request version value must be one of")) { // Unsupported HTTP version error dotnet code var error = ArgumentError.Invalid(nameof(RestExchangeOptions.HttpVersion), $"Invalid HTTP version {request.HttpVersion}: " + notSupportedException.Message); return new WebCallResult(null, null, null, sw.Elapsed, null, null, request.RequestId, request.Uri.ToString(), request.Content, request.Method, request.GetHeaders(), ResultDataSource.Server, default, error); } throw; } finally { responseStream?.Close(); response?.Close(); } } /// /// 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 ValueTask 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) 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); } } /// /// 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, ClientOptions.HttpKeepAliveInterval); } private async ValueTask CheckTimeSync(int requestId, RequestDefinition definition) { if (!definition.Authenticated) return; var lastUpdateTime = TimeOffsetManager.GetRestLastUpdateTime(ClientName); var syncTask = CheckTimeOffsetAsync(); if (lastUpdateTime == null) { // Initially with first request we'll need to wait for the time syncing before making the actual request. // If it's not the first request we can just continue and let it complete in the background await syncTask.ConfigureAwait(false); } return; } internal async ValueTask CheckTimeOffsetAsync() { if (!(ApiOptions.AutoTimestamp ?? ClientOptions.AutoTimestamp)) // Time syncing not enabled return; await TimeOffsetManager.EnterAsync(ClientName).ConfigureAwait(false); try { var lastUpdateTime = TimeOffsetManager.GetRestLastUpdateTime(ClientName); if (DateTime.UtcNow - lastUpdateTime < (ApiOptions.TimestampRecalculationInterval ?? ClientOptions.TimestampRecalculationInterval)) // Time syncing was recently done return; var localTime = DateTime.UtcNow; var result = await GetServerTimestampAsync().ConfigureAwait(false); if (!result) { _logger.LogWarning("Failed to determine time offset between client and server, timestamping might fail"); return; } 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) { _logger.LogWarning("Failed to determine time offset between client and server, timestamping might fail"); return; } } // Estimate the offset as the round trip time / 2 var offset = result.Data - localTime.AddMilliseconds(result.ResponseTime!.Value.TotalMilliseconds / 2); if (offset.TotalMilliseconds > 0 && offset.TotalMilliseconds < 500) { _logger.LogInformation("{ClientName} Time offset within limits ({Offset}ms), set offset to 0ms", ClientName, Math.Round(offset.TotalMilliseconds)); offset = TimeSpan.Zero; } else { _logger.LogInformation("{ClientName} Time offset set to {Offset}ms", ClientName, Math.Round(offset.TotalMilliseconds)); } TimeOffsetManager.UpdateRestOffset(ClientName, offset.TotalMilliseconds); } finally { TimeOffsetManager.Release(ClientName); } } private bool ShouldCache(RequestDefinition definition) => ClientOptions.CachingEnabled && definition.Method == HttpMethod.Get && !definition.PreventCaching; } }