mirror of
https://github.com/JKorf/CryptoExchange.Net.git
synced 2026-10-14 07:01:46 +00:00
Added RateLimitAdmission client option delegate
This commit is contained in:
@@ -33,12 +33,12 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(i == requests ? triggered : !triggered);
|
||||
}
|
||||
triggered = false;
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(!triggered);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
bool expected = i == 1 ? expectLimiting ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
@@ -79,9 +79,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimiting ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -100,12 +100,12 @@ namespace CryptoExchange.Net.UnitTests
|
||||
|
||||
for (var i = 0; i < requests + 1; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(i == requests ? triggered : !triggered);
|
||||
}
|
||||
triggered = false;
|
||||
await Task.Delay((int)Math.Round(perSeconds * 1000) + 10);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(!triggered);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
@@ -143,7 +143,7 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
bool expected = i == 1 ? expectLimited ? evnt?.DelayTime > TimeSpan.Zero : evnt == null : evnt == null;
|
||||
Assert.That(expected);
|
||||
}
|
||||
@@ -166,9 +166,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, key1, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, key1, 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, key2, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, key2, 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -185,9 +185,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, null, 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, null, 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -205,9 +205,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition1, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, requestDefinition2, "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -222,9 +222,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
RateLimitEvent? evnt = null;
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host1, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host1, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(evnt == null);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host2, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition(host2, "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, default);
|
||||
Assert.That(expectLimited ? evnt != null : evnt == null);
|
||||
}
|
||||
|
||||
@@ -238,8 +238,8 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, ct.Token);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, ct.Token);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Connection, new RequestDefinition("https://test.com", "1", HttpMethod.Get), "123", 1, RateLimitingBehaviour.Wait, null, 1, ct.Token);
|
||||
Assert.That(result2.Error, Is.TypeOf<CancellationRequestedError>());
|
||||
}
|
||||
|
||||
@@ -257,9 +257,9 @@ namespace CryptoExchange.Net.UnitTests
|
||||
var ct = new CancellationTokenSource(TimeSpan.FromSeconds(0.2));
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, 1, ct.Token);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition, null, null, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, ct.Token);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition, null, 1, RateLimitingBehaviour.Fail, null, 1, ct.Token);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Null);
|
||||
@@ -279,10 +279,10 @@ namespace CryptoExchange.Net.UnitTests
|
||||
rateLimiter.RateLimitTriggered += (x) => { evnt = x; };
|
||||
|
||||
// act
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result1 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition1, null, 1, RateLimitingBehaviour.Fail, null, 1, default);
|
||||
var result2 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, 1, default);
|
||||
await rateLimiter.ResetAsync(RateLimitItemType.Request, definition1, null, null, null, default);
|
||||
var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, default);
|
||||
var result3 = await rateLimiter.ProcessAsync(new TraceLogger(), 1, RateLimitItemType.Request, definition2, null, 1, RateLimitingBehaviour.Fail, null, 1, default);
|
||||
|
||||
// assert
|
||||
Assert.That(evnt, Is.Not.Null);
|
||||
@@ -321,5 +321,48 @@ namespace CryptoExchange.Net.UnitTests
|
||||
// assert
|
||||
Assert.That(evnt != null, Is.EqualTo(expectLimited));
|
||||
}
|
||||
|
||||
[TestCase(RateLimitWindowType.Fixed)]
|
||||
[TestCase(RateLimitWindowType.FixedAfterFirst)]
|
||||
[TestCase(RateLimitWindowType.Sliding)]
|
||||
[TestCase(RateLimitWindowType.Decay)]
|
||||
public async Task LowerThreshold_ReservesCapacityForFullThreshold(RateLimitWindowType windowType)
|
||||
{
|
||||
var gate = new RateLimitGate("Test");
|
||||
gate.AddGuard(new RateLimitGuard(
|
||||
RateLimitGuard.PerHost,
|
||||
new LimitItemTypeFilter(RateLimitItemType.Request),
|
||||
10,
|
||||
TimeSpan.FromHours(1),
|
||||
windowType,
|
||||
decayPerTimeSpan: 1));
|
||||
|
||||
var definition = new RequestDefinition(
|
||||
"https://test.com", "/ticker", HttpMethod.Get);
|
||||
var logger = new TraceLogger();
|
||||
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var result = await gate.ProcessAsync(
|
||||
logger, i, RateLimitItemType.Request, definition, null, 1,
|
||||
RateLimitingBehaviour.Fail, null, 0.8, default);
|
||||
Assert.That(result.Success, Is.True);
|
||||
}
|
||||
|
||||
var marketData = await gate.ProcessAsync(
|
||||
logger, 9, RateLimitItemType.Request, definition, null, 1,
|
||||
RateLimitingBehaviour.Fail, null, 0.8, default);
|
||||
Assert.That(marketData.Error, Is.TypeOf<ClientRateLimitError>());
|
||||
|
||||
var order = await gate.ProcessAsync(
|
||||
logger, 10, RateLimitItemType.Request, definition, null, 2,
|
||||
RateLimitingBehaviour.Fail, null, 1.0, default);
|
||||
Assert.That(order.Success, Is.True);
|
||||
|
||||
var overHardLimit = await gate.ProcessAsync(
|
||||
logger, 11, RateLimitItemType.Request, definition, null, 1,
|
||||
RateLimitingBehaviour.Fail, null, 1.0, default);
|
||||
Assert.That(overHardLimit.Error, Is.TypeOf<ClientRateLimitError>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +350,10 @@ namespace CryptoExchange.Net.Clients
|
||||
|
||||
if (ClientOptions.RateLimiterEnabled)
|
||||
{
|
||||
var rateRatio = 1.0;
|
||||
if (ClientOptions.RateLimitAdmission != null)
|
||||
rateRatio = ClientOptions.RateLimitAdmission(definition, requestWeight).MaxUtilization;
|
||||
|
||||
var limitResult = await definition.RateLimitGate.ProcessAsync(
|
||||
_logger,
|
||||
requestId,
|
||||
@@ -359,6 +363,7 @@ namespace CryptoExchange.Net.Clients
|
||||
requestWeight,
|
||||
ClientOptions.RateLimitingBehaviour,
|
||||
rateLimitKeySuffix + ClientOptions.RateLimitGroup,
|
||||
rateRatio,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!limitResult.Success)
|
||||
return limitResult.Error!;
|
||||
@@ -374,6 +379,10 @@ namespace CryptoExchange.Net.Clients
|
||||
if (ClientOptions.RateLimiterEnabled)
|
||||
{
|
||||
var singleRequestWeight = weightSingleLimiter ?? 1;
|
||||
var rateRatio = 1.0;
|
||||
if (ClientOptions.RateLimitAdmission != null)
|
||||
rateRatio = ClientOptions.RateLimitAdmission(definition, singleRequestWeight).MaxUtilization;
|
||||
|
||||
var limitResult = await definition.RateLimitGate.ProcessSingleAsync(
|
||||
_logger,
|
||||
requestId,
|
||||
@@ -384,6 +393,7 @@ namespace CryptoExchange.Net.Clients
|
||||
singleRequestWeight,
|
||||
ClientOptions.RateLimitingBehaviour,
|
||||
rateLimitKeySuffix,
|
||||
rateRatio,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!limitResult.Success)
|
||||
return limitResult.Error!;
|
||||
|
||||
@@ -858,6 +858,7 @@ namespace CryptoExchange.Net.Clients
|
||||
ReconnectInterval = ClientOptions.ReconnectInterval,
|
||||
RateLimiter = ClientOptions.RateLimiterEnabled ? RateLimiter : null,
|
||||
RateLimitingBehavior = ClientOptions.RateLimitingBehaviour,
|
||||
RateLimitAdmission = ClientOptions.RateLimitAdmission,
|
||||
Proxy = ClientOptions.Proxy,
|
||||
Timeout = ApiOptions.SocketNoDataTimeout ?? ClientOptions.SocketNoDataTimeout,
|
||||
ReceiveBufferSize = ClientOptions.ReceiveBufferSize,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using CryptoExchange.Net.Authentication;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using System;
|
||||
|
||||
namespace CryptoExchange.Net.Objects.Options
|
||||
@@ -41,6 +42,12 @@ namespace CryptoExchange.Net.Objects.Options
|
||||
/// </summary>
|
||||
public RateLimitingBehaviour RateLimitingBehaviour { get; set; } = RateLimitingBehaviour.Wait;
|
||||
|
||||
/// <summary>
|
||||
/// A delegate receiving the request definition and the request weight that can be used to determine whether a request should be admitted or rejected based
|
||||
/// on the request definition and the current rate limit usage ratio. This allows for custom rate limiting logic to be implemented.
|
||||
/// </summary>
|
||||
public Func<RequestDefinition, int, RateLimitAdmission>? RateLimitAdmission { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
|
||||
@@ -75,6 +75,7 @@ namespace CryptoExchange.Net.Objects.Options
|
||||
item.RateLimiterEnabled = RateLimiterEnabled;
|
||||
item.RateLimitingBehaviour = RateLimitingBehaviour;
|
||||
item.RateLimitGroup = RateLimitGroup;
|
||||
item.RateLimitAdmission = RateLimitAdmission;
|
||||
item.CachingEnabled = CachingEnabled;
|
||||
item.CachingMaxAge = CachingMaxAge;
|
||||
item.HttpVersion = HttpVersion;
|
||||
|
||||
@@ -105,6 +105,7 @@ namespace CryptoExchange.Net.Objects.Options
|
||||
item.RateLimitingBehaviour = RateLimitingBehaviour;
|
||||
item.RateLimiterEnabled = RateLimiterEnabled;
|
||||
item.RateLimitGroup = RateLimitGroup;
|
||||
item.RateLimitAdmission = RateLimitAdmission;
|
||||
item.ReceiveBufferSize = ReceiveBufferSize;
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using CryptoExchange.Net.RateLimiting;
|
||||
using CryptoExchange.Net.RateLimiting.Interfaces;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
@@ -64,6 +65,12 @@ namespace CryptoExchange.Net.Objects.Sockets
|
||||
/// </summary>
|
||||
public RateLimitingBehaviour RateLimitingBehavior { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A delegate receiving the request definition and the request weight that can be used to determine whether a request should be admitted or rejected based
|
||||
/// on the request definition and the current rate limit usage ratio. This allows for custom rate limiting logic to be implemented.
|
||||
/// </summary>
|
||||
public Func<RequestDefinition, int, RateLimitAdmission>? RateLimitAdmission { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Encoding for sending/receiving data
|
||||
/// </summary>
|
||||
|
||||
@@ -104,7 +104,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix)
|
||||
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio)
|
||||
{
|
||||
foreach (var filter in _filters)
|
||||
{
|
||||
@@ -127,7 +127,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
|
||||
_trackers.Add(key, tracker);
|
||||
}
|
||||
|
||||
var delay = tracker.GetWaitTime(requestWeight);
|
||||
var delay = tracker.GetWaitTime(requestWeight, allowedRateRatio);
|
||||
if (delay == default)
|
||||
return LimitCheck.NotNeeded(Limit, TimeSpan, tracker.Current);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix)
|
||||
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio)
|
||||
{
|
||||
if (type != Type)
|
||||
return LimitCheck.NotApplicable;
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix)
|
||||
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio)
|
||||
{
|
||||
var key = _keySelector(definition, apiKey) + keySuffix;
|
||||
if (!_trackers.TryGetValue(key, out var tracker))
|
||||
@@ -62,7 +62,7 @@ namespace CryptoExchange.Net.RateLimiting.Guards
|
||||
_trackers.Add(key, tracker);
|
||||
}
|
||||
|
||||
var delay = tracker.GetWaitTime(requestWeight);
|
||||
var delay = tracker.GetWaitTime(requestWeight, allowedRateRatio);
|
||||
if (delay == default)
|
||||
return LimitCheck.NotNeeded(_limit, _period, tracker.Current);
|
||||
|
||||
|
||||
@@ -53,9 +53,20 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
|
||||
/// <param name="requestWeight">Request weight</param>
|
||||
/// <param name="behaviour">Behaviour when rate limit is hit</param>
|
||||
/// <param name="keySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
|
||||
/// <param name="ct">Cancelation token</param>
|
||||
/// <param name="allowedRateRatio">The allowed rate ratio</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Error if RateLimitingBehaviour is Fail and rate limit is hit</returns>
|
||||
ValueTask<CallResult> ProcessAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour behaviour, string? keySuffix, CancellationToken ct);
|
||||
ValueTask<CallResult> ProcessAsync(
|
||||
ILogger logger,
|
||||
int itemId,
|
||||
RateLimitItemType type,
|
||||
RequestDefinition definition,
|
||||
string? apiKey,
|
||||
int requestWeight,
|
||||
RateLimitingBehaviour behaviour,
|
||||
string? keySuffix,
|
||||
double allowedRateRatio,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the rate limit as defined in the request definition. When a rate limit is hit will wait for the rate limit to pass if RateLimitingBehaviour is Wait, or return an error if it is set to Fail
|
||||
@@ -69,9 +80,21 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
|
||||
/// <param name="behaviour">Behaviour when rate limit is hit</param>
|
||||
/// <param name="requestWeight">The weight to apply to the limit guard</param>
|
||||
/// <param name="keySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
|
||||
/// <param name="allowedRateRatio">The allowed rate ratio</param>
|
||||
/// <param name="ct">Cancelation token</param>
|
||||
/// <returns>Error if RateLimitingBehaviour is Fail and rate limit is hit</returns>
|
||||
ValueTask<CallResult> ProcessSingleAsync(ILogger logger, int itemId, IRateLimitGuard guard, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour behaviour, string? keySuffix, CancellationToken ct);
|
||||
ValueTask<CallResult> ProcessSingleAsync(
|
||||
ILogger logger,
|
||||
int itemId,
|
||||
IRateLimitGuard guard,
|
||||
RateLimitItemType type,
|
||||
RequestDefinition definition,
|
||||
string? apiKey,
|
||||
int requestWeight,
|
||||
RateLimitingBehaviour behaviour,
|
||||
string? keySuffix,
|
||||
double allowedRateRatio,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reset the limit for the specified parameters
|
||||
|
||||
@@ -25,8 +25,9 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
|
||||
/// <param name="apiKey">The API key</param>
|
||||
/// <param name="requestWeight">The request weight</param>
|
||||
/// <param name="keySuffix">An additional optional suffix for the key selector. Can be used to make rate limiting work based on parameters.</param>
|
||||
/// <param name="allowedRateRatio">The allowed rate ratio</param>
|
||||
/// <returns></returns>
|
||||
LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix);
|
||||
LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, string? keySuffix, double allowedRateRatio);
|
||||
|
||||
/// <summary>
|
||||
/// Apply the request to this guard with the specified weight
|
||||
|
||||
@@ -22,9 +22,7 @@ namespace CryptoExchange.Net.RateLimiting.Interfaces
|
||||
/// <summary>
|
||||
/// Get the time to wait to fit the weight
|
||||
/// </summary>
|
||||
/// <param name="weight"></param>
|
||||
/// <returns></returns>
|
||||
TimeSpan GetWaitTime(int weight);
|
||||
TimeSpan GetWaitTime(int weight, double allowedRateRatio);
|
||||
/// <summary>
|
||||
/// Register the weight in this window
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace CryptoExchange.Net.RateLimiting
|
||||
{
|
||||
/// <summary>
|
||||
/// Rate limit admission decision result
|
||||
/// </summary>
|
||||
public record RateLimitAdmission
|
||||
{
|
||||
/// <summary>
|
||||
/// Value ratio between 0 and 1
|
||||
/// </summary>
|
||||
public double MaxUtilization { get; }
|
||||
|
||||
private RateLimitAdmission(double maxUtilizationValue) {
|
||||
if (maxUtilizationValue <= 0 || maxUtilizationValue > 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxUtilizationValue), "Max utilization value must be bigger than 0 and less than or equal to 1");
|
||||
|
||||
MaxUtilization = maxUtilizationValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only allow the request when below a certain ratio of the rate limit, for example 0.5 means it can use a max of 50% of the rate limit,
|
||||
/// 1 means it's allowed to use the full rate limit.
|
||||
/// </summary>
|
||||
/// <param name="value">0.5 means a max use 50% of the rate limit, 1 means the request is allowed to use the full rate limit</param>
|
||||
public static RateLimitAdmission WithMaxUtilization(double value)
|
||||
=> new RateLimitAdmission(value);
|
||||
}
|
||||
}
|
||||
@@ -37,14 +37,24 @@ namespace CryptoExchange.Net.RateLimiting
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<CallResult> ProcessAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, string? keySuffix, CancellationToken ct)
|
||||
public async ValueTask<CallResult> ProcessAsync(
|
||||
ILogger logger,
|
||||
int itemId,
|
||||
RateLimitItemType type,
|
||||
RequestDefinition definition,
|
||||
string? apiKey,
|
||||
int requestWeight,
|
||||
RateLimitingBehaviour rateLimitingBehaviour,
|
||||
string? keySuffix,
|
||||
double allowedRateRatio,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
|
||||
bool release = true;
|
||||
_waitingCount++;
|
||||
try
|
||||
{
|
||||
return await CheckGuardsAsync(_guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, ct).ConfigureAwait(false);
|
||||
return await CheckGuardsAsync(_guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, allowedRateRatio, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException tce)
|
||||
{
|
||||
@@ -71,6 +81,7 @@ namespace CryptoExchange.Net.RateLimiting
|
||||
int requestWeight,
|
||||
RateLimitingBehaviour rateLimitingBehaviour,
|
||||
string? keySuffix,
|
||||
double allowedRateRatio,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
|
||||
@@ -78,7 +89,7 @@ namespace CryptoExchange.Net.RateLimiting
|
||||
_waitingCount++;
|
||||
try
|
||||
{
|
||||
return await CheckGuardsAsync(new IRateLimitGuard[] { guard }, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, ct).ConfigureAwait(false);
|
||||
return await CheckGuardsAsync(new IRateLimitGuard[] { guard }, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, allowedRateRatio, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException tce)
|
||||
{
|
||||
@@ -94,12 +105,23 @@ namespace CryptoExchange.Net.RateLimiting
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<CallResult> CheckGuardsAsync(IEnumerable<IRateLimitGuard> guards, ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, string? keySuffix, CancellationToken ct)
|
||||
private async ValueTask<CallResult> CheckGuardsAsync(
|
||||
IEnumerable<IRateLimitGuard> guards,
|
||||
ILogger logger,
|
||||
int itemId,
|
||||
RateLimitItemType type,
|
||||
RequestDefinition definition,
|
||||
string? apiKey,
|
||||
int requestWeight,
|
||||
RateLimitingBehaviour rateLimitingBehaviour,
|
||||
string? keySuffix,
|
||||
double allowedRateRatio,
|
||||
CancellationToken ct)
|
||||
{
|
||||
foreach (var guard in guards)
|
||||
{
|
||||
// Check if a wait is needed for this guard
|
||||
var result = guard.Check(type, definition, apiKey, requestWeight, keySuffix);
|
||||
var result = guard.Check(type, definition, apiKey, requestWeight, keySuffix, allowedRateRatio);
|
||||
if (result.Delay != TimeSpan.Zero && rateLimitingBehaviour == RateLimitingBehaviour.Fail)
|
||||
{
|
||||
// Delay is needed and limit behaviour is to fail the request
|
||||
@@ -126,7 +148,7 @@ namespace CryptoExchange.Net.RateLimiting
|
||||
RateLimitTriggered?.Invoke(new RateLimitEvent(itemId, _name, guard.Description, definition, result.Current, requestWeight, result.Limit, result.Period, result.Delay, rateLimitingBehaviour));
|
||||
await Task.Delay((int)result.Delay.TotalMilliseconds + 1, ct).ConfigureAwait(false);
|
||||
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
|
||||
return await CheckGuardsAsync(guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, ct).ConfigureAwait(false);
|
||||
return await CheckGuardsAsync(guards, logger, itemId, type, definition, apiKey, requestWeight, rateLimitingBehaviour, keySuffix, allowedRateRatio, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,22 +41,31 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan GetWaitTime(int weight)
|
||||
public TimeSpan GetWaitTime(int weight, double allowedRateRatio)
|
||||
{
|
||||
// Decrease the counter based on the last update time and decay rate
|
||||
DecreaseCounter(DateTime.UtcNow);
|
||||
|
||||
if (Current + weight > Limit)
|
||||
if ((Current + weight) / (double)Limit > allowedRateRatio)
|
||||
{
|
||||
// The weight would cause the rate limit to be passed
|
||||
if (Current == 0)
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
if (allowedRateRatio < 1)
|
||||
{
|
||||
throw new Exception("Request limit reached max utilization. " +
|
||||
$"This request can never execute with the current rate limiter configuration. Request weight: {weight}, RateLimit: {Limit}, " +
|
||||
$"Request ratio: {(Current + weight) / (double)Limit}, AllowedRateRatio: {allowedRateRatio}");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the time to wait before this weight can be applied without going over the rate limit
|
||||
return DetermineWaitTime(weight);
|
||||
return DetermineWaitTime(weight, allowedRateRatio);
|
||||
}
|
||||
|
||||
// Weight can fit without going over limit
|
||||
@@ -90,10 +99,11 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
|
||||
/// Determine the time to wait before the weight would fit
|
||||
/// </summary>
|
||||
/// <param name="requestWeight"></param>
|
||||
/// <param name="allowedRateRatio"></param>
|
||||
/// <returns></returns>
|
||||
private TimeSpan DetermineWaitTime(int requestWeight)
|
||||
private TimeSpan DetermineWaitTime(int requestWeight, double allowedRateRatio)
|
||||
{
|
||||
var weightToRemove = Math.Max(Current - (Limit - requestWeight), 0);
|
||||
var weightToRemove = Math.Max(Current + requestWeight - Limit * allowedRateRatio, 0);
|
||||
var result = TimeSpan.FromMilliseconds(Math.Ceiling(weightToRemove / DecreaseRate) * TimePeriod.TotalMilliseconds);
|
||||
if (result < TimeSpan.Zero)
|
||||
return TimeSpan.Zero;
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetWaitTime(int weight)
|
||||
public TimeSpan GetWaitTime(int weight, double allowedRateRatio)
|
||||
{
|
||||
// Remove requests no longer in time period from the history
|
||||
var checkTime = DateTime.UtcNow;
|
||||
@@ -63,13 +63,22 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
|
||||
if (Current == 0)
|
||||
_nextReset = null;
|
||||
|
||||
if (Current + weight > Limit)
|
||||
if ((Current + weight) / (double)Limit > allowedRateRatio)
|
||||
{
|
||||
// The weight would cause the rate limit to be passed
|
||||
if (Current == 0)
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
if (allowedRateRatio < 1)
|
||||
{
|
||||
throw new Exception("Request limit reached max utilization. " +
|
||||
$"This request can never execute with the current rate limiter configuration. Request weight: {weight}, RateLimit: {Limit}, " +
|
||||
$"Request ratio: {(Current + weight) / (double)Limit}, AllowedRateRatio: {allowedRateRatio}");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the time to wait before this weight can be applied without going over the rate limit
|
||||
|
||||
@@ -52,19 +52,28 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan GetWaitTime(int weight)
|
||||
public TimeSpan GetWaitTime(int weight, double allowedRateRatio)
|
||||
{
|
||||
// Remove requests no longer in time period from the history
|
||||
var checkTime = DateTime.UtcNow;
|
||||
RemoveBefore(checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks)));
|
||||
|
||||
if (Current + weight > Limit)
|
||||
if ((Current + weight) / (double)Limit > allowedRateRatio)
|
||||
{
|
||||
// The weight would cause the rate limit to be passed
|
||||
if (Current == 0)
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
if (allowedRateRatio < 1)
|
||||
{
|
||||
throw new Exception("Request limit reached max utilization. " +
|
||||
$"This request can never execute with the current rate limiter configuration. Request weight: {weight}, RateLimit: {Limit}, " +
|
||||
$"Request ratio: {(Current + weight) / (double)Limit}, AllowedRateRatio: {allowedRateRatio}");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the time to wait before this weight can be applied without going over the rate limit
|
||||
|
||||
@@ -53,22 +53,31 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan GetWaitTime(int weight)
|
||||
public TimeSpan GetWaitTime(int weight, double allowedRateRatio)
|
||||
{
|
||||
// Remove requests no longer in time period from the history
|
||||
RemoveBefore(DateTime.UtcNow - TimePeriod);
|
||||
|
||||
if (Current + weight > Limit)
|
||||
if ((Current + weight) / (double)Limit > allowedRateRatio)
|
||||
{
|
||||
// The weight would cause the rate limit to be passed
|
||||
if (Current == 0)
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
if (allowedRateRatio < 1)
|
||||
{
|
||||
throw new Exception("Request limit reached max utilization. " +
|
||||
$"This request can never execute with the current rate limiter configuration. Request weight: {weight}, RateLimit: {Limit}, " +
|
||||
$"Request ratio: {(Current + weight) / (double)Limit}, AllowedRateRatio: {allowedRateRatio}");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Request limit reached without any prior request. " +
|
||||
$"This request can never execute with the current rate limiter. Request weight: {weight}, RateLimit: {Limit}");
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the time to wait before this weight can be applied without going over the rate limit
|
||||
return DetermineWaitTime(weight);
|
||||
return DetermineWaitTime(weight, allowedRateRatio);
|
||||
}
|
||||
|
||||
// Weight can fit without going over limit
|
||||
@@ -108,9 +117,9 @@ namespace CryptoExchange.Net.RateLimiting.Trackers
|
||||
/// Determine the time to wait before the weight would fit
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private TimeSpan DetermineWaitTime(int requestWeight)
|
||||
private TimeSpan DetermineWaitTime(int requestWeight, double allowedRateRatio)
|
||||
{
|
||||
var weightToRemove = Math.Max(Current - (Limit - requestWeight), 0);
|
||||
var weightToRemove = Math.Max(Current + requestWeight - Limit * allowedRateRatio, 0);
|
||||
var removedWeight = 0;
|
||||
for (var i = 0; i < _entries.Count; i++)
|
||||
{
|
||||
|
||||
@@ -208,7 +208,11 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
{
|
||||
if (Parameters.RateLimiter != null)
|
||||
{
|
||||
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, Id, RateLimitItemType.Connection, _requestDefinition, null, 1, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false);
|
||||
var rateRatio = 1.0;
|
||||
if (Parameters.RateLimitAdmission != null)
|
||||
rateRatio = Parameters.RateLimitAdmission(_requestDefinition, 1).MaxUtilization;
|
||||
|
||||
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, Id, RateLimitItemType.Connection, _requestDefinition, null, 1, Parameters.RateLimitingBehavior, null, rateRatio, _ctsSource.Token).ConfigureAwait(false);
|
||||
if (!limitResult.Success)
|
||||
return CallResult.Fail(new ClientRateLimitError("Connection limit reached"));
|
||||
}
|
||||
@@ -530,9 +534,14 @@ namespace CryptoExchange.Net.Sockets.Default
|
||||
{
|
||||
if (Parameters.RateLimiter != null)
|
||||
{
|
||||
var rateRatio = 1.0;
|
||||
if (Parameters.RateLimitAdmission != null)
|
||||
rateRatio = Parameters.RateLimitAdmission(_requestDefinition, data.Weight).MaxUtilization;
|
||||
|
||||
try
|
||||
{
|
||||
var limitResult = await Parameters.RateLimiter.ProcessAsync(_logger, data.Id, RateLimitItemType.Request, _requestDefinition, null, data.Weight, Parameters.RateLimitingBehavior, null, _ctsSource.Token).ConfigureAwait(false);
|
||||
var limitResult = await Parameters.RateLimiter.ProcessAsync(
|
||||
_logger, data.Id, RateLimitItemType.Request, _requestDefinition, null, data.Weight, Parameters.RateLimitingBehavior, null, rateRatio, _ctsSource.Token).ConfigureAwait(false);
|
||||
if (!limitResult.Success)
|
||||
{
|
||||
await (OnRequestRateLimited?.Invoke(data.Id) ?? Task.CompletedTask).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user