1
0
mirror of https://github.com/JKorf/CryptoExchange.Net.git synced 2026-08-21 13:23:07 +00:00

Feature/ratelimit refactor (#197)

This commit is contained in:
Jan Korf
2024-04-16 14:55:27 +02:00
committed by GitHub
parent 2dbd5be924
commit 1b1961db00
57 changed files with 2294 additions and 688 deletions
@@ -0,0 +1,27 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Filters
{
/// <summary>
/// Filter requests based on whether they're authenticated or not
/// </summary>
public class AuthenticatedEndpointFilter : IGuardFilter
{
private readonly bool _authenticated;
/// <summary>
/// ctor
/// </summary>
/// <param name="authenticated"></param>
public AuthenticatedEndpointFilter(bool authenticated)
{
_authenticated = authenticated;
}
/// <inheritdoc />
public bool Passes(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey)
=> definition.Authenticated == _authenticated;
}
}
@@ -0,0 +1,30 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using System;
using System.Collections.Generic;
using System.Security;
using System.Text;
namespace CryptoExchange.Net.RateLimiting.Filters
{
/// <summary>
/// Filter requests based on whether the request path matches a specific path
/// </summary>
public class ExactPathFilter : IGuardFilter
{
private readonly string _path;
/// <summary>
/// ctor
/// </summary>
/// <param name="path"></param>
public ExactPathFilter(string path)
{
_path = path;
}
/// <inheritdoc />
public bool Passes(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey)
=> string.Equals(definition.Path, _path, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,28 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using System.Collections.Generic;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Filters
{
/// <summary>
/// Filter requests based on whether the request path matches any specific path in a list
/// </summary>
public class ExactPathsFilter : IGuardFilter
{
private readonly HashSet<string> _paths;
/// <summary>
/// ctor
/// </summary>
/// <param name="paths"></param>
public ExactPathsFilter(IEnumerable<string> paths)
{
_paths = new HashSet<string>(paths);
}
/// <inheritdoc />
public bool Passes(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey)
=> _paths.Contains(definition.Path);
}
}
@@ -0,0 +1,28 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Filters
{
/// <summary>
/// Filter requests based on whether the host address matches a specific address
/// </summary>
public class HostFilter : IGuardFilter
{
private readonly string _host;
/// <summary>
/// ctor
/// </summary>
/// <param name="host"></param>
public HostFilter(string host)
{
_host = host;
}
/// <inheritdoc />
public bool Passes(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey)
=> host == _host;
}
}
@@ -0,0 +1,27 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Filters
{
/// <summary>
/// Filter requests based on whether it's a connection or a request
/// </summary>
public class LimitItemTypeFilter : IGuardFilter
{
private readonly RateLimitItemType _type;
/// <summary>
/// ctor
/// </summary>
/// <param name="type"></param>
public LimitItemTypeFilter(RateLimitItemType type)
{
_type = type;
}
/// <inheritdoc />
public bool Passes(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey)
=> type == _type;
}
}
@@ -0,0 +1,28 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using System;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Filters
{
/// <summary>
/// Filter requests based on whether the path starts with a specific string
/// </summary>
public class PathStartFilter : IGuardFilter
{
private readonly string _path;
/// <summary>
/// ctor
/// </summary>
/// <param name="path"></param>
public PathStartFilter(string path)
{
_path = path;
}
/// <inheritdoc />
public bool Passes(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey)
=> definition.Path.StartsWith(_path, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,146 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using CryptoExchange.Net.RateLimiting.Trackers;
using System;
using System.Collections.Generic;
using System.Security;
using System.Text;
namespace CryptoExchange.Net.RateLimiting.Guards
{
/// <inheritdoc />
public class RateLimitGuard : IRateLimitGuard
{
/// <summary>
/// Apply guard per host
/// </summary>
public static Func<RequestDefinition, string, SecureString?, string> PerHost { get; } = new Func<RequestDefinition, string, SecureString?, string>((def, host, key) => host);
/// <summary>
/// Apply guard per endpoint
/// </summary>
public static Func<RequestDefinition, string, SecureString?, string> PerEndpoint { get; } = new Func<RequestDefinition, string, SecureString?, string>((def, host, key) => def.Path + def.Method);
/// <summary>
/// Apply guard per API key
/// </summary>
public static Func<RequestDefinition, string, SecureString?, string> PerApiKey { get; } = new Func<RequestDefinition, string, SecureString?, string>((def, host, key) => key!.GetString());
/// <summary>
/// Apply guard per API key per endpoint
/// </summary>
public static Func<RequestDefinition, string, SecureString?, string> PerApiKeyPerEndpoint { get; } = new Func<RequestDefinition, string, SecureString?, string>((def, host, key) => key!.GetString() + def.Path + def.Method);
private readonly IEnumerable<IGuardFilter> _filters;
private readonly Dictionary<string, IWindowTracker> _trackers;
private RateLimitWindowType _windowType;
private double? _decayRate;
private int? _connectionWeight;
private readonly Func<RequestDefinition, string, SecureString?, string> _keySelector;
/// <inheritdoc />
public string Name => "RateLimitGuard";
/// <inheritdoc />
public string Description => _windowType == RateLimitWindowType.Decay ? $"Limit of {Limit} with a decay rate of {_decayRate}" : $"Limit of {Limit} per {TimeSpan}";
/// <summary>
/// The limit per period
/// </summary>
public int Limit { get; }
/// <summary>
/// The time period for the limit
/// </summary>
public TimeSpan TimeSpan { get; }
/// <summary>
/// ctor
/// </summary>
/// <param name="keySelector">The rate limit key selector</param>
/// <param name="filter">Filter for rate limit items. Only when the rate limit item passes the filter the guard will apply</param>
/// <param name="limit">Limit per period</param>
/// <param name="timeSpan">Timespan for the period</param>
/// <param name="windowType">Type of rate limit window</param>
/// <param name="decayPerTimeSpan">The decay per timespan if windowType is DecayWindowTracker</param>
/// <param name="connectionWeight">The weight of a new connection</param>
public RateLimitGuard(Func<RequestDefinition, string, SecureString?, string> keySelector, IGuardFilter filter, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, double? decayPerTimeSpan = null, int? connectionWeight = null)
: this(keySelector, new[] { filter }, limit, timeSpan, windowType, decayPerTimeSpan, connectionWeight)
{
}
/// <summary>
/// ctor
/// </summary>
/// <param name="keySelector">The rate limit key selector</param>
/// <param name="filters">Filters for rate limit items. Only when the rate limit item passes all filters the guard will apply</param>
/// <param name="limit">Limit per period</param>
/// <param name="timeSpan">Timespan for the period</param>
/// <param name="windowType">Type of rate limit window</param>
/// <param name="decayPerTimeSpan">The decay per timespan if windowType is DecayWindowTracker</param>
/// <param name="connectionWeight">The weight of a new connection</param>
public RateLimitGuard(Func<RequestDefinition, string, SecureString?, string> keySelector, IEnumerable<IGuardFilter> filters, int limit, TimeSpan timeSpan, RateLimitWindowType windowType, double? decayPerTimeSpan = null, int? connectionWeight = null)
{
_filters = filters;
_trackers = new Dictionary<string, IWindowTracker>();
_windowType = windowType;
Limit = limit;
TimeSpan = timeSpan;
_keySelector = keySelector;
_decayRate = decayPerTimeSpan;
_connectionWeight = connectionWeight;
}
/// <inheritdoc />
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
foreach(var filter in _filters)
{
if (!filter.Passes(type, definition, host, apiKey))
return LimitCheck.NotApplicable;
}
if (type == RateLimitItemType.Connection)
requestWeight = _connectionWeight ?? requestWeight;
var key = _keySelector(definition, host, apiKey);
if (!_trackers.TryGetValue(key, out var tracker))
{
tracker = CreateTracker();
_trackers.Add(key, tracker);
}
var delay = tracker.GetWaitTime(requestWeight);
if (delay == default)
return LimitCheck.NotNeeded;
return LimitCheck.Needed(delay, Limit, TimeSpan, tracker.Current);
}
/// <inheritdoc />
public RateLimitState ApplyWeight(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
foreach (var filter in _filters)
{
if (!filter.Passes(type, definition, host, apiKey))
return RateLimitState.NotApplied;
}
if (type == RateLimitItemType.Connection)
requestWeight = _connectionWeight ?? requestWeight;
var key = _keySelector(definition, host, apiKey);
var tracker = _trackers[key];
tracker.ApplyWeight(requestWeight);
return RateLimitState.Applied(Limit, TimeSpan, tracker.Current);
}
/// <summary>
/// Create a new WindowTracker
/// </summary>
/// <returns></returns>
protected IWindowTracker CreateTracker()
{
return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(Limit, TimeSpan)
: _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(Limit, TimeSpan)
: _windowType == RateLimitWindowType.FixedAfterFirst ? new FixedAfterStartWindowTracker(Limit, TimeSpan) :
new DecayWindowTracker(Limit, TimeSpan, _decayRate ?? throw new InvalidOperationException("Decay rate not provided"));
}
}
}
@@ -0,0 +1,62 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using System;
using System.Collections.Generic;
using System.Security;
using System.Text;
namespace CryptoExchange.Net.RateLimiting.Guards
{
/// <summary>
/// Retry after guard
/// </summary>
public class RetryAfterGuard : IRateLimitGuard
{
/// <summary>
/// Additional wait time to apply to account for time offset between server and client
/// </summary>
private static readonly TimeSpan _windowBuffer = TimeSpan.FromMilliseconds(1000);
/// <inheritdoc />
public string Name => "RetryAfterGuard";
/// <inheritdoc />
public string Description => $"Pause requests until after {After}";
/// <summary>
/// The timestamp after which requests are allowed again
/// </summary>
public DateTime After { get; private set; }
/// <summary>
/// ctor
/// </summary>
/// <param name="after"></param>
public RetryAfterGuard(DateTime after)
{
After = after;
}
/// <inheritdoc />
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
var dif = (After + _windowBuffer) - DateTime.UtcNow;
if (dif <= TimeSpan.Zero)
return LimitCheck.NotApplicable;
return LimitCheck.Needed(dif, default, default, default);
}
/// <inheritdoc />
public RateLimitState ApplyWeight(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
return RateLimitState.NotApplied;
}
/// <summary>
/// Update the 'after' time
/// </summary>
/// <param name="after"></param>
public void UpdateAfter(DateTime after) => After = after;
}
}
@@ -0,0 +1,72 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Interfaces;
using CryptoExchange.Net.RateLimiting.Trackers;
using System;
using System.Collections.Generic;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Guards
{
/// <summary>
/// Rate limit guard for a per endpoint limit
/// </summary>
public class SingleLimitGuard : IRateLimitGuard
{
private readonly Dictionary<string, IWindowTracker> _trackers;
private readonly RateLimitWindowType _windowType;
private readonly double? _decayRate;
/// <inheritdoc />
public string Name => "EndpointLimitGuard";
/// <inheritdoc />
public string Description => $"Limit requests to endpoint";
/// <summary>
/// ctor
/// </summary>
public SingleLimitGuard(RateLimitWindowType windowType, double? decayRate = null)
{
_windowType = windowType;
_decayRate = decayRate;
_trackers = new Dictionary<string, IWindowTracker>();
}
/// <inheritdoc />
public LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
var key = definition.Path + definition.Method;
if (!_trackers.TryGetValue(key, out var tracker))
{
tracker = CreateTracker(definition.EndpointLimitCount!.Value, definition.EndpointLimitPeriod!.Value);
_trackers.Add(key, tracker);
}
var delay = tracker.GetWaitTime(requestWeight);
if (delay == default)
return LimitCheck.NotNeeded;
return LimitCheck.Needed(delay, definition.EndpointLimitCount!.Value, definition.EndpointLimitPeriod!.Value, tracker.Current);
}
/// <inheritdoc />
public RateLimitState ApplyWeight(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight)
{
var key = definition.Path + definition.Method + definition;
var tracker = _trackers[key];
tracker.ApplyWeight(requestWeight);
return RateLimitState.Applied(definition.EndpointLimitCount!.Value, definition.EndpointLimitPeriod!.Value, tracker.Current);
}
/// <summary>
/// Create a new WindowTracker
/// </summary>
/// <returns></returns>
protected IWindowTracker CreateTracker(int limit, TimeSpan timeSpan)
{
return _windowType == RateLimitWindowType.Sliding ? new SlidingWindowTracker(limit, timeSpan)
: _windowType == RateLimitWindowType.Fixed ? new FixedWindowTracker(limit, timeSpan) :
new DecayWindowTracker(limit, timeSpan, _decayRate ?? throw new InvalidOperationException("Decay rate not provided"));
}
}
}
@@ -0,0 +1,21 @@
using CryptoExchange.Net.Objects;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Interfaces
{
/// <summary>
/// Filter requests based on specific condition
/// </summary>
public interface IGuardFilter
{
/// <summary>
/// Whether a request or connection passes this filter
/// </summary>
/// <param name="type">The type of item</param>
/// <param name="definition">The request definition</param>
/// <param name="host">The host address</param>
/// <param name="apiKey">The API key</param>
/// <returns>True if passed</returns>
bool Passes(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey);
}
}
@@ -0,0 +1,78 @@
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Guards;
using Microsoft.Extensions.Logging;
using System;
using System.Security;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.RateLimiting.Interfaces
{
/// <summary>
/// Rate limit gate
/// </summary>
public interface IRateLimitGate
{
/// <summary>
/// Event when the rate limit is triggered
/// </summary>
event Action<RateLimitEvent> RateLimitTriggered;
/// <summary>
/// Add a rate limit guard
/// </summary>
/// <param name="guard">Guard to add</param>
/// <returns></returns>
IRateLimitGate AddGuard(IRateLimitGuard guard);
/// <summary>
/// Set a RetryAfter guard, can be used when a server rate limit is hit and a RetryAfter header is specified
/// </summary>
/// <param name="retryAfter">The time after which requests can be send again</param>
/// <returns></returns>
Task SetRetryAfterGuardAsync(DateTime retryAfter);
/// <summary>
/// Set the SingleLimitGuard for handling individual endpoint rate limits
/// </summary>
/// <param name="guard"></param>
/// <returns></returns>
IRateLimitGate SetSingleLimitGuard(SingleLimitGuard guard);
/// <summary>
/// Returns the 'retry after' timestamp if set
/// </summary>
/// <returns></returns>
Task<DateTime?> GetRetryAfterTime();
/// <summary>
/// Process a request. Enforces the configured rate limits. 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
/// </summary>
/// <param name="logger">Logger</param>
/// <param name="itemId">Id of the item to check</param>
/// <param name="type">The rate limit item type</param>
/// <param name="definition">The request definition</param>
/// <param name="baseAddress">The host address</param>
/// <param name="apiKey">The API key</param>
/// <param name="requestWeight">Request weight</param>
/// <param name="behaviour">Behaviour when rate limit is hit</param>
/// <param name="ct">Cancelation token</param>
/// <returns>Error if RateLimitingBehaviour is Fail and rate limit is hit</returns>
Task<CallResult> ProcessAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string baseAddress, SecureString? apiKey, int requestWeight, RateLimitingBehaviour behaviour, 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
/// </summary>
/// <param name="logger">Logger</param>
/// <param name="itemId">Id of the item to check</param>
/// <param name="type">The rate limit item type</param>
/// <param name="definition">The request definition</param>
/// <param name="baseAddress">The host address</param>
/// <param name="apiKey">The API key</param>
/// <param name="requestWeight">Request weight</param>
/// <param name="behaviour">Behaviour when rate limit is hit</param>
/// <param name="ct">Cancelation token</param>
/// <returns>Error if RateLimitingBehaviour is Fail and rate limit is hit</returns>
Task<CallResult> ProcessSingleAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string baseAddress, SecureString? apiKey, int requestWeight, RateLimitingBehaviour behaviour, CancellationToken ct);
}
}
@@ -0,0 +1,44 @@
using CryptoExchange.Net.Objects;
using System.Net.Http;
using System.Security;
namespace CryptoExchange.Net.RateLimiting.Interfaces
{
/// <summary>
/// Rate limit guard
/// </summary>
public interface IRateLimitGuard
{
/// <summary>
/// Name
/// </summary>
string Name { get; }
/// <summary>
/// Description
/// </summary>
string Description { get; }
/// <summary>
/// Check whether a request can pass this rate limit guard
/// </summary>
/// <param name="type">The rate limit item type</param>
/// <param name="definition">The request definition</param>
/// <param name="host">The host address</param>
/// <param name="apiKey">The API key</param>
/// <param name="requestWeight">The request weight</param>
/// <returns></returns>
LimitCheck Check(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight);
/// <summary>
/// Apply the request to this guard with the specified weight
/// </summary>
/// <param name="type">The rate limit item type</param>
/// <param name="definition">The request definition</param>
/// <param name="host">The host address</param>
/// <param name="apiKey">The API key</param>
/// <param name="requestWeight">The request weight</param>
/// <returns></returns>
RateLimitState ApplyWeight(RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight);
}
}
@@ -0,0 +1,34 @@
using System;
namespace CryptoExchange.Net.RateLimiting.Interfaces
{
/// <summary>
/// Rate limit window tracker
/// </summary>
public interface IWindowTracker
{
/// <summary>
/// Time period the limit is for
/// </summary>
TimeSpan TimePeriod { get; }
/// <summary>
/// The limit in the time period
/// </summary>
int Limit { get; }
/// <summary>
/// The current count within the time period
/// </summary>
int Current { get; }
/// <summary>
/// Get the time to wait to fit the weight
/// </summary>
/// <param name="weight"></param>
/// <returns></returns>
TimeSpan GetWaitTime(int weight);
/// <summary>
/// Register the weight in this window
/// </summary>
/// <param name="weight">Request weight</param>
void ApplyWeight(int weight);
}
}
@@ -0,0 +1,60 @@
using System;
namespace CryptoExchange.Net.RateLimiting
{
/// <summary>
/// Limit check
/// </summary>
public readonly struct LimitCheck
{
/// <summary>
/// Is guard applicable
/// </summary>
public bool Applicable { get; }
/// <summary>
/// Delay needed
/// </summary>
public TimeSpan Delay { get; }
/// <summary>
/// Current counter
/// </summary>
public int Current { get; }
/// <summary>
/// Limit
/// </summary>
public int? Limit { get; }
/// <summary>
/// Time period
/// </summary>
public TimeSpan? Period { get; }
private LimitCheck(bool applicable, TimeSpan delay, int limit, TimeSpan period, int current)
{
Applicable = applicable;
Delay = delay;
Limit = limit;
Period = period;
Current = current;
}
/// <summary>
/// Not applicable
/// </summary>
public static LimitCheck NotApplicable { get; } = new LimitCheck(false, default, default, default, default);
/// <summary>
/// No wait needed
/// </summary>
public static LimitCheck NotNeeded { get; } = new LimitCheck(true, default, default, default, default);
/// <summary>
/// Wait needed
/// </summary>
/// <param name="delay">The delay needed</param>
/// <param name="limit">Limit per period</param>
/// <param name="period">Period the limit is for</param>
/// <param name="current">Current counter</param>
/// <returns></returns>
public static LimitCheck Needed(TimeSpan delay, int limit, TimeSpan period, int current) => new(true, delay, limit, period, current);
}
}
@@ -0,0 +1,30 @@
using System;
namespace CryptoExchange.Net.RateLimiting
{
/// <summary>
/// A rate limit entry
/// </summary>
public struct LimitEntry
{
/// <summary>
/// Timestamp of the item
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// Item weight
/// </summary>
public int Weight { get; set; }
/// <summary>
/// ctor
/// </summary>
/// <param name="timestamp"></param>
/// <param name="weight"></param>
public LimitEntry(DateTime timestamp, int weight)
{
Timestamp = timestamp;
Weight = weight;
}
}
}
@@ -0,0 +1,80 @@
using CryptoExchange.Net.Objects;
using System;
namespace CryptoExchange.Net.RateLimiting
{
/// <summary>
/// Rate limit event
/// </summary>
public record RateLimitEvent
{
/// <summary>
/// Name of the API limit that is reached
/// </summary>
public string ApiLimit { get; set; } = string.Empty;
/// <summary>
/// Description of the limit that is reached
/// </summary>
public string LimitDescription { get; set; } = string.Empty;
/// <summary>
/// The request definition
/// </summary>
public RequestDefinition RequestDefinition { get; set; }
/// <summary>
/// The host the request is for
/// </summary>
public string Host { get; set; } = default!;
/// <summary>
/// The current counter value
/// </summary>
public int Current { get; set; }
/// <summary>
/// The weight of the limited request
/// </summary>
public int RequestWeight { get; set; }
/// <summary>
/// The limit per time period
/// </summary>
public int? Limit { get; set; }
/// <summary>
/// The time period the limit is for
/// </summary>
public TimeSpan? TimePeriod { get; set; }
/// <summary>
/// The time the request will be delayed for if the Behaviour is RateLimitingBehaviour.Wait
/// </summary>
public TimeSpan? DelayTime { get; set; }
/// <summary>
/// The handling behaviour for the rquest
/// </summary>
public RateLimitingBehaviour Behaviour { get; set; }
/// <summary>
/// ctor
/// </summary>
/// <param name="apiLimit"></param>
/// <param name="limitDescription"></param>
/// <param name="definition"></param>
/// <param name="host"></param>
/// <param name="current"></param>
/// <param name="requestWeight"></param>
/// <param name="limit"></param>
/// <param name="timePeriod"></param>
/// <param name="delayTime"></param>
/// <param name="behaviour"></param>
public RateLimitEvent(string apiLimit, string limitDescription, RequestDefinition definition, string host, int current, int requestWeight, int? limit, TimeSpan? timePeriod, TimeSpan? delayTime, RateLimitingBehaviour behaviour)
{
ApiLimit = apiLimit;
LimitDescription = limitDescription;
RequestDefinition = definition;
Host = host;
Current = current;
RequestWeight = requestWeight;
Limit = limit;
TimePeriod = timePeriod;
DelayTime = delayTime;
Behaviour = behaviour;
}
}
}
@@ -0,0 +1,174 @@
using CryptoExchange.Net.Logging.Extensions;
using CryptoExchange.Net.Objects;
using CryptoExchange.Net.RateLimiting.Guards;
using CryptoExchange.Net.RateLimiting.Interfaces;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security;
using System.Threading;
using System.Threading.Tasks;
namespace CryptoExchange.Net.RateLimiting
{
/// <inheritdoc />
public class RateLimitGate : IRateLimitGate
{
private IRateLimitGuard _singleLimitGuard = new SingleLimitGuard(RateLimitWindowType.Sliding);
private readonly ConcurrentBag<IRateLimitGuard> _guards;
private readonly SemaphoreSlim _semaphore;
private readonly string _name;
private int _waitingCount;
/// <inheritdoc />
public event Action<RateLimitEvent>? RateLimitTriggered;
/// <summary>
/// ctor
/// </summary>
public RateLimitGate(string name)
{
_name = name;
_guards = new ConcurrentBag<IRateLimitGuard>();
_semaphore = new SemaphoreSlim(1);
}
/// <inheritdoc />
public async Task<CallResult> ProcessAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, CancellationToken ct)
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
_waitingCount++;
try
{
return await CheckGuardsAsync(_guards, logger, itemId, type, definition, host, apiKey, requestWeight, rateLimitingBehaviour, ct).ConfigureAwait(false);
}
finally
{
_waitingCount--;
_semaphore.Release();
}
}
/// <inheritdoc />
public async Task<CallResult> ProcessSingleAsync(ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, CancellationToken ct)
{
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
if (requestWeight == 0)
requestWeight = 1;
_waitingCount++;
try
{
return await CheckGuardsAsync(new IRateLimitGuard[] { _singleLimitGuard }, logger, itemId, type, definition, host, apiKey, requestWeight, rateLimitingBehaviour, ct).ConfigureAwait(false);
}
finally
{
_waitingCount--;
_semaphore.Release();
}
}
private async Task<CallResult> CheckGuardsAsync(IEnumerable<IRateLimitGuard> guards, ILogger logger, int itemId, RateLimitItemType type, RequestDefinition definition, string host, SecureString? apiKey, int requestWeight, RateLimitingBehaviour rateLimitingBehaviour, CancellationToken ct)
{
foreach (var guard in guards)
{
// Check if a wait is needed for this guard
var result = guard.Check(type, definition, host, apiKey, requestWeight);
if (result.Delay != TimeSpan.Zero && rateLimitingBehaviour == RateLimitingBehaviour.Fail)
{
// Delay is needed and limit behaviour is to fail the request
if (type == RateLimitItemType.Connection)
logger.RateLimitConnectionFailed(itemId, guard.Name, guard.Description);
else
logger.RateLimitRequestFailed(itemId, definition.Path, guard.Name, guard.Description);
RateLimitTriggered?.Invoke(new RateLimitEvent(_name, guard.Description, definition, host, result.Current, requestWeight, result.Limit, result.Period, result.Delay, rateLimitingBehaviour));
return new CallResult(new ClientRateLimitError($"Rate limit check failed on guard {guard.Name}; {guard.Description}"));
}
if (result.Delay != TimeSpan.Zero)
{
// Delay is needed and limit behaviour is to wait for the request to be under the limit
_semaphore.Release();
var description = result.Limit == null ? guard.Description : $"{guard.Description}, Request weight: {requestWeight}, Current: {result.Current}, Limit: {result.Limit}, requests now being limited: {_waitingCount}";
if (type == RateLimitItemType.Connection)
logger.RateLimitDelayingConnection(itemId, result.Delay, guard.Name, description);
else
logger.RateLimitDelayingRequest(itemId, definition.Path, result.Delay, guard.Name, description);
RateLimitTriggered?.Invoke(new RateLimitEvent(_name, guard.Description, definition, host, result.Current, requestWeight, result.Limit, result.Period, result.Delay, rateLimitingBehaviour));
await Task.Delay(result.Delay, ct).ConfigureAwait(false);
await _semaphore.WaitAsync(ct).ConfigureAwait(false);
return await CheckGuardsAsync(guards, logger, itemId, type, definition, host, apiKey, requestWeight, rateLimitingBehaviour, ct).ConfigureAwait(false);
}
}
// Apply the weight on each guard
foreach (var guard in guards)
{
var result = guard.ApplyWeight(type, definition, host, apiKey, requestWeight);
if (result.IsApplied)
{
if (type == RateLimitItemType.Connection)
logger.RateLimitAppliedConnection(itemId, guard.Name, guard.Description, result.Current);
else
logger.RateLimitAppliedRequest(itemId, definition.Path, guard.Name, guard.Description, result.Current);
}
}
return new CallResult(null);
}
/// <inheritdoc />
public IRateLimitGate AddGuard(IRateLimitGuard guard)
{
_guards.Add(guard);
return this;
}
/// <inheritdoc />
public IRateLimitGate SetSingleLimitGuard(SingleLimitGuard guard)
{
_singleLimitGuard = guard;
return this;
}
/// <inheritdoc />
public async Task SetRetryAfterGuardAsync(DateTime retryAfter)
{
await _semaphore.WaitAsync().ConfigureAwait(false);
try
{
var retryAfterGuard = _guards.OfType<RetryAfterGuard>().SingleOrDefault();
if (retryAfterGuard == null)
_guards.Add(new RetryAfterGuard(retryAfter));
else
retryAfterGuard.UpdateAfter(retryAfter);
}
finally
{
_semaphore.Release();
}
}
/// <inheritdoc />
public async Task<DateTime?> GetRetryAfterTime()
{
await _semaphore.WaitAsync().ConfigureAwait(false);
try
{
var retryAfterGuard = _guards.OfType<RetryAfterGuard>().SingleOrDefault();
return retryAfterGuard?.After;
}
finally
{
_semaphore.Release();
}
}
}
}
@@ -0,0 +1,20 @@
using System;
namespace CryptoExchange.Net.RateLimiting
{
/// <summary>
/// Rate limit item type
/// </summary>
[Flags]
public enum RateLimitItemType
{
/// <summary>
/// A connection attempt
/// </summary>
Connection = 1,
/// <summary>
/// A request
/// </summary>
Request = 2
}
}
@@ -0,0 +1,55 @@
using System;
namespace CryptoExchange.Net.RateLimiting
{
/// <summary>
/// Limit state
/// </summary>
public struct RateLimitState
{
/// <summary>
/// Limit
/// </summary>
public int Limit { get; }
/// <summary>
/// Period
/// </summary>
public TimeSpan Period { get; }
/// <summary>
/// Current count
/// </summary>
public int Current { get; }
/// <summary>
/// Whether the limit is applied
/// </summary>
public bool IsApplied { get; set; }
/// <summary>
/// ctor
/// </summary>
/// <param name="applied"></param>
/// <param name="limit"></param>
/// <param name="period"></param>
/// <param name="current"></param>
public RateLimitState(bool applied, int limit, TimeSpan period, int current)
{
IsApplied = applied;
Limit = limit;
Period = period;
Current = current;
}
/// <summary>
/// Not applied result
/// </summary>
public static RateLimitState NotApplied { get; } = new RateLimitState(false, default, default, default);
/// <summary>
/// Applied result
/// </summary>
/// <param name="limit"></param>
/// <param name="period"></param>
/// <param name="current"></param>
/// <returns></returns>
public static RateLimitState Applied(int limit, TimeSpan period, int current) => new RateLimitState(true, limit, period, current);
}
}
@@ -0,0 +1,86 @@
using System;
using CryptoExchange.Net.RateLimiting.Interfaces;
namespace CryptoExchange.Net.RateLimiting.Trackers
{
internal class DecayWindowTracker : IWindowTracker
{
/// <inheritdoc />
public TimeSpan TimePeriod { get; }
/// <summary>
/// Decrease rate per TimePeriod
/// </summary>
public double DecreaseRate { get; }
/// <inheritdoc />
public int Limit { get; }
/// <inheritdoc />
public int Current => _currentWeight;
private int _currentWeight = 0;
private DateTime _lastDecrease = DateTime.UtcNow;
public DecayWindowTracker(int limit, TimeSpan period, double decayRate)
{
Limit = limit;
TimePeriod = period;
DecreaseRate = decayRate;
}
/// <inheritdoc />
public TimeSpan GetWaitTime(int weight)
{
// Decrease the counter based on the last update time and decay rate
DecreaseCounter(DateTime.UtcNow);
if (Current + weight > Limit)
{
// 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}");
}
// Determine the time to wait before this weight can be applied without going over the rate limit
return DetermineWaitTime(weight);
}
// Weight can fit without going over limit
return TimeSpan.Zero;
}
/// <inheritdoc />
public void ApplyWeight(int weight)
{
if (_currentWeight == 0)
_lastDecrease = DateTime.UtcNow;
_currentWeight += weight;
}
/// <summary>
/// Decrease the counter based on time passed since last update and the decay rate
/// </summary>
/// <param name="time"></param>
protected void DecreaseCounter(DateTime time)
{
var dif = (time - _lastDecrease).TotalMilliseconds / TimePeriod.TotalMilliseconds * DecreaseRate;
var decrease = (int)Math.Floor(dif);
if (decrease >= 1)
{
_currentWeight = Math.Max(0, _currentWeight - (int)Math.Floor(dif));
_lastDecrease = time;
}
}
/// <summary>
/// Determine the time to wait before the weight would fit
/// </summary>
/// <param name="requestWeight"></param>
/// <returns></returns>
private TimeSpan DetermineWaitTime(int requestWeight)
{
var weightToRemove = Math.Max(Current - (Limit - requestWeight), 0);
return TimeSpan.FromMilliseconds(Math.Ceiling(weightToRemove / DecreaseRate) * TimePeriod.TotalMilliseconds);
}
}
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using CryptoExchange.Net.RateLimiting.Interfaces;
namespace CryptoExchange.Net.RateLimiting.Trackers
{
internal class FixedAfterStartWindowTracker : IWindowTracker
{
/// <inheritdoc />
public TimeSpan TimePeriod { get; }
/// <inheritdoc />
public int Limit { get; }
/// <inheritdoc />
public int Current => _currentWeight;
private readonly Queue<LimitEntry> _entries;
private int _currentWeight = 0;
private DateTime? _nextReset;
/// <summary>
/// Additional wait time to apply to account for time offset between server and client
/// </summary>
private static TimeSpan _fixedWindowBuffer = TimeSpan.FromMilliseconds(1000);
public FixedAfterStartWindowTracker(int limit, TimeSpan period)
{
Limit = limit;
TimePeriod = period;
_entries = new Queue<LimitEntry>();
}
public TimeSpan GetWaitTime(int weight)
{
// Remove requests no longer in time period from the history
var checkTime = DateTime.UtcNow;
if (_nextReset != null && checkTime > _nextReset)
RemoveBefore(_nextReset.Value);
if (Current == 0)
_nextReset = null;
if (Current + weight > Limit)
{
// 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}");
}
// Determine the time to wait before this weight can be applied without going over the rate limit
return DetermineWaitTime();
}
// Weight can fit without going over limit
return TimeSpan.Zero;
}
/// <inheritdoc />
public void ApplyWeight(int weight)
{
if (_currentWeight == 0)
_nextReset = DateTime.UtcNow + TimePeriod;
_currentWeight += weight;
_entries.Enqueue(new LimitEntry(DateTime.UtcNow, weight));
}
/// <summary>
/// Remove items before a certain time
/// </summary>
/// <param name="time"></param>
protected void RemoveBefore(DateTime time)
{
while (true)
{
if (_entries.Count == 0)
break;
var firstItem = _entries.Peek();
if (firstItem.Timestamp < time)
{
_entries.Dequeue();
_currentWeight -= firstItem.Weight;
}
else
{
// Either no entries left, or the entry time is still within the window
break;
}
}
}
/// <summary>
/// Determine the time to wait before a new item would fit
/// </summary>
/// <returns></returns>
private TimeSpan DetermineWaitTime()
{
var checkTime = DateTime.UtcNow;
return (_nextReset!.Value - checkTime) + _fixedWindowBuffer;
}
}
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using CryptoExchange.Net.RateLimiting.Interfaces;
namespace CryptoExchange.Net.RateLimiting.Trackers
{
internal class FixedWindowTracker : IWindowTracker
{
/// <inheritdoc />
public TimeSpan TimePeriod { get; }
/// <inheritdoc />
public int Limit { get; }
/// <inheritdoc />
public int Current => _currentWeight;
private readonly Queue<LimitEntry> _entries;
private int _currentWeight = 0;
/// <summary>
/// Additional wait time to apply to account for time offset between server and client
/// </summary>
private static readonly TimeSpan _fixedWindowBuffer = TimeSpan.FromMilliseconds(1000);
public FixedWindowTracker(int limit, TimeSpan period)
{
Limit = limit;
TimePeriod = period;
_entries = new Queue<LimitEntry>();
}
/// <inheritdoc />
public TimeSpan GetWaitTime(int weight)
{
// 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)
{
// 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}");
}
// Determine the time to wait before this weight can be applied without going over the rate limit
return DetermineWaitTime();
}
// Weight can fit without going over limit
return TimeSpan.Zero;
}
/// <inheritdoc />
public void ApplyWeight(int weight)
{
_currentWeight += weight;
_entries.Enqueue(new LimitEntry(DateTime.UtcNow, weight));
}
/// <summary>
/// Remove items before a certain time
/// </summary>
/// <param name="time"></param>
protected void RemoveBefore(DateTime time)
{
while (true)
{
if (_entries.Count == 0)
break;
var firstItem = _entries.Peek();
if (firstItem.Timestamp < time)
{
_entries.Dequeue();
_currentWeight -= firstItem.Weight;
}
else
{
// Either no entries left, or the entry time is still within the window
break;
}
}
}
/// <summary>
/// Determine the time to wait before a new item would fit
/// </summary>
/// <returns></returns>
private TimeSpan DetermineWaitTime()
{
var checkTime = DateTime.UtcNow;
var startCurrentWindow = checkTime.AddTicks(-(checkTime.Ticks % TimePeriod.Ticks));
var wait = startCurrentWindow.Add(TimePeriod) - checkTime;
return wait.Add(_fixedWindowBuffer);
}
}
}
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using CryptoExchange.Net.RateLimiting.Interfaces;
namespace CryptoExchange.Net.RateLimiting.Trackers
{
internal class SlidingWindowTracker : IWindowTracker
{
/// <inheritdoc />
public TimeSpan TimePeriod { get; }
/// <inheritdoc />
public int Limit { get; }
/// <inheritdoc />
public int Current => _currentWeight;
private readonly List<LimitEntry> _entries;
private int _currentWeight = 0;
public SlidingWindowTracker(int limit, TimeSpan period)
{
Limit = limit;
TimePeriod = period;
_entries = new List<LimitEntry>();
}
/// <inheritdoc />
public TimeSpan GetWaitTime(int weight)
{
// Remove requests no longer in time period from the history
RemoveBefore(DateTime.UtcNow - TimePeriod);
if (Current + weight > Limit)
{
// 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}");
}
// Determine the time to wait before this weight can be applied without going over the rate limit
return DetermineWaitTime(weight);
}
// Weight can fit without going over limit
return TimeSpan.Zero;
}
/// <inheritdoc />
public void ApplyWeight(int weight)
{
_currentWeight += weight;
_entries.Add(new LimitEntry(DateTime.UtcNow, weight));
}
/// <summary>
/// Remove items before a certain time
/// </summary>
/// <param name="time"></param>
protected void RemoveBefore(DateTime time)
{
for (var i = 0; i < _entries.Count; i++)
{
if (_entries[i].Timestamp < time)
{
var entry = _entries[i];
_entries.Remove(entry);
_currentWeight -= entry.Weight;
i--;
}
else
{
break;
}
}
}
/// <summary>
/// Determine the time to wait before the weight would fit
/// </summary>
/// <returns></returns>
private TimeSpan DetermineWaitTime(int requestWeight)
{
var weightToRemove = Math.Max(Current - (Limit - requestWeight), 0);
var removedWeight = 0;
for (var i = 0; i < _entries.Count; i++)
{
var entry = _entries[i];
removedWeight += entry.Weight;
if (removedWeight >= weightToRemove)
{
return entry.Timestamp + TimePeriod - DateTime.UtcNow;
}
}
throw new Exception("Request not possible to execute with current rate limit guard. " +
$" Request weight: {requestWeight}, Ratelimit: {Limit}");
}
}
}