1
0
mirror of https://github.com/JKorf/CryptoExchange.Net.git synced 2026-10-13 22:51:39 +00:00
This commit is contained in:
Jkorf
2026-08-27 15:57:28 +02:00
parent d93d6b7a2e
commit 32e74e9bc4
23 changed files with 874 additions and 60 deletions
@@ -0,0 +1,281 @@
using CryptoExchange.Net.Objects;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using System;
using System.Collections;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.UnitTests
{
[TestFixture]
public class RequestCoalescerTests
{
private static readonly TimeSpan _testTimeout = TimeSpan.FromSeconds(5);
[Test]
public async Task ExecuteAsync_WithIdenticalRequests_ShouldExecuteRequestOnce()
{
var coalescer = CreateCoalescer();
var key = CreateKey();
var requestCompletion = CreateCompletionSource<string>();
var requestStarted = CreateCompletionSource<bool>();
var requestCount = 0;
async Task<string> ExecuteRequest(CancellationToken cancellationToken)
{
Interlocked.Increment(ref requestCount);
requestStarted.TrySetResult(true);
return await requestCompletion.Task;
}
var first = coalescer.ExecuteAsync(key, ExecuteRequest, () => "canceled", CancellationToken.None);
await requestStarted.Task.WaitAsync(_testTimeout);
var second = coalescer.ExecuteAsync(key, ExecuteRequest, () => "canceled", CancellationToken.None);
Assert.That(requestCount, Is.EqualTo(1));
requestCompletion.SetResult("result");
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("result"));
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("result"));
Assert.That(requestCount, Is.EqualTo(1));
}
[Test]
public async Task ExecuteAsync_WithDifferentKeys_ShouldExecuteEachRequest()
{
var coalescer = CreateCoalescer();
var firstCompletion = CreateCompletionSource<string>();
var secondCompletion = CreateCompletionSource<string>();
var firstStarted = CreateCompletionSource<bool>();
var secondStarted = CreateCompletionSource<bool>();
var requestCount = 0;
var first = coalescer.ExecuteAsync(
CreateKey(parameters: "symbol=ETH"),
async cancellationToken =>
{
Interlocked.Increment(ref requestCount);
firstStarted.TrySetResult(true);
return await firstCompletion.Task;
},
() => "canceled",
CancellationToken.None);
var second = coalescer.ExecuteAsync(
CreateKey(parameters: "symbol=BTC"),
async cancellationToken =>
{
Interlocked.Increment(ref requestCount);
secondStarted.TrySetResult(true);
return await secondCompletion.Task;
},
() => "canceled",
CancellationToken.None);
await Task.WhenAll(firstStarted.Task, secondStarted.Task).WaitAsync(_testTimeout);
Assert.That(requestCount, Is.EqualTo(2));
firstCompletion.SetResult("first");
secondCompletion.SetResult("second");
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("first"));
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("second"));
}
[Test]
public async Task ExecuteAsync_WhenOneListenerCancels_ShouldKeepSharedRequestRunning()
{
var coalescer = CreateCoalescer();
var requestCompletion = CreateCompletionSource<string>();
var requestStarted = CreateCompletionSource<CancellationToken>();
var requestCount = 0;
using var firstCancellationSource = new CancellationTokenSource();
async Task<string> ExecuteRequest(CancellationToken cancellationToken)
{
Interlocked.Increment(ref requestCount);
requestStarted.TrySetResult(cancellationToken);
return await requestCompletion.Task;
}
var first = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", firstCancellationSource.Token);
var requestCancellationToken = await requestStarted.Task.WaitAsync(_testTimeout);
var second = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", CancellationToken.None);
firstCancellationSource.Cancel();
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("canceled"));
Assert.That(requestCancellationToken.IsCancellationRequested, Is.False);
Assert.That(requestCount, Is.EqualTo(1));
requestCompletion.SetResult("result");
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("result"));
}
[Test]
public async Task ExecuteAsync_WhenAllListenersCancel_ShouldCancelSharedRequest()
{
var coalescer = CreateCoalescer();
var requestStarted = CreateCompletionSource<bool>();
var requestCanceled = CreateCompletionSource<bool>();
using var firstCancellationSource = new CancellationTokenSource();
using var secondCancellationSource = new CancellationTokenSource();
async Task<string> ExecuteRequest(CancellationToken cancellationToken)
{
using (cancellationToken.Register(() => requestCanceled.TrySetResult(true)))
{
requestStarted.TrySetResult(true);
await requestCanceled.Task;
return "request canceled";
}
}
var first = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "listener canceled", firstCancellationSource.Token);
await requestStarted.Task.WaitAsync(_testTimeout);
var second = coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "listener canceled", secondCancellationSource.Token);
firstCancellationSource.Cancel();
Assert.That(await first.WaitAsync(_testTimeout), Is.EqualTo("listener canceled"));
Assert.That(requestCanceled.Task.IsCompleted, Is.False);
secondCancellationSource.Cancel();
Assert.That(await second.WaitAsync(_testTimeout), Is.EqualTo("listener canceled"));
Assert.That(await requestCanceled.Task.WaitAsync(_testTimeout), Is.True);
}
[Test]
public async Task ExecuteAsync_WithAlreadyCanceledListener_ShouldNotStartRequest()
{
var coalescer = CreateCoalescer();
var requestCount = 0;
using var cancellationSource = new CancellationTokenSource();
cancellationSource.Cancel();
var result = await coalescer.ExecuteAsync(
CreateKey(),
cancellationToken =>
{
Interlocked.Increment(ref requestCount);
return Task.FromResult("result");
},
() => "canceled",
cancellationSource.Token);
Assert.That(result, Is.EqualTo("canceled"));
Assert.That(requestCount, Is.Zero);
}
[Test]
public async Task ExecuteAsync_AfterRequestCompletes_ShouldStartNewRequest()
{
var coalescer = CreateCoalescer();
var requestCount = 0;
Task<int> ExecuteRequest(CancellationToken cancellationToken)
=> Task.FromResult(Interlocked.Increment(ref requestCount));
var first = await coalescer.ExecuteAsync(CreateKey(typeof(int)), ExecuteRequest, () => -1, CancellationToken.None);
var second = await coalescer.ExecuteAsync(CreateKey(typeof(int)), ExecuteRequest, () => -1, CancellationToken.None);
Assert.That(first, Is.EqualTo(1));
Assert.That(second, Is.EqualTo(2));
Assert.That(requestCount, Is.EqualTo(2));
}
[Test]
public void ExecuteAsync_AfterRequestFails_ShouldStartNewRequest()
{
var coalescer = CreateCoalescer();
var requestCount = 0;
Task<string> ExecuteRequest(CancellationToken cancellationToken)
{
if (Interlocked.Increment(ref requestCount) == 1)
return Task.FromException<string>(new InvalidOperationException("Request failed"));
return Task.FromResult("result");
}
Assert.ThrowsAsync<InvalidOperationException>(async () =>
await coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", CancellationToken.None));
Assert.That(
coalescer.ExecuteAsync(CreateKey(), ExecuteRequest, () => "canceled", CancellationToken.None).GetAwaiter().GetResult(),
Is.EqualTo("result"));
Assert.That(requestCount, Is.EqualTo(2));
}
[Test]
public async Task ExecuteAsync_WhenAbandonedRequestCompletes_ShouldNotRemoveReplacementRequest()
{
var coalescer = CreateCoalescer();
var key = CreateKey();
var oldRequestCompletion = CreateCompletionSource<string>();
var oldRequestStarted = CreateCompletionSource<bool>();
var replacementCompletion = CreateCompletionSource<string>();
var replacementStarted = CreateCompletionSource<bool>();
var requestCount = 0;
using var cancellationSource = new CancellationTokenSource();
async Task<string> ExecuteOldRequest(CancellationToken cancellationToken)
{
Interlocked.Increment(ref requestCount);
oldRequestStarted.TrySetResult(true);
return await oldRequestCompletion.Task;
}
async Task<string> ExecuteReplacementRequest(CancellationToken cancellationToken)
{
Interlocked.Increment(ref requestCount);
replacementStarted.TrySetResult(true);
return await replacementCompletion.Task;
}
var abandonedListener = coalescer.ExecuteAsync(key, ExecuteOldRequest, () => "canceled", cancellationSource.Token);
await oldRequestStarted.Task.WaitAsync(_testTimeout);
var oldPhysicalRequest = GetPhysicalRequest(coalescer, key);
cancellationSource.Cancel();
Assert.That(await abandonedListener.WaitAsync(_testTimeout), Is.EqualTo("canceled"));
var replacementListener = coalescer.ExecuteAsync(key, ExecuteReplacementRequest, () => "canceled", CancellationToken.None);
await replacementStarted.Task.WaitAsync(_testTimeout);
oldRequestCompletion.SetResult("old result");
await oldPhysicalRequest.WaitAsync(_testTimeout);
var joinedReplacementListener = coalescer.ExecuteAsync(key, ExecuteReplacementRequest, () => "canceled", CancellationToken.None);
Assert.That(requestCount, Is.EqualTo(2));
replacementCompletion.SetResult("replacement result");
Assert.That(await replacementListener.WaitAsync(_testTimeout), Is.EqualTo("replacement result"));
Assert.That(await joinedReplacementListener.WaitAsync(_testTimeout), Is.EqualTo("replacement result"));
Assert.That(requestCount, Is.EqualTo(2));
}
private static RequestCoalescer CreateCoalescer()
=> new RequestCoalescer(NullLogger.Instance);
private static RequestCoalescingKey CreateKey(Type? responseType = null, string? parameters = null)
=> new RequestCoalescingKey("GET", "https://localhost/test", parameters, responseType ?? typeof(string));
private static TaskCompletionSource<T> CreateCompletionSource<T>()
=> new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
private static Task GetPhysicalRequest(RequestCoalescer coalescer, RequestCoalescingKey key)
{
var requestsField = typeof(RequestCoalescer).GetField("_requests", BindingFlags.Instance | BindingFlags.NonPublic)!;
var requests = (IDictionary)requestsField.GetValue(coalescer)!;
var entry = requests[key]!;
var requestProperty = entry.GetType().GetProperty("Request")!;
return ((Lazy<Task<object>>)requestProperty.GetValue(entry)!).Value;
}
}
}
+92 -2
View File
@@ -98,6 +98,10 @@ namespace CryptoExchange.Net.Clients
/// Configured environment name
/// </summary>
public abstract string EnvironmentName { get; }
/// <summary>
/// Request coalescer
/// </summary>
protected RequestCoalescer RequestCoalescer { get; }
/// <summary>
/// ctor
@@ -123,6 +127,7 @@ namespace CryptoExchange.Net.Clients
{
TimeOffsetManager.RegisterRestApi(ClientName);
RequestCoalescer = new RequestCoalescer(_logger);
RequestFactory.Configure(options, httpClient);
}
@@ -177,8 +182,7 @@ namespace CryptoExchange.Net.Clients
/// <param name="weight">Override the request weight for this request definition, for example when the weight depends on the parameters</param>
/// <param name="weightSingleLimiter">Specify the weight to apply to the individual rate limit guard for this request</param>
/// <param name="rateLimitKeySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
/// <returns></returns>
protected virtual async Task<HttpResult<T>> SendAsync<T>(
protected virtual Task<HttpResult<T>> SendAsync<T>(
RequestDefinition definition,
Parameters? uriParameters,
Parameters? bodyParameters,
@@ -187,6 +191,61 @@ namespace CryptoExchange.Net.Clients
int? weight = null,
int? weightSingleLimiter = null,
string? rateLimitKeySuffix = null)
{
if (!ShouldCoalesce(definition, additionalHeaders))
{
return SendCoreAsync<T>(
definition,
uriParameters,
bodyParameters,
cancellationToken,
additionalHeaders,
weight,
weightSingleLimiter,
rateLimitKeySuffix);
}
var key = GetRequestCoalescingKey<T>(
definition,
uriParameters);
return RequestCoalescer.ExecuteAsync(
key,
requestCancellationToken => SendCoreAsync<T>(
definition,
uriParameters,
bodyParameters,
requestCancellationToken,
additionalHeaders,
weight,
weightSingleLimiter,
rateLimitKeySuffix),
() => HttpResult.Fail<T>(
Exchange,
new CancellationRequestedError()),
cancellationToken);
}
/// <summary>
/// Send a request to the base address based on the request definition
/// </summary>
/// <typeparam name="T">Response type</typeparam>
/// <param name="definition">Request definition</param>
/// <param name="uriParameters">Request query parameters</param>
/// <param name="bodyParameters">Request body parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="additionalHeaders">Additional headers for this request</param>
/// <param name="weight">Override the request weight for this request definition, for example when the weight depends on the parameters</param>
/// <param name="weightSingleLimiter">Specify the weight to apply to the individual rate limit guard for this request</param>
/// <param name="rateLimitKeySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
protected virtual async Task<HttpResult<T>> SendCoreAsync<T>(RequestDefinition definition,
Parameters? uriParameters,
Parameters? bodyParameters,
CancellationToken cancellationToken,
Dictionary<string, string>? additionalHeaders = null,
int? weight = null,
int? weightSingleLimiter = null,
string? rateLimitKeySuffix = null)
{
var requestId = ExchangeHelpers.NextId();
if (definition.Authenticated && GetAuthenticationProvider() == null)
@@ -688,7 +747,10 @@ namespace CryptoExchange.Net.Clients
stringData = stringSerializer.Serialize(parameters.BodyValue);
}
else
{
stringData = stringSerializer.Serialize(parameters);
}
request.SetContent(stringData, RequestBodyContentEncoding, contentType);
}
else if (contentType == Constants.FormContentHeader)
@@ -808,6 +870,34 @@ namespace CryptoExchange.Net.Clients
return definition.FullUrl + definition + parameters?.ToFormData();
}
/// <summary>
/// Whether a request can be coalesced or not
/// </summary>
protected virtual bool ShouldCoalesce(
RequestDefinition definition,
Dictionary<string, string>? additionalHeaders)
{
return ClientOptions.RequestCoalescingEnabled // Enabled in client options
&& !definition.PreventRequestCoalescing // Enabled in request definition
&& definition.Method == HttpMethod.Get // Is a GET request
&& !definition.Authenticated // Not an authenticated request
&& (additionalHeaders == null || additionalHeaders.Count == 0); // No additional headers that might change the response
}
/// <summary>
/// Get a unique key for coalescing requests based on the request definition, parameters, and the type of the expected response.
/// </summary>
protected virtual RequestCoalescingKey GetRequestCoalescingKey<T>(
RequestDefinition definition,
Parameters? uriParameters)
{
return new RequestCoalescingKey(
definition.Method.Method,
definition.FullUrl,
uriParameters?.ToFormData(),
typeof(T));
}
/// <inheritdoc />
public virtual void SetOptions(UpdateOptions options)
{
@@ -22,6 +22,7 @@ namespace CryptoExchange.Net.Logging.Extensions
private static readonly Action<ILogger, string, Exception?> _restApiCacheHit;
private static readonly Action<ILogger, string, Exception?> _restApiCacheNotHit;
private static readonly Action<ILogger, int?, Exception?> _restApiCancellationRequested;
private static readonly Action<ILogger, string, string, Exception?> _restApiRequestsJoined;
static RestApiClientLoggingExtensions()
{
@@ -89,7 +90,11 @@ namespace CryptoExchange.Net.Logging.Extensions
LogLevel.Debug,
new EventId(4012, "RestApiCancellationRequested"),
"[Req {RequestId}] request cancelled by user");
_restApiRequestsJoined = LoggerMessage.Define<string, string>(
LogLevel.Debug,
new EventId(4013, "RestApiRequestsJoined"),
"Request for {Method} {Path} joined with in flight request");
}
public static void RestApiErrorReceived(this ILogger logger, int? requestId, HttpStatusCode? responseStatusCode, long responseTime, string? error, string? originalData, Exception? exception)
@@ -151,9 +156,15 @@ namespace CryptoExchange.Net.Logging.Extensions
{
_restApiCacheNotHit(logger, key, null);
}
public static void RestApiCancellationRequested(this ILogger logger, int? requestId)
{
_restApiCancellationRequested(logger, requestId, null);
}
public static void RestApiRequestsJoined(this ILogger logger, string method, string path)
{
_restApiRequestsJoined(logger, method, path, null);
}
}
}
@@ -34,6 +34,11 @@ namespace CryptoExchange.Net.Objects.Options
= new Version(1, 1);
#endif
/// <summary>
/// Whether concurrent identical REST requests may share the same in-flight request when allowed by the request definition.
/// </summary>
public bool RequestCoalescingEnabled { get; set; } = true;
/// <summary>
/// Http client keep alive interval for keeping connections open. Only applied when using dotnet8.0 or higher and dependency injection
/// </summary>
@@ -73,6 +78,7 @@ namespace CryptoExchange.Net.Objects.Options
item.CachingEnabled = CachingEnabled;
item.CachingMaxAge = CachingMaxAge;
item.HttpVersion = HttpVersion;
item.RequestCoalescingEnabled = RequestCoalescingEnabled;
item.HttpKeepAliveInterval = HttpKeepAliveInterval;
#if NET5_0_OR_GREATER
item.HttpMaxConnectionsPerServer = HttpMaxConnectionsPerServer;
@@ -0,0 +1,277 @@
using CryptoExchange.Net.Logging.Extensions;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.Objects
{
/// <summary>
/// Request coalescer, used to detect multiple identical requests and wait for and return only a single actual request result
/// </summary>
public class RequestCoalescer
{
private sealed class RequestEntry
{
public RequestCoalescingKey Key { get; }
public CancellationTokenSource CancellationSource { get; } = new();
public Lazy<Task<object>> Request { get; set; } = null!;
public int ListenerCount { get; set; }
public bool Completed { get; set; }
public bool CancellationInProgress { get; set; }
public bool Disposed { get; set; }
public RequestEntry(RequestCoalescingKey key)
{
Key = key;
}
}
private readonly object _sync = new();
private readonly ILogger _logger;
private readonly Dictionary<RequestCoalescingKey, RequestEntry> _requests = new();
/// <summary>
/// ctor
/// </summary>
public RequestCoalescer(ILogger logger)
{
_logger = logger;
}
/// <summary>
/// Execute a request, coalescing identical requests into a single request
/// </summary>
public Task<TResult> ExecuteAsync<TResult>(
RequestCoalescingKey key,
Func<CancellationToken, Task<TResult>> requestFactory,
Func<TResult> cancellationResultFactory,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
return Task.FromResult(cancellationResultFactory());
RequestEntry entry;
bool joined;
lock (_sync)
{
joined = _requests.TryGetValue(key, out entry!);
if (!joined)
{
entry = CreateEntry(key, requestFactory);
_requests.Add(key, entry);
}
entry.ListenerCount++;
}
if (joined)
_logger.RestApiRequestsJoined(key.Method, key.Url);
return WaitAsync(
entry,
cancellationResultFactory,
cancellationToken);
}
private RequestEntry CreateEntry<TResult>(
RequestCoalescingKey key,
Func<CancellationToken, Task<TResult>> requestFactory)
{
var entry = new RequestEntry(key);
entry.Request = new Lazy<Task<object>>(
() => ExecuteCoreAsync(
entry,
async requestCancellationToken =>
(await requestFactory(requestCancellationToken).ConfigureAwait(false))!),
LazyThreadSafetyMode.ExecutionAndPublication);
return entry;
}
private async Task<object> ExecuteCoreAsync(
RequestEntry entry,
Func<CancellationToken, Task<object>> requestFactory)
{
try
{
return await requestFactory(entry.CancellationSource.Token).ConfigureAwait(false);
}
finally
{
CompleteRequest(entry);
}
}
private async Task<TResult> WaitAsync<TResult>(
RequestEntry entry,
Func<TResult> cancellationResultFactory,
CancellationToken cancellationToken)
{
try
{
var requestTask = entry.Request.Value;
if (!cancellationToken.CanBeCanceled)
return (TResult)await requestTask.ConfigureAwait(false);
var cancellationCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (cancellationToken.Register(
state => ((TaskCompletionSource<bool>)state!).TrySetResult(true),
cancellationCompletion))
{
var completedTask = await Task.WhenAny(requestTask, cancellationCompletion.Task).ConfigureAwait(false);
if (completedTask != requestTask)
return cancellationResultFactory();
}
return (TResult)await requestTask.ConfigureAwait(false);
}
finally
{
ReleaseListener(entry);
}
}
private void ReleaseListener(RequestEntry entry)
{
var cancelRequest = false;
var disposeCancellationSource = false;
lock (_sync)
{
entry.ListenerCount--;
if (entry.ListenerCount == 0)
{
if (!entry.Completed)
{
// Remove the entry before canceling it. A caller arriving
// after this point must create a new physical request
// instead of joining one that is being canceled.
RemoveEntry(entry);
entry.CancellationInProgress = true;
cancelRequest = true;
}
else if (!entry.Disposed)
{
entry.Disposed = true;
disposeCancellationSource = true;
}
}
}
if (cancelRequest)
{
try
{
entry.CancellationSource.Cancel();
}
catch (Exception exception)
{
// Cancellation callbacks are external to the coalescer and
// must not cause an exception to escape the library.
_logger.LogWarning(
exception,
"Error canceling in-flight request {Method} {Url}",
entry.Key.Method,
entry.Key.Url);
}
finally
{
CompleteCancellation(entry);
}
}
else if (disposeCancellationSource)
{
entry.CancellationSource.Dispose();
}
}
private void CompleteCancellation(RequestEntry entry)
{
var disposeCancellationSource = false;
lock (_sync)
{
entry.CancellationInProgress = false;
if (entry.Completed
&& entry.ListenerCount == 0
&& !entry.Disposed)
{
entry.Disposed = true;
disposeCancellationSource = true;
}
}
if (disposeCancellationSource)
entry.CancellationSource.Dispose();
}
private void CompleteRequest(RequestEntry entry)
{
var disposeCancellationSource = false;
lock (_sync)
{
entry.Completed = true;
// Only remove this specific entry. If all listeners canceled,
// a replacement request may already have been created for the
// same key.
RemoveEntry(entry);
if (entry.ListenerCount == 0
&& !entry.CancellationInProgress
&& !entry.Disposed)
{
entry.Disposed = true;
disposeCancellationSource = true;
}
}
if (disposeCancellationSource)
entry.CancellationSource.Dispose();
}
/// <summary>
/// Remove an entry when it is still the current entry for its key.
/// Must only be called while holding <see cref="_sync"/>.
/// </summary>
private void RemoveEntry(RequestEntry entry)
{
if (_requests.TryGetValue(entry.Key, out var current)
&& ReferenceEquals(current, entry))
{
_requests.Remove(entry.Key);
}
}
}
/// <summary>
/// Key used to identify identical requests for coalescing
/// </summary>
/// <param name="Method">Request method</param>
/// <param name="Url">Request URL</param>
/// <param name="Parameters">Request parameter string</param>
/// <param name="ResponseType">Response type</param>
public sealed record RequestCoalescingKey(
string Method,
string Url,
string? Parameters,
Type ResponseType);
}
@@ -101,6 +101,11 @@ namespace CryptoExchange.Net.Objects
}
}
/// <summary>
/// Whether concurrent identical requests should be prevented from sharing the same request/response
/// </summary>
public bool PreventRequestCoalescing { get; set; }
/// <summary>
/// ctor
/// </summary>
@@ -53,6 +53,7 @@ namespace CryptoExchange.Net.Objects
/// <param name="tryParseOnNonSuccess">Try parse the response even when status is not success</param>
/// <param name="forcePathEndWithSlash">Force trailing `/`</param>
/// <param name="identifier">Optional request identifier override</param>
/// <param name="preventRequestCoalescing">Whether concurrent identical requests should be prevented from sharing the same request/response</param>
/// <returns></returns>
public RequestDefinition GetOrCreate(
HttpMethod method,
@@ -68,7 +69,8 @@ namespace CryptoExchange.Net.Objects
bool? preventCaching = null,
bool? tryParseOnNonSuccess = null,
bool? forcePathEndWithSlash = null,
string? identifier = null)
string? identifier = null,
bool? preventRequestCoalescing = null)
{
var identifierToUse = identifier ?? $"{path}{method.Method}{baseAddress}";
if (!_definitions.TryGetValue(identifierToUse, out var def))
@@ -85,6 +87,7 @@ namespace CryptoExchange.Net.Objects
PreventCaching = preventCaching ?? false,
TryParseOnNonSuccess = tryParseOnNonSuccess ?? false,
ForcePathEndWithSlash = forcePathEndWithSlash ?? false,
PreventRequestCoalescing = preventRequestCoalescing ?? false,
};
_definitions.TryAdd(identifierToUse, def);
}
@@ -165,7 +165,10 @@ namespace CryptoExchange.Net.Objects.Sockets
{
StreamId = StreamId,
UpdateType = UpdateType,
Symbol = Symbol
Symbol = Symbol,
DataTime = DataTime,
DataTimeLocal = DataTimeLocal,
SequenceNumber = SequenceNumber
};
}
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// The type of updates the incremental order book subscription produces
/// </summary>
public enum SharedOrderBookSubscriptionType
{
/// <summary>
/// Subscription produces an initial snapshot update, followed by incremental change updates
/// </summary>
SnapshotThenIncremental,
/// <summary>
/// Subscription produces only incremental change updates
/// </summary>
OnlyIncremental
}
}
@@ -25,6 +25,6 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="request">Request info</param>
/// <param name="nextPageToken">The pagination request from the previous request result <see cref="HttpResult{T}.NextPageRequest"/> property to continue pagination</param>
/// <param name="ct">Cancellation token</param>
Task<HttpResult<SharedUserTrade[]>> GetFuturesUserTradesAsync(GetUserTradesRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
Task<HttpResult<SharedUserTrade[]>> GetFuturesUserTradeHistoryAsync(GetUserTradesRequest request, PageRequest? nextPageToken = null, CancellationToken ct = default);
}
}
@@ -23,6 +23,6 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="handler">Update handler</param>
/// <param name="ct">Cancellation token, can be used to stop the updates</param>
/// <returns></returns>
Task<WebSocketResult<UpdateSubscription>> SubscribeToFuturesOrderUpdatesAsync(SubscribeFuturesOrderRequest request, Action<DataEvent<SharedFuturesOrder[]>> handler, CancellationToken ct = default);
Task<WebSocketResult<UpdateSubscription>> SubscribeToFuturesOrderUpdatesAsync(SubscribeFuturesOrderRequest request, Action<DataEvent<SharedFuturesOrderUpdate[]>> handler, CancellationToken ct = default);
}
}
@@ -0,0 +1,28 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.Objects.Sockets;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Client for subscribing to incremental order book updates for a symbol
/// </summary>
public interface ISubscribeIncrementalOrderBookOperation : ISharedSubscription
{
/// <summary>
/// Order book subscription options
/// </summary>
SubscribeOrderBookOptions SubscribeOrderBookOptions { get; }
/// <summary>
/// Subscribe to incremental order book updates for a symbol
/// </summary>
/// <param name="request">Request info</param>
/// <param name="handler">Update handler</param>
/// <param name="ct">Cancellation token, can be used to stop the updates</param>
/// <returns></returns>
Task<WebSocketResult<UpdateSubscription>> SubscribeToOrderBookUpdatesAsync(SubscribeOrderBookRequest request, Action<DataEvent<SharedOrderBook>> handler, CancellationToken ct = default);
}
}
@@ -23,6 +23,6 @@ namespace CryptoExchange.Net.SharedApis
/// <param name="handler">Update handler</param>
/// <param name="ct">Cancellation token, can be used to stop the updates</param>
/// <returns></returns>
Task<WebSocketResult<UpdateSubscription>> SubscribeToSpotOrderUpdatesAsync(SubscribeSpotOrderRequest request, Action<DataEvent<SharedSpotOrder[]>> handler, CancellationToken ct = default);
Task<WebSocketResult<UpdateSubscription>> SubscribeToSpotOrderUpdatesAsync(SubscribeSpotOrderRequest request, Action<DataEvent<SharedSpotOrderUpdate[]>> handler, CancellationToken ct = default);
}
}
@@ -16,7 +16,7 @@ namespace CryptoExchange.Net.SharedApis
/// ctor
/// </summary>
public GetFuturesUserTradeHistoryOptions(string exchange, bool supportsAscending, bool supportsDescending, bool timeFilterSupported, int maxLimit)
: base(exchange, supportsAscending, supportsDescending, timeFilterSupported, maxLimit, true, nameof(IGetFuturesUserTradeHistoryEndpoint.GetFuturesUserTradesAsync))
: base(exchange, supportsAscending, supportsDescending, timeFilterSupported, maxLimit, true, nameof(IGetFuturesUserTradeHistoryEndpoint.GetFuturesUserTradeHistoryAsync))
{
}
@@ -0,0 +1,46 @@
using CryptoExchange.Net.Objects;
using System;
using System.Linq;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Options for subscribing to order book snapshot updates
/// </summary>
public class SubscribeIncrementalOrderBookOptions : CapabilityOptions<SubscribeOrderBookRequest, ISubscribeIncrementalOrderBookOperation>
{
/// <inheritdoc />
public override string Description => "Subscribe to incremental order book updates for a symbol";
/// <summary>
/// Order book depths supported for updates
/// </summary>
public int[] SupportedLimits { get; }
/// <summary>
/// The type of updates the subscription produces
/// </summary>
public SharedOrderBookSubscriptionType UpdateType { get; }
/// <summary>
/// ctor
/// </summary>
public SubscribeIncrementalOrderBookOptions(string exchange, bool needsAuthentication, int[] limits, SharedOrderBookSubscriptionType updateType)
: base(exchange, needsAuthentication, nameof(ISubscribeIncrementalOrderBookOperation.SubscribeToOrderBookUpdatesAsync))
{
SupportedLimits = limits;
UpdateType = updateType;
}
/// <summary>
/// Validate a request
/// </summary>
public override Error? ValidateRequest(SubscribeOrderBookRequest request, ISubscribeIncrementalOrderBookOperation client)
{
if (request.Limit != null && !SupportedLimits.Contains(request.Limit.Value))
return ArgumentError.Invalid(nameof(SubscribeOrderBookRequest.Limit), "Limit not supported");
return base.ValidateRequest(request, client);
}
}
}
@@ -73,10 +73,12 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Asset the fee is in
/// </summary>
[Obsolete("FeeAsset on order level is deprecated and will be removed in a futures version, use FeeAsset on trade level instead")]
public string? FeeAsset { get; set; }
/// <summary>
/// Fee paid
/// </summary>
[Obsolete("Fee on order level is deprecated and will be removed in a futures version, use Fee on trade level instead")]
public decimal? Fee { get; set; }
/// <summary>
/// Leverage
@@ -94,6 +96,7 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Last trade info, only available for websocket order updates if the API provides this data in the update
/// </summary>
[Obsolete("Use SharedFuturesOrderUpdate.LastTrade instead, LastTrade is never filled on non websocket updates")]
public SharedUserTrade? LastTrade { get; set; }
/// <summary>
@@ -0,0 +1,38 @@
using System;
using System.Diagnostics;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Futures order info
/// </summary>
[DebuggerDisplay("{DebugView,nq}")]
public record SharedFuturesOrderUpdate : SharedFuturesOrder
{
/// <summary>
/// The info on the executed trade for this update
/// </summary>
public new SharedUserTrade? LastTrade
{
#pragma warning disable CS0618 // Type or member is obsolete
get => base.LastTrade;
set => base.LastTrade = value;
#pragma warning restore CS0618 // Type or member is obsolete
}
/// <summary>
/// ctor
/// </summary>
public SharedFuturesOrderUpdate(
SharedSymbol? sharedSymbol,
string symbol,
string orderId,
SharedOrderType orderType,
SharedOrderSide orderSide,
SharedOrderStatus orderStatus,
DateTime? createTime)
: base(sharedSymbol, symbol, orderId, orderType, orderSide, orderStatus, createTime)
{
}
}
}
@@ -12,6 +12,10 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public SharedQuantityType QuantityType { get; set; }
/// <summary>
/// The sequence number this snapshot or update is at
/// </summary>
public long? SequenceNumber { get; set; }
/// <summary>
/// Asks list
/// </summary>
public ISymbolOrderBookEntry[] Asks { get; set; }
@@ -23,9 +27,10 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// ctor
/// </summary>
public SharedOrderBook(SharedQuantityType quantityType, ISymbolOrderBookEntry[] asks, ISymbolOrderBookEntry[] bids)
public SharedOrderBook(SharedQuantityType quantityType, long? sequenceNumber, ISymbolOrderBookEntry[] asks, ISymbolOrderBookEntry[] bids)
{
QuantityType = quantityType;
SequenceNumber = sequenceNumber;
Asks = asks;
Bids = bids;
}
@@ -65,10 +65,12 @@ namespace CryptoExchange.Net.SharedApis
/// <summary>
/// Asset the fee is in
/// </summary>
[Obsolete("FeeAsset on order level is deprecated and will be removed in a futures version, use FeeAsset on trade level instead")]
public string? FeeAsset { get; set; }
/// <summary>
/// Fee paid for the order
/// </summary>
[Obsolete("Fee on order level is deprecated and will be removed in a futures version, use Fee on trade level instead")]
public decimal? Fee { get; set; }
/// <summary>
/// Timestamp the order was created
@@ -79,8 +81,9 @@ namespace CryptoExchange.Net.SharedApis
/// </summary>
public DateTime? UpdateTime { get; set; }
/// <summary>
/// Last trade info, only available for websocket order updates if the API provides this data in the update
/// Only available for order updates, contains the info on the executed trade
/// </summary>
[Obsolete("Use SharedSpotOrderUpdate.LastTrade instead, LastTrade is never filled on non websocket updates")]
public SharedUserTrade? LastTrade { get; set; }
/// <summary>
@@ -0,0 +1,38 @@
using System;
using System.Diagnostics;
namespace CryptoExchange.Net.SharedApis
{
/// <summary>
/// Spot order info
/// </summary>
[DebuggerDisplay("{DebugView,nq}")]
public record SharedSpotOrderUpdate : SharedSpotOrder
{
/// <summary>
/// The info on the executed trade for this update
/// </summary>
public new SharedUserTrade? LastTrade
{
#pragma warning disable CS0618 // Type or member is obsolete
get => base.LastTrade;
set => base.LastTrade = value;
#pragma warning restore CS0618 // Type or member is obsolete
}
/// <summary>
/// ctor
/// </summary>
public SharedSpotOrderUpdate(
SharedSymbol? sharedSymbol,
string symbol,
string orderId,
SharedOrderType orderType,
SharedOrderSide orderSide,
SharedOrderStatus orderStatus,
DateTime? createTime)
: base(sharedSymbol, symbol, orderId, orderType, orderSide, orderStatus, createTime)
{
}
}
}
@@ -83,18 +83,6 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
changed = true;
}
if (updateItem.Fee != null && updateItem.Fee != existingItem.Fee)
{
existingItem.Fee = updateItem.Fee;
changed = true;
}
if (updateItem.FeeAsset != null && updateItem.FeeAsset != existingItem.FeeAsset)
{
existingItem.FeeAsset = updateItem.FeeAsset;
changed = true;
}
if (updateItem.OrderQuantity != null && updateItem.OrderQuantity != existingItem.OrderQuantity)
{
existingItem.OrderQuantity = updateItem.OrderQuantity;
@@ -196,16 +184,6 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
}
if (existingItem.Fee != null && updateItem.Fee != null)
{
// Higher fee means later processing
if (existingItem.Fee < updateItem.Fee)
return true;
if (existingItem.Fee > updateItem.Fee)
return false;
}
return null;
}
@@ -214,7 +192,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
await base.HandleUpdateAsync(source, @event).ConfigureAwait(false);
var trades = @event.Where(x => x.LastTrade != null).Select(x => x.LastTrade!).ToArray();
var trades = @event.OfType<SharedFuturesOrderUpdate>().Where(x => x.LastTrade != null).Select(x => x.LastTrade!).ToArray();
if (trades.Length != 0 && OnTradeUpdate != null)
await OnTradeUpdate.Invoke(source, trades).ConfigureAwait(false);
}
@@ -225,7 +203,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
if (_socketClient == null)
return Task.FromResult(new WebSocketResult<UpdateSubscription?>(_exchange, default!, default));
return ExchangeHelpers.ProcessQueuedAsync<SharedFuturesOrder[]>(
return ExchangeHelpers.ProcessQueuedAsync<SharedFuturesOrderUpdate[]>(
async handler => await _socketClient.SubscribeToFuturesOrderUpdatesAsync(new SubscribeFuturesOrderRequest(exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}
@@ -75,7 +75,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
var updatedPollTime = DateTime.UtcNow;
foreach (var symbol in _symbolTracker.GetTrackedSymbols())
{
var tradesResult = await _restClient.GetFuturesUserTradesAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
var tradesResult = await _restClient.GetFuturesUserTradeHistoryAsync(new GetUserTradesRequest(symbol, startTime: fromTimeTrades, exchangeParameters: _exchangeParameters)).ConfigureAwait(false);
if (!tradesResult.Success)
{
anyError = true;
@@ -82,18 +82,6 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
changed = true;
}
if (updateItem.Fee != null && updateItem.Fee != existingItem.Fee)
{
existingItem.Fee = updateItem.Fee;
changed = true;
}
if (updateItem.FeeAsset != null && updateItem.FeeAsset != existingItem.FeeAsset)
{
existingItem.FeeAsset = updateItem.FeeAsset;
changed = true;
}
if (updateItem.OrderQuantity != null && updateItem.OrderQuantity != existingItem.OrderQuantity)
{
existingItem.OrderQuantity ??= new SharedOrderQuantity();
@@ -206,16 +194,6 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
}
}
if (existingItem.Fee != null && updateItem.Fee != null)
{
// Higher fee means later processing
if (existingItem.Fee < updateItem.Fee)
return true;
if (existingItem.Fee > updateItem.Fee)
return false;
}
return null;
}
@@ -224,7 +202,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
{
await base.HandleUpdateAsync(source, @event).ConfigureAwait(false);
var trades = @event.Where(x => x.LastTrade != null).Select(x => x.LastTrade!).ToArray();
var trades = @event.OfType<SharedSpotOrderUpdate>().Where(x => x.LastTrade != null).Select(x => x.LastTrade!).ToArray();
if (trades.Length != 0 && OnTradeUpdate != null)
await OnTradeUpdate(source, trades).ConfigureAwait(false);
}
@@ -235,7 +213,7 @@ namespace CryptoExchange.Net.Trackers.UserData.ItemTrackers
if (_socketClient == null)
return Task.FromResult(new WebSocketResult<UpdateSubscription?>(_exchange!, default!, default));
return ExchangeHelpers.ProcessQueuedAsync<SharedSpotOrder[]>(
return ExchangeHelpers.ProcessQueuedAsync<SharedSpotOrderUpdate[]>(
async handler => await _socketClient.SubscribeToSpotOrderUpdatesAsync(new SubscribeSpotOrderRequest(exchangeParameters: _exchangeParameters), handler, ct: _cts!.Token).ConfigureAwait(false),
x => HandleUpdateAsync(UpdateSource.Push, x.Data))!;
}