diff --git a/src/libraries/System.Threading.RateLimiting/ref/System.Threading.RateLimiting.cs b/src/libraries/System.Threading.RateLimiting/ref/System.Threading.RateLimiting.cs index 13e779c61ca797..5d83149cb8f118 100644 --- a/src/libraries/System.Threading.RateLimiting/ref/System.Threading.RateLimiting.cs +++ b/src/libraries/System.Threading.RateLimiting/ref/System.Threading.RateLimiting.cs @@ -113,4 +113,49 @@ public TokenBucketRateLimiterOptions(int tokenLimit, System.Threading.RateLimiti public int TokenLimit { get { throw null; } } public int TokensPerPeriod { get { throw null; } } } + public sealed partial class SlidingWindowRateLimiter : System.Threading.RateLimiting.ReplenishingRateLimiter + { + public SlidingWindowRateLimiter(System.Threading.RateLimiting.SlidingWindowRateLimiterOptions options) { } + public override System.TimeSpan? IdleDuration { get { throw null; } } + public override bool IsAutoReplenishing { get { throw null; } } + public override System.TimeSpan ReplenishmentPeriod { get { throw null; } } + protected override System.Threading.RateLimiting.RateLimitLease AcquireCore(int requestCount) { throw null; } + protected override void Dispose(bool disposing) { } + protected override System.Threading.Tasks.ValueTask DisposeAsyncCore() { throw null; } + public override int GetAvailablePermits() { throw null; } + public override bool TryReplenish() { throw null; } + protected override System.Threading.Tasks.ValueTask WaitAsyncCore(int requestCount, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } + public sealed partial class SlidingWindowRateLimiterOptions + { + public SlidingWindowRateLimiterOptions(int permitLimit, System.Threading.RateLimiting.QueueProcessingOrder queueProcessingOrder, int queueLimit, System.TimeSpan window, int segmentsPerWindow, bool autoReplenishment = true) { } + public bool AutoReplenishment { get { throw null; } } + public int QueueLimit { get { throw null; } } + public System.Threading.RateLimiting.QueueProcessingOrder QueueProcessingOrder { get { throw null; } } + public System.TimeSpan Window { get { throw null; } } + public int PermitLimit { get { throw null; } } + public int SegmentsPerWindow { get { throw null; } } + } + public sealed partial class FixedWindowRateLimiter : System.Threading.RateLimiting.ReplenishingRateLimiter + { + public FixedWindowRateLimiter(System.Threading.RateLimiting.FixedWindowRateLimiterOptions options) { } + public override System.TimeSpan? IdleDuration { get { throw null; } } + public override bool IsAutoReplenishing { get { throw null; } } + public override System.TimeSpan ReplenishmentPeriod { get { throw null; } } + protected override System.Threading.RateLimiting.RateLimitLease AcquireCore(int requestCount) { throw null; } + protected override void Dispose(bool disposing) { } + protected override System.Threading.Tasks.ValueTask DisposeAsyncCore() { throw null; } + public override int GetAvailablePermits() { throw null; } + public override bool TryReplenish() { throw null; } + protected override System.Threading.Tasks.ValueTask WaitAsyncCore(int requestCount, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } + public sealed partial class FixedWindowRateLimiterOptions + { + public FixedWindowRateLimiterOptions(int permitLimit, System.Threading.RateLimiting.QueueProcessingOrder queueProcessingOrder, int queueLimit, System.TimeSpan window, bool autoReplenishment = true) { } + public bool AutoReplenishment { get { throw null; } } + public int QueueLimit { get { throw null; } } + public System.Threading.RateLimiting.QueueProcessingOrder QueueProcessingOrder { get { throw null; } } + public System.TimeSpan Window { get { throw null; } } + public int PermitLimit { get { throw null; } } + } } diff --git a/src/libraries/System.Threading.RateLimiting/src/System.Threading.RateLimiting.csproj b/src/libraries/System.Threading.RateLimiting/src/System.Threading.RateLimiting.csproj index 90582d65edee1a..edcf6445f88cab 100644 --- a/src/libraries/System.Threading.RateLimiting/src/System.Threading.RateLimiting.csproj +++ b/src/libraries/System.Threading.RateLimiting/src/System.Threading.RateLimiting.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent);$(NetCoreAppMinimum);netstandard2.0;$(NetFrameworkMinimum) true @@ -16,6 +16,8 @@ System.Threading.RateLimiting.RateLimitLease + + @@ -23,6 +25,8 @@ System.Threading.RateLimiting.RateLimitLease + + diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiter.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiter.cs new file mode 100644 index 00000000000000..e6fbff009b31d6 --- /dev/null +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiter.cs @@ -0,0 +1,423 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace System.Threading.RateLimiting +{ + /// + /// implementation that refreshes allowed permits in a window periodically. + /// + public sealed class FixedWindowRateLimiter : ReplenishingRateLimiter + { + private int _requestCount; + private int _queueCount; + private long _lastReplenishmentTick; + private long? _idleSince; + private bool _disposed; + + private readonly Timer? _renewTimer; + private readonly FixedWindowRateLimiterOptions _options; + private readonly Deque _queue = new Deque(); + + private object Lock => _queue; + + private static readonly RateLimitLease SuccessfulLease = new FixedWindowLease(true, null); + private static readonly RateLimitLease FailedLease = new FixedWindowLease(false, null); + private static readonly double TickFrequency = (double)TimeSpan.TicksPerSecond / Stopwatch.Frequency; + + /// + public override TimeSpan? IdleDuration => _idleSince is null ? null : new TimeSpan((long)((Stopwatch.GetTimestamp() - _idleSince) * TickFrequency)); + + /// + public override bool IsAutoReplenishing => _options.AutoReplenishment; + + /// + public override TimeSpan ReplenishmentPeriod => _options.Window; + + /// + /// Initializes the . + /// + /// Options to specify the behavior of the . + public FixedWindowRateLimiter(FixedWindowRateLimiterOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _requestCount = options.PermitLimit; + + _idleSince = _lastReplenishmentTick = Stopwatch.GetTimestamp(); + + if (_options.AutoReplenishment) + { + _renewTimer = new Timer(Replenish, this, _options.Window, _options.Window); + } + } + + /// + public override int GetAvailablePermits() => _requestCount; + + /// + protected override RateLimitLease AcquireCore(int requestCount) + { + // These amounts of resources can never be acquired + // Raises a PermitLimitExceeded ArgumentOutOFRangeException + if (requestCount > _options.PermitLimit) + { + throw new ArgumentOutOfRangeException(nameof(requestCount), requestCount, SR.Format(SR.PermitLimitExceeded, requestCount, _options.PermitLimit)); + } + + // Return SuccessfulLease or FailedLease depending to indicate limiter state + if (requestCount == 0 && !_disposed) + { + // Check if the requests are permitted in a window + // Requests will be allowed if the total served request is less than the max allowed requests (permit limit). + if (_requestCount > 0) + { + return SuccessfulLease; + } + + return CreateFailedWindowLease(requestCount); + } + + lock (Lock) + { + if (TryLeaseUnsynchronized(requestCount, out RateLimitLease? lease)) + { + return lease; + } + + return CreateFailedWindowLease(requestCount); + } + } + + /// + protected override ValueTask WaitAsyncCore(int requestCount, CancellationToken cancellationToken = default) + { + // These amounts of resources can never be acquired + if (requestCount > _options.PermitLimit) + { + throw new ArgumentOutOfRangeException(nameof(requestCount), requestCount, SR.Format(SR.PermitLimitExceeded, requestCount, _options.PermitLimit)); + } + + ThrowIfDisposed(); + + // Return SuccessfulAcquisition if requestCount is 0 and resources are available + if (requestCount == 0 && _requestCount > 0) + { + return new ValueTask(SuccessfulLease); + } + + lock (Lock) + { + if (TryLeaseUnsynchronized(requestCount, out RateLimitLease? lease)) + { + return new ValueTask(lease); + } + + // Avoid integer overflow by using subtraction instead of addition + Debug.Assert(_options.QueueLimit >= _queueCount); + if (_options.QueueLimit - _queueCount < requestCount) + { + if (_options.QueueProcessingOrder == QueueProcessingOrder.NewestFirst && requestCount <= _options.QueueLimit) + { + // remove oldest items from queue until there is space for the newest acquisition request + do + { + RequestRegistration oldestRequest = _queue.DequeueHead(); + _queueCount -= oldestRequest.Count; + Debug.Assert(_queueCount >= 0); + oldestRequest.Tcs.TrySetResult(FailedLease); + } + while (_options.QueueLimit - _queueCount < requestCount); + } + else + { + // Don't queue if queue limit reached and QueueProcessingOrder is OldestFirst + return new ValueTask(CreateFailedWindowLease(requestCount)); + } + } + + CancelQueueState tcs = new CancelQueueState(requestCount, this, cancellationToken); + CancellationTokenRegistration ctr = default; + if (cancellationToken.CanBeCanceled) + { + ctr = cancellationToken.Register(static obj => + { + ((CancelQueueState)obj!).TrySetCanceled(); + }, tcs); + } + + RequestRegistration registration = new RequestRegistration(requestCount, tcs, ctr); + _queue.EnqueueTail(registration); + _queueCount += requestCount; + Debug.Assert(_queueCount <= _options.QueueLimit); + + return new ValueTask(registration.Tcs.Task); + } + } + + private RateLimitLease CreateFailedWindowLease(int requestCount) + { + int replenishAmount = requestCount - _requestCount + _queueCount; + // can't have 0 replenish window, that would mean it should be a successful lease + int replenishWindow = Math.Max(replenishAmount / _options.PermitLimit, 1); + + return new FixedWindowLease(false, TimeSpan.FromTicks(_options.Window.Ticks * replenishWindow)); + } + + private bool TryLeaseUnsynchronized(int requestCount, [NotNullWhen(true)] out RateLimitLease? lease) + { + ThrowIfDisposed(); + + // if permitCount is 0 we want to queue it if there are no available permits + if (_requestCount >= requestCount && _requestCount != 0) + { + if (requestCount == 0) + { + // Edge case where the check before the lock showed 0 available permit counters but when we got the lock, some permits were now available + lease = SuccessfulLease; + return true; + } + + // a. If there are no items queued we can lease + // b. If there are items queued but the processing order is newest first, then we can lease the incoming request since it is the newest + if (_queueCount == 0 || (_queueCount > 0 && _options.QueueProcessingOrder == QueueProcessingOrder.NewestFirst)) + { + _idleSince = null; + _requestCount -= requestCount; + Debug.Assert(_requestCount >= 0); + lease = SuccessfulLease; + return true; + } + } + + lease = null; + return false; + } + + /// + /// Attempts to replenish request counters in the window. + /// + /// + /// False if is enabled, otherwise true. + /// Does not reflect if counters were replenished. + /// + public override bool TryReplenish() + { + if (_options.AutoReplenishment) + { + return false; + } + Replenish(this); + return true; + } + + private static void Replenish(object? state) + { + FixedWindowRateLimiter limiter = (state as FixedWindowRateLimiter)!; + Debug.Assert(limiter is not null); + + // Use Stopwatch instead of DateTime.UtcNow to avoid issues on systems where the clock can change + long nowTicks = Stopwatch.GetTimestamp(); + limiter!.ReplenishInternal(nowTicks); + } + + // Used in tests that test behavior with specific time intervals + private void ReplenishInternal(long nowTicks) + { + // Method is re-entrant (from Timer), lock to avoid multiple simultaneous replenishes + lock (Lock) + { + if (_disposed) + { + return; + } + + if ((long)((nowTicks - _lastReplenishmentTick) * TickFrequency) < _options.Window.Ticks) + { + return; + } + + _lastReplenishmentTick = nowTicks; + + int availableRequestCounters = _requestCount; + int maxPermits = _options.PermitLimit; + int resourcesToAdd; + + if (availableRequestCounters < maxPermits) + { + resourcesToAdd = maxPermits - availableRequestCounters; + } + else + { + // All counters available, nothing to do + return; + } + + _requestCount += resourcesToAdd; + Debug.Assert(_requestCount == _options.PermitLimit); + + // Process queued requests + while (_queue.Count > 0) + { + RequestRegistration nextPendingRequest = + _options.QueueProcessingOrder == QueueProcessingOrder.OldestFirst + ? _queue.PeekHead() + : _queue.PeekTail(); + + if (_requestCount >= nextPendingRequest.Count) + { + // Request can be fulfilled + nextPendingRequest = + _options.QueueProcessingOrder == QueueProcessingOrder.OldestFirst + ? _queue.DequeueHead() + : _queue.DequeueTail(); + + _queueCount -= nextPendingRequest.Count; + _requestCount -= nextPendingRequest.Count; + Debug.Assert(_requestCount >= 0); + + if (!nextPendingRequest.Tcs.TrySetResult(SuccessfulLease)) + { + // Queued item was canceled so add count back + _requestCount += nextPendingRequest.Count; + // Updating queue count is handled by the cancellation code + _queueCount += nextPendingRequest.Count; + } + nextPendingRequest.CancellationTokenRegistration.Dispose(); + Debug.Assert(_queueCount >= 0); + } + else + { + // Request cannot be fulfilled + break; + } + } + + if (_requestCount == _options.PermitLimit) + { + Debug.Assert(_idleSince is null); + Debug.Assert(_queueCount == 0); + _idleSince = Stopwatch.GetTimestamp(); + } + } + } + + protected override void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + lock (Lock) + { + if (_disposed) + { + return; + } + _disposed = true; + _renewTimer?.Dispose(); + while (_queue.Count > 0) + { + RequestRegistration next = _options.QueueProcessingOrder == QueueProcessingOrder.OldestFirst + ? _queue.DequeueHead() + : _queue.DequeueTail(); + next.CancellationTokenRegistration.Dispose(); + next.Tcs.SetResult(FailedLease); + } + } + } + + protected override ValueTask DisposeAsyncCore() + { + Dispose(true); + + return default; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(FixedWindowRateLimiter)); + } + } + + private sealed class FixedWindowLease : RateLimitLease + { + private static readonly string[] s_allMetadataNames = new[] { MetadataName.RetryAfter.Name }; + + private readonly TimeSpan? _retryAfter; + + public FixedWindowLease(bool isAcquired, TimeSpan? retryAfter) + { + IsAcquired = isAcquired; + _retryAfter = retryAfter; + } + + public override bool IsAcquired { get; } + + public override IEnumerable MetadataNames => s_allMetadataNames; + + public override bool TryGetMetadata(string metadataName, out object? metadata) + { + if (metadataName == MetadataName.RetryAfter.Name && _retryAfter.HasValue) + { + metadata = _retryAfter.Value; + return true; + } + + metadata = default; + return false; + } + } + + private readonly struct RequestRegistration + { + public RequestRegistration(int requestCount, TaskCompletionSource tcs, CancellationTokenRegistration cancellationTokenRegistration) + { + Count = requestCount; + // Use VoidAsyncOperationWithData instead + Tcs = tcs; + CancellationTokenRegistration = cancellationTokenRegistration; + } + + public int Count { get; } + + public TaskCompletionSource Tcs { get; } + + public CancellationTokenRegistration CancellationTokenRegistration { get; } + } + + private sealed class CancelQueueState : TaskCompletionSource + { + private readonly int _requestCount; + private readonly FixedWindowRateLimiter _limiter; + private readonly CancellationToken _cancellationToken; + + public CancelQueueState(int requestCount, FixedWindowRateLimiter limiter, CancellationToken cancellationToken) + : base(TaskCreationOptions.RunContinuationsAsynchronously) + { + _requestCount = requestCount; + _limiter = limiter; + _cancellationToken = cancellationToken; + } + + public new bool TrySetCanceled() + { + if (TrySetCanceled(_cancellationToken)) + { + lock (_limiter.Lock) + { + _limiter._queueCount -= _requestCount; + } + return true; + } + return false; + } + } + } +} diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiterOptions.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiterOptions.cs new file mode 100644 index 00000000000000..0b8693b479230b --- /dev/null +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/FixedWindowRateLimiterOptions.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Threading.RateLimiting +{ + /// + /// Options to specify the behavior of a . + /// + public sealed class FixedWindowRateLimiterOptions + { + /// + /// Initializes the . + /// + /// Maximum number of requests that can be served in the window. + /// + /// Maximum number of unprocessed request counters waiting via . + /// + /// Specifies how often request counters can be replenished. Replenishing is triggered either by an internal timer if is true, or by calling . + /// + /// + /// Specifies whether request replenishment will be handled by the or by another party via . + /// + /// When or are less than 0. + public FixedWindowRateLimiterOptions( + int permitLimit, + QueueProcessingOrder queueProcessingOrder, + int queueLimit, + TimeSpan window, + bool autoReplenishment = true) + { + if (permitLimit < 0) + { + throw new ArgumentOutOfRangeException(nameof(permitLimit)); + } + if (queueLimit < 0) + { + throw new ArgumentOutOfRangeException(nameof(queueLimit)); + } + + PermitLimit = permitLimit; + QueueProcessingOrder = queueProcessingOrder; + QueueLimit = queueLimit; + Window = window; + AutoReplenishment = autoReplenishment; + } + + /// + /// Specifies the time window that takes in the requests. + /// + public TimeSpan Window { get; } + + /// + /// Specified whether the is automatically refresh counters or if someone else + /// will be calling to refresh counters. + /// + public bool AutoReplenishment { get; } + + /// + /// Maximum number of permit counters that can be allowed in a window. + /// + public int PermitLimit { get; } + + /// + /// Determines the behaviour of when not enough resources can be leased. + /// + /// + /// by default. + /// + public QueueProcessingOrder QueueProcessingOrder { get; } + + /// + /// Maximum cumulative permit count of queued acquisition requests. + /// + public int QueueLimit { get; } + } +} diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiter.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiter.cs new file mode 100644 index 00000000000000..3f28de48e1bd95 --- /dev/null +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiter.cs @@ -0,0 +1,422 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace System.Threading.RateLimiting +{ + /// + /// implementation that replenishes permit counters periodically instead of via a release mechanism. + /// + public sealed class SlidingWindowRateLimiter : ReplenishingRateLimiter + { + private int _requestCount; + private int _queueCount; + private int[] _requestsPerSegment; + private int _currentSegmentIndex; + private long _lastReplenishmentTick; + private long? _idleSince; + private bool _disposed; + + private readonly Timer? _renewTimer; + private readonly SlidingWindowRateLimiterOptions _options; + private readonly Deque _queue = new Deque(); + + // Use the queue as the lock field so we don't need to allocate another object for a lock and have another field in the object + private object Lock => _queue; + + private static readonly RateLimitLease SuccessfulLease = new SlidingWindowLease(true, null); + private static readonly RateLimitLease FailedLease = new SlidingWindowLease(false, null); + private static readonly double TickFrequency = (double)TimeSpan.TicksPerSecond / Stopwatch.Frequency; + + /// + public override TimeSpan? IdleDuration => _idleSince is null ? null : new TimeSpan((long)((Stopwatch.GetTimestamp() - _idleSince) * TickFrequency)); + + /// + public override bool IsAutoReplenishing => _options.AutoReplenishment; + + /// + public override TimeSpan ReplenishmentPeriod => new TimeSpan(_options.Window.Ticks / _options.SegmentsPerWindow); + + /// + /// Initializes the . + /// + /// Options to specify the behavior of the . + public SlidingWindowRateLimiter(SlidingWindowRateLimiterOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _requestCount = options.PermitLimit; + + // _requestsPerSegment holds the no. of acquired requests in each window segment + _requestsPerSegment = new int[options.SegmentsPerWindow]; + _currentSegmentIndex = 0; + + _idleSince = _lastReplenishmentTick = Stopwatch.GetTimestamp(); + + if (_options.AutoReplenishment) + { + _renewTimer = new Timer(Replenish, this, ReplenishmentPeriod, ReplenishmentPeriod); + } + } + + /// + public override int GetAvailablePermits() => _requestCount; + + /// + protected override RateLimitLease AcquireCore(int requestCount) + { + // These amounts of resources can never be acquired + if (requestCount > _options.PermitLimit) + { + throw new ArgumentOutOfRangeException(nameof(requestCount), requestCount, SR.Format(SR.PermitLimitExceeded, requestCount, _options.PermitLimit)); + } + + // Return SuccessfulLease or FailedLease depending to indicate limiter state + if (requestCount == 0 && !_disposed) + { + if (_requestCount > 0) + { + return SuccessfulLease; + } + + return FailedLease; + } + + lock (Lock) + { + if (TryLeaseUnsynchronized(requestCount, out RateLimitLease? lease)) + { + return lease; + } + + // TODO: Acquire additional metadata during a failed lease decision + return FailedLease; + } + } + + /// + protected override ValueTask WaitAsyncCore(int requestCount, CancellationToken cancellationToken = default) + { + // These amounts of resources can never be acquired + if (requestCount > _options.PermitLimit) + { + throw new ArgumentOutOfRangeException(nameof(requestCount), requestCount, SR.Format(SR.PermitLimitExceeded, requestCount, _options.PermitLimit)); + } + + ThrowIfDisposed(); + + // Return SuccessfulAcquisition if resources are available + if (requestCount == 0 && _requestCount > 0) + { + return new ValueTask(SuccessfulLease); + } + + lock (Lock) + { + if (TryLeaseUnsynchronized(requestCount, out RateLimitLease? lease)) + { + return new ValueTask(lease); + } + + // Avoid integer overflow by using subtraction instead of addition + Debug.Assert(_options.QueueLimit >= _queueCount); + if (_options.QueueLimit - _queueCount < requestCount) + { + if (_options.QueueProcessingOrder == QueueProcessingOrder.NewestFirst && requestCount <= _options.QueueLimit) + { + // Remove oldest items from queue until there is space for the newest acquisition request + do + { + RequestRegistration oldestRequest = _queue.DequeueHead(); + _queueCount -= oldestRequest.Count; + Debug.Assert(_queueCount >= 0); + oldestRequest.Tcs.TrySetResult(FailedLease); + } + while (_options.QueueLimit - _queueCount < requestCount); + } + else + { + // Don't queue if queue limit reached and QueueProcessingOrder is OldestFirst + return new ValueTask(FailedLease); + } + } + + CancelQueueState tcs = new CancelQueueState(requestCount, this, cancellationToken); + CancellationTokenRegistration ctr = default; + if (cancellationToken.CanBeCanceled) + { + ctr = cancellationToken.Register(static obj => + { + ((CancelQueueState)obj!).TrySetCanceled(); + }, tcs); + } + + RequestRegistration registration = new RequestRegistration(requestCount, tcs, ctr); + _queue.EnqueueTail(registration); + _queueCount += requestCount; + Debug.Assert(_queueCount <= _options.QueueLimit); + + return new ValueTask(registration.Tcs.Task); + } + } + + private bool TryLeaseUnsynchronized(int requestCount, [NotNullWhen(true)] out RateLimitLease? lease) + { + ThrowIfDisposed(); + + // if requestCount is 0 we want to queue it if there are no available permits + if (_requestCount >= requestCount && _requestCount != 0) + { + if (requestCount == 0) + { + // Edge case where the check before the lock showed 0 available permits but when we got the lock some permits were now available + lease = SuccessfulLease; + return true; + } + + // a. If there are no items queued we can lease + // b. If there are items queued but the processing order is NewestFirst, then we can lease the incoming request since it is the newest + if (_queueCount == 0 || (_queueCount > 0 && _options.QueueProcessingOrder == QueueProcessingOrder.NewestFirst)) + { + _idleSince = null; + _requestsPerSegment[_currentSegmentIndex] += requestCount; + _requestCount -= requestCount; + Debug.Assert(_requestCount >= 0); + lease = SuccessfulLease; + return true; + } + } + + lease = null; + return false; + } + + /// + /// Attempts to replenish request counters in a window. + /// + /// + /// False if is enabled, otherwise true. + /// Does not reflect if permits were replenished. + /// + public override bool TryReplenish() + { + if (_options.AutoReplenishment) + { + return false; + } + + // Replenish call will slide the window one segment at a time + Replenish(this); + return true; + } + + private static void Replenish(object? state) + { + SlidingWindowRateLimiter limiter = (state as SlidingWindowRateLimiter)!; + Debug.Assert(limiter is not null); + + // Use Stopwatch instead of DateTime.UtcNow to avoid issues on systems where the clock can change + long nowTicks = Stopwatch.GetTimestamp(); + limiter!.ReplenishInternal(nowTicks); + } + + // Used in tests that test behavior with specific time intervals + private void ReplenishInternal(long nowTicks) + { + // Method is re-entrant (from Timer), lock to avoid multiple simultaneous replenishes + lock (Lock) + { + if (_disposed) + { + return; + } + + if ((long)((nowTicks - _lastReplenishmentTick) * TickFrequency) < ReplenishmentPeriod.Ticks) + { + return; + } + + _lastReplenishmentTick = nowTicks; + + // Increment the current segment index while move the window + // We need to know the no. of requests that were acquired in a segment previously to ensure that we don't acquire more than the permit limit. + _currentSegmentIndex = (_currentSegmentIndex + 1) % _options.SegmentsPerWindow; + int oldSegmentRequestCount = _requestsPerSegment[_currentSegmentIndex]; + _requestsPerSegment[_currentSegmentIndex] = 0; + + if (oldSegmentRequestCount == 0) + { + return; + } + + _requestCount += oldSegmentRequestCount; + Debug.Assert(_requestCount <= _options.PermitLimit); + + // Process queued requests + while (_queue.Count > 0) + { + RequestRegistration nextPendingRequest = + _options.QueueProcessingOrder == QueueProcessingOrder.OldestFirst + ? _queue.PeekHead() + : _queue.PeekTail(); + + // If we have enough permits after replenishing to serve the queued requests + if (_requestCount >= nextPendingRequest.Count) + { + // Request can be fulfilled + nextPendingRequest = + _options.QueueProcessingOrder == QueueProcessingOrder.OldestFirst + ? _queue.DequeueHead() + : _queue.DequeueTail(); + + _queueCount -= nextPendingRequest.Count; + _requestCount -= nextPendingRequest.Count; + _requestsPerSegment[_currentSegmentIndex] += nextPendingRequest.Count; + Debug.Assert(_requestCount >= 0); + + if (!nextPendingRequest.Tcs.TrySetResult(SuccessfulLease)) + { + // Queued item was canceled so add count back + _requestCount += nextPendingRequest.Count; + _requestsPerSegment[_currentSegmentIndex] -= nextPendingRequest.Count; + // Updating queue count is handled by the cancellation code + _queueCount += nextPendingRequest.Count; + } + nextPendingRequest.CancellationTokenRegistration.Dispose(); + Debug.Assert(_queueCount >= 0); + } + else + { + // Request cannot be fulfilled + break; + } + } + + if (_requestCount == _options.PermitLimit) + { + Debug.Assert(_idleSince is null); + Debug.Assert(_queueCount == 0); + _idleSince = Stopwatch.GetTimestamp(); + } + } + } + + protected override void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + lock (Lock) + { + if (_disposed) + { + return; + } + _disposed = true; + _renewTimer?.Dispose(); + while (_queue.Count > 0) + { + RequestRegistration next = _options.QueueProcessingOrder == QueueProcessingOrder.OldestFirst + ? _queue.DequeueHead() + : _queue.DequeueTail(); + next.CancellationTokenRegistration.Dispose(); + next.Tcs.SetResult(FailedLease); + } + } + } + + protected override ValueTask DisposeAsyncCore() + { + Dispose(true); + + return default; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(SlidingWindowRateLimiter)); + } + } + + private sealed class SlidingWindowLease : RateLimitLease + { + private static readonly string[] s_allMetadataNames = new[] { MetadataName.RetryAfter.Name }; + + private readonly TimeSpan? _retryAfter; + + public SlidingWindowLease(bool isAcquired, TimeSpan? retryAfter) + { + IsAcquired = isAcquired; + _retryAfter = retryAfter; + } + + public override bool IsAcquired { get; } + + public override IEnumerable MetadataNames => s_allMetadataNames; + + public override bool TryGetMetadata(string metadataName, out object? metadata) + { + if (metadataName == MetadataName.RetryAfter.Name && _retryAfter.HasValue) + { + metadata = _retryAfter.Value; + return true; + } + + metadata = default; + return false; + } + } + + private readonly struct RequestRegistration + { + public RequestRegistration(int requestCount, TaskCompletionSource tcs, CancellationTokenRegistration cancellationTokenRegistration) + { + Count = requestCount; + // Use VoidAsyncOperationWithData instead + Tcs = tcs; + CancellationTokenRegistration = cancellationTokenRegistration; + } + + public int Count { get; } + + public TaskCompletionSource Tcs { get; } + + public CancellationTokenRegistration CancellationTokenRegistration { get; } + } + + private sealed class CancelQueueState : TaskCompletionSource + { + private readonly int _requestCount; + private readonly SlidingWindowRateLimiter _limiter; + private readonly CancellationToken _cancellationToken; + + public CancelQueueState(int requestCount, SlidingWindowRateLimiter limiter, CancellationToken cancellationToken) + : base(TaskCreationOptions.RunContinuationsAsynchronously) + { + _requestCount = requestCount; + _limiter = limiter; + _cancellationToken = cancellationToken; + } + + public new bool TrySetCanceled() + { + if (TrySetCanceled(_cancellationToken)) + { + lock (_limiter.Lock) + { + _limiter._queueCount -= _requestCount; + } + return true; + } + return false; + } + } + } +} diff --git a/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiterOptions.cs b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiterOptions.cs new file mode 100644 index 00000000000000..159b4338f07d2b --- /dev/null +++ b/src/libraries/System.Threading.RateLimiting/src/System/Threading/RateLimiting/SlidingWindowRateLimiterOptions.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Threading.RateLimiting +{ + /// + /// Options to specify the behavior of a . + /// + public sealed class SlidingWindowRateLimiterOptions + { + /// + /// Initializes the . + /// + /// Maximum number of request counters that can be served in a window. + /// + /// Maximum number of unprocessed request counters waiting via . + /// + /// Specifies how often requests can be replenished. Replenishing is triggered either by an internal timer if is true, or by calling . + /// + /// Specified how many segments a window can be divided into. The total requests a segment can serve cannot exceed the max limit.. + /// + /// Specifies whether request replenishment will be handled by the or by another party via . + /// + /// When , , or are less than 0. + public SlidingWindowRateLimiterOptions( + int permitLimit, + QueueProcessingOrder queueProcessingOrder, + int queueLimit, + TimeSpan window, + int segmentsPerWindow, + bool autoReplenishment = true) + { + if (permitLimit < 0) + { + throw new ArgumentOutOfRangeException(nameof(permitLimit)); + } + if (queueLimit < 0) + { + throw new ArgumentOutOfRangeException(nameof(queueLimit)); + } + if (segmentsPerWindow <= 0) + { + throw new ArgumentOutOfRangeException(nameof(segmentsPerWindow)); + } + + PermitLimit = permitLimit; + QueueProcessingOrder = queueProcessingOrder; + QueueLimit = queueLimit; + Window = window; + SegmentsPerWindow = segmentsPerWindow; + AutoReplenishment = autoReplenishment; + } + + /// + /// Specifies the minimum period between replenishments. + /// + public TimeSpan Window { get; } + + /// + /// Specifies the maximum number of segments a window is divided into. + /// + public int SegmentsPerWindow { get; } + + /// + /// Specified whether the is automatically replenishing request counters or if someone else + /// will be calling to replenish tokens. + /// + public bool AutoReplenishment { get; } + + /// + /// Maximum number of requests that can be served in a window. + /// + public int PermitLimit { get; } + + /// + /// Determines the behaviour of when not enough resources can be leased. + /// + /// + /// by default. + /// + public QueueProcessingOrder QueueProcessingOrder { get; } + + /// + /// Maximum cumulative permit count of queued acquisition requests. + /// + public int QueueLimit { get; } + } +} diff --git a/src/libraries/System.Threading.RateLimiting/tests/FixedWindowRateLimiterTests.cs b/src/libraries/System.Threading.RateLimiting/tests/FixedWindowRateLimiterTests.cs new file mode 100644 index 00000000000000..f83b5c6a48b542 --- /dev/null +++ b/src/libraries/System.Threading.RateLimiting/tests/FixedWindowRateLimiterTests.cs @@ -0,0 +1,694 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Threading.Tasks; +using Xunit; + +namespace System.Threading.RateLimiting.Test +{ + public class FixedWindowRateLimiterTests : BaseRateLimiterTests + { + [Fact] + public override void CanAcquireResource() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(); + + Assert.True(lease.IsAcquired); + Assert.False(limiter.Acquire().IsAcquired); + + lease.Dispose(); + Assert.False(limiter.Acquire().IsAcquired); + Assert.True(limiter.TryReplenish()); + + Assert.True(limiter.Acquire().IsAcquired); + } + + [Fact] + public override void InvalidOptionsThrows() + { + Assert.Throws( + () => new FixedWindowRateLimiterOptions(-1, QueueProcessingOrder.NewestFirst, 1, TimeSpan.FromMinutes(2), autoReplenishment: false)); + Assert.Throws( + () => new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, -1, TimeSpan.FromMinutes(2), autoReplenishment: false)); + } + + [Fact] + public override async Task CanAcquireResourceAsync() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + + using var lease = await limiter.WaitAsync(); + + Assert.True(lease.IsAcquired); + var wait = limiter.WaitAsync(); + Assert.False(wait.IsCompleted); + + Assert.True(limiter.TryReplenish()); + + Assert.True((await wait).IsAcquired); + } + + [Fact] + public override async Task CanAcquireResourceAsync_QueuesAndGrabsOldest() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.Zero, autoReplenishment: false)); + var lease = await limiter.WaitAsync(); + + Assert.True(lease.IsAcquired); + var wait1 = limiter.WaitAsync(); + var wait2 = limiter.WaitAsync(); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + lease = await wait1; + Assert.True(lease.IsAcquired); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.Equal(0, limiter.GetAvailablePermits()); + Assert.True(limiter.TryReplenish()); + + lease = await wait2; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CanAcquireResourceAsync_QueuesAndGrabsNewest() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 3, + TimeSpan.FromMinutes(0), autoReplenishment: false)); + + var lease = await limiter.WaitAsync(2); + Assert.True(lease.IsAcquired); + + var wait1 = limiter.WaitAsync(2); + var wait2 = limiter.WaitAsync(); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + // second queued item completes first with NewestFirst + lease = await wait2; + Assert.True(lease.IsAcquired); + Assert.False(wait1.IsCompleted); + + lease.Dispose(); + Assert.Equal(1, limiter.GetAvailablePermits()); + Assert.True(limiter.TryReplenish()); + + lease = await wait1; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task FailsWhenQueuingMoreThanLimit_OldestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + var wait = limiter.WaitAsync(1); + + var failedLease = await limiter.WaitAsync(1); + Assert.False(failedLease.IsAcquired); + Assert.True(failedLease.TryGetMetadata(MetadataName.RetryAfter, out var timeSpan)); + Assert.Equal(TimeSpan.Zero, timeSpan); + } + + [Fact] + public override async Task DropsOldestWhenQueuingMoreThanLimit_NewestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(1); + var wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(1); + var lease1 = await wait; + Assert.False(lease1.IsAcquired); + Assert.False(wait2.IsCompleted); + + limiter.TryReplenish(); + + lease = await wait2; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task DropsMultipleOldestWhenQueuingMoreThanLimit_NewestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 2, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(2); + Assert.True(lease.IsAcquired); + var wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(1); + Assert.False(wait2.IsCompleted); + + var wait3 = limiter.WaitAsync(2); + var lease1 = await wait; + var lease2 = await wait2; + Assert.False(lease1.IsAcquired); + Assert.False(lease2.IsAcquired); + Assert.False(wait3.IsCompleted); + + limiter.TryReplenish(); + + lease = await wait3; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task DropsRequestedLeaseIfPermitCountGreaterThanQueueLimitAndNoAvailability_NewestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(2); + Assert.True(lease.IsAcquired); + + // Fill queue + var wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + var lease1 = await limiter.WaitAsync(2); + Assert.False(lease1.IsAcquired); + + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task QueueAvailableAfterQueueLimitHitAndResources_BecomeAvailable() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(1); + var wait = limiter.WaitAsync(1); + + var failedLease = await limiter.WaitAsync(1); + Assert.False(failedLease.IsAcquired); + + limiter.TryReplenish(); + lease = await wait; + Assert.True(lease.IsAcquired); + + wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task LargeAcquiresAndQueuesDoNotIntegerOverflow() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(int.MaxValue, QueueProcessingOrder.NewestFirst, int.MaxValue, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(int.MaxValue); + Assert.True(lease.IsAcquired); + + // Fill queue + var wait = limiter.WaitAsync(3); + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(int.MaxValue); + Assert.False(wait2.IsCompleted); + + var lease1 = await wait; + Assert.False(lease1.IsAcquired); + + limiter.TryReplenish(); + var lease2 = await wait2; + Assert.True(lease2.IsAcquired); + } + + [Fact] + public override void ThrowsWhenAcquiringMoreThanLimit() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + Assert.Throws(() => limiter.Acquire(2)); + } + + [Fact] + public override async Task ThrowsWhenWaitingForMoreThanLimit() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + await Assert.ThrowsAsync(async () => await limiter.WaitAsync(2)); + } + + [Fact] + public override void ThrowsWhenAcquiringLessThanZero() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + Assert.Throws(() => limiter.Acquire(-1)); + } + + [Fact] + public override async Task ThrowsWhenWaitingForLessThanZero() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + await Assert.ThrowsAsync(async () => await limiter.WaitAsync(-1)); + } + + [Fact] + public override void AcquireZero_WithAvailability() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + + using var lease = limiter.Acquire(0); + Assert.True(lease.IsAcquired); + } + + [Fact] + public override void AcquireZero_WithoutAvailability() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var lease2 = limiter.Acquire(0); + Assert.False(lease2.IsAcquired); + lease2.Dispose(); + } + + [Fact] + public override async Task WaitAsyncZero_WithAvailability() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + + using var lease = await limiter.WaitAsync(0); + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task WaitAsyncZero_WithoutAvailabilityWaitsForAvailability() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = await limiter.WaitAsync(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(0); + Assert.False(wait.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + using var lease2 = await wait; + Assert.True(lease2.IsAcquired); + } + + [Fact] + public override async Task CanDequeueMultipleResourcesAtOnce() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.Zero, autoReplenishment: false)); + using var lease = await limiter.WaitAsync(2); + Assert.True(lease.IsAcquired); + + var wait1 = limiter.WaitAsync(1); + var wait2 = limiter.WaitAsync(1); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + var lease1 = await wait1; + var lease2 = await wait2; + Assert.True(lease1.IsAcquired); + Assert.True(lease2.IsAcquired); + } + + [Fact] + public override async Task CanCancelWaitAsyncAfterQueuing() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var cts = new CancellationTokenSource(); + var wait = limiter.WaitAsync(1, cts.Token); + + cts.Cancel(); + var ex = await Assert.ThrowsAsync(() => wait.AsTask()); + Assert.Equal(cts.Token, ex.CancellationToken); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + Assert.Equal(1, limiter.GetAvailablePermits()); + } + + [Fact] + public override async Task CanCancelWaitAsyncBeforeQueuing() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Assert.ThrowsAsync(() => limiter.WaitAsync(1, cts.Token).AsTask()); + Assert.Equal(cts.Token, ex.CancellationToken); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + Assert.Equal(1, limiter.GetAvailablePermits()); + } + + [Fact] + public override async Task CancelUpdatesQueueLimit() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var cts = new CancellationTokenSource(); + var wait = limiter.WaitAsync(1, cts.Token); + + cts.Cancel(); + var ex = await Assert.ThrowsAsync(() => wait.AsTask()); + Assert.Equal(cts.Token, ex.CancellationToken); + + wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override void NoMetadataOnAcquiredLease() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + Assert.False(lease.TryGetMetadata(MetadataName.RetryAfter, out _)); + } + + [Fact] + public override void MetadataNamesContainsAllMetadata() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + Assert.Collection(lease.MetadataNames, metadataName => Assert.Equal(metadataName, MetadataName.RetryAfter.Name)); + } + + [Fact] + public override async Task DisposeReleasesQueuedAcquires() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(1); + var wait1 = limiter.WaitAsync(1); + var wait2 = limiter.WaitAsync(1); + var wait3 = limiter.WaitAsync(1); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + Assert.False(wait3.IsCompleted); + + limiter.Dispose(); + + lease = await wait1; + Assert.False(lease.IsAcquired); + lease = await wait2; + Assert.False(lease.IsAcquired); + lease = await wait3; + Assert.False(lease.IsAcquired); + + // Throws after disposal + Assert.Throws(() => limiter.Acquire(1)); + await Assert.ThrowsAsync(() => limiter.WaitAsync(1).AsTask()); + } + + [Fact] + public override async Task DisposeAsyncReleasesQueuedAcquires() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.Zero, autoReplenishment: false)); + var lease = limiter.Acquire(1); + var wait1 = limiter.WaitAsync(1); + var wait2 = limiter.WaitAsync(1); + var wait3 = limiter.WaitAsync(1); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + Assert.False(wait3.IsCompleted); + + await limiter.DisposeAsync(); + + lease = await wait1; + Assert.False(lease.IsAcquired); + lease = await wait2; + Assert.False(lease.IsAcquired); + lease = await wait3; + Assert.False(lease.IsAcquired); + + // Throws after disposal + Assert.Throws(() => limiter.Acquire(1)); + await Assert.ThrowsAsync(() => limiter.WaitAsync(1).AsTask()); + } + + [Fact] + public async Task RetryMetadataOnFailedWaitAsync() + { + var options = new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.FromSeconds(20), autoReplenishment: false); + var limiter = new FixedWindowRateLimiter(options); + + using var lease = limiter.Acquire(2); + + var failedLease = await limiter.WaitAsync(2); + Assert.False(failedLease.IsAcquired); + Assert.True(failedLease.TryGetMetadata(MetadataName.RetryAfter.Name, out var metadata)); + var metaDataTime = Assert.IsType(metadata); + Assert.Equal(options.Window.Ticks, metaDataTime.Ticks); + + Assert.True(failedLease.TryGetMetadata(MetadataName.RetryAfter, out var typedMetadata)); + Assert.Equal(options.Window.Ticks, typedMetadata.Ticks); + Assert.Collection(failedLease.MetadataNames, item => item.Equals(MetadataName.RetryAfter.Name)); + } + + [Fact] + public async Task CorrectRetryMetadataWithQueuedItem() + { + var options = new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.FromSeconds(20), autoReplenishment: false); + var limiter = new FixedWindowRateLimiter(options); + + using var lease = limiter.Acquire(2); + // Queue item which changes the retry after time for failed items + var wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + var failedLease = await limiter.WaitAsync(2); + Assert.False(failedLease.IsAcquired); + Assert.True(failedLease.TryGetMetadata(MetadataName.RetryAfter, out var typedMetadata)); + Assert.Equal(options.Window.Ticks, typedMetadata.Ticks); + } + + + [Fact] + public async Task CorrectRetryMetadataWithNonZeroAvailableItems() + { + var options = new FixedWindowRateLimiterOptions(3, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.FromSeconds(20), autoReplenishment: false); + var limiter = new FixedWindowRateLimiter(options); + + using var lease = limiter.Acquire(2); + + var failedLease = await limiter.WaitAsync(3); + Assert.False(failedLease.IsAcquired); + Assert.True(failedLease.TryGetMetadata(MetadataName.RetryAfter, out var typedMetadata)); + Assert.Equal(options.Window.Ticks, typedMetadata.Ticks); + } + + [Fact] + public void TryReplenishWithAutoReplenish_ReturnsFalse() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.FromSeconds(1), autoReplenishment: true)); + Assert.Equal(2, limiter.GetAvailablePermits()); + Assert.False(limiter.TryReplenish()); + Assert.Equal(2, limiter.GetAvailablePermits()); + } + + [Fact] + public async Task AutoReplenish_ReplenishesCounters() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.FromMilliseconds(1000), autoReplenishment: true)); + Assert.Equal(2, limiter.GetAvailablePermits()); + limiter.Acquire(2); + + var lease = await limiter.WaitAsync(1); + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CanAcquireResourcesWithWaitAsyncWithQueuedItemsIfNewestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 2, + TimeSpan.Zero, autoReplenishment: false)); + + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + Assert.Equal(1, limiter.GetAvailablePermits()); + lease = await limiter.WaitAsync(1); + Assert.True(lease.IsAcquired); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CannotAcquireResourcesWithWaitAsyncWithQueuedItemsIfOldestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.Zero, autoReplenishment: false)); + + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + var wait2 = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + Assert.False(wait2.IsCompleted); + + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + Assert.False(wait2.IsCompleted); + + limiter.TryReplenish(); + + lease = await wait2; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CanAcquireResourcesWithAcquireWithQueuedItemsIfNewestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 3, + TimeSpan.Zero, autoReplenishment: false)); + + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CannotAcquireResourcesWithAcquireWithQueuedItemsIfOldestFirst() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.Zero, autoReplenishment: false)); + + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + lease = limiter.Acquire(1); + Assert.False(lease.IsAcquired); + + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override void NullIdleDurationWhenActive() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.FromMilliseconds(2), autoReplenishment: false)); + limiter.Acquire(1); + Assert.Null(limiter.IdleDuration); + } + + [Fact] + public override async Task IdleDurationUpdatesWhenIdle() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.FromMilliseconds(2), autoReplenishment: false)); + Assert.NotNull(limiter.IdleDuration); + var previousDuration = limiter.IdleDuration; + await Task.Delay(15); + Assert.True(previousDuration < limiter.IdleDuration); + } + + [Fact] + public override void IdleDurationUpdatesWhenChangingFromActive() + { + var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.Zero, autoReplenishment: false)); + limiter.Acquire(1); + limiter.TryReplenish(); + Assert.NotNull(limiter.IdleDuration); + } + + [Fact] + public void ReplenishingRateLimiterPropertiesHaveCorrectValues() + { + var replenishPeriod = TimeSpan.FromMinutes(1); + using ReplenishingRateLimiter limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + replenishPeriod, autoReplenishment: true)); + Assert.True(limiter.IsAutoReplenishing); + Assert.Equal(replenishPeriod, limiter.ReplenishmentPeriod); + + replenishPeriod = TimeSpan.FromSeconds(2); + using ReplenishingRateLimiter limiter2 = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + replenishPeriod, autoReplenishment: false)); + Assert.False(limiter2.IsAutoReplenishing); + Assert.Equal(replenishPeriod, limiter2.ReplenishmentPeriod); + } + } +} diff --git a/src/libraries/System.Threading.RateLimiting/tests/SlidingWindowRateLimiterTests.cs b/src/libraries/System.Threading.RateLimiting/tests/SlidingWindowRateLimiterTests.cs new file mode 100644 index 00000000000000..b9d1c1caf6f9f4 --- /dev/null +++ b/src/libraries/System.Threading.RateLimiting/tests/SlidingWindowRateLimiterTests.cs @@ -0,0 +1,717 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Threading.Tasks; +using Xunit; + +namespace System.Threading.RateLimiting.Test +{ + public class SlidingWindowRateLimiterTests : BaseRateLimiterTests + { + [Fact] + public override void CanAcquireResource() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(); + + Assert.True(lease.IsAcquired); + Assert.False(limiter.Acquire().IsAcquired); + + lease.Dispose(); + Assert.False(limiter.Acquire().IsAcquired); + Assert.True(limiter.TryReplenish()); + Assert.True(limiter.TryReplenish()); + + Assert.True(limiter.Acquire().IsAcquired); + } + + [Fact] + public override void InvalidOptionsThrows() + { + Assert.Throws( + () => new SlidingWindowRateLimiterOptions(-1, QueueProcessingOrder.NewestFirst, 1, TimeSpan.FromMinutes(2), 1, autoReplenishment: false)); + Assert.Throws( + () => new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, -1, TimeSpan.FromMinutes(2), 1, autoReplenishment: false)); + Assert.Throws( + () => new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, TimeSpan.FromMinutes(2), -1, autoReplenishment: false)); + } + + [Fact] + public override async Task CanAcquireResourceAsync() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 4, + TimeSpan.Zero, 2, autoReplenishment: false)); + + using var lease = await limiter.WaitAsync(); + + Assert.True(lease.IsAcquired); + var wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + Assert.True(limiter.TryReplenish()); + + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(2); + Assert.False(wait2.IsCompleted); + + Assert.True(limiter.TryReplenish()); + + Assert.True((await wait2).IsAcquired); + } + + [Fact] + public async Task CanAcquireMultipleRequestsAsync() + { + // This test verifies the following behavior + // 1. when we have available permits after replenish to serve the queued requests + // 2. when the oldest item from queue is remove to accomodate new requests (QueueProcessingOrder: NewestFirst) + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(4, QueueProcessingOrder.NewestFirst, 4, + TimeSpan.Zero, 3, autoReplenishment: false)); + + using var lease = await limiter.WaitAsync(2); + + Assert.True(lease.IsAcquired); + var wait = limiter.WaitAsync(3); + Assert.False(wait.IsCompleted); + + Assert.True(limiter.TryReplenish()); + + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(2); + Assert.True(wait2.IsCompleted); + + Assert.True(limiter.TryReplenish()); + + var wait3 = limiter.WaitAsync(2); + Assert.False(wait3.IsCompleted); + + Assert.True(limiter.TryReplenish()); + Assert.True((await wait3).IsAcquired); + + Assert.False((await wait).IsAcquired); + Assert.Equal(0, limiter.GetAvailablePermits()); + } + + [Fact] + public override async Task CanAcquireResourceAsync_QueuesAndGrabsOldest() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.FromMinutes(0), 2, autoReplenishment: false)); + var lease = await limiter.WaitAsync(2); + + Assert.True(lease.IsAcquired); + var wait1 = limiter.WaitAsync(); + var wait2 = limiter.WaitAsync(2); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + Assert.False(wait1.IsCompleted); + Assert.True(limiter.TryReplenish()); + + lease = await wait1; + Assert.True(lease.IsAcquired); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.Equal(1, limiter.GetAvailablePermits()); + Assert.True(limiter.TryReplenish()); + Assert.True(limiter.TryReplenish()); + + lease = await wait2; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CanAcquireResourceAsync_QueuesAndGrabsNewest() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 3, + TimeSpan.FromMinutes(0), 2, autoReplenishment: false)); + + var lease = await limiter.WaitAsync(2); + Assert.True(lease.IsAcquired); + + var wait1 = limiter.WaitAsync(2); + var wait2 = limiter.WaitAsync(); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + Assert.False(wait2.IsCompleted); + + Assert.True(limiter.TryReplenish()); + // second queued item completes first with NewestFirst + lease = await wait2; + Assert.True(lease.IsAcquired); + Assert.False(wait1.IsCompleted); + + lease.Dispose(); + Assert.Equal(1, limiter.GetAvailablePermits()); + Assert.True(limiter.TryReplenish()); + Assert.True(limiter.TryReplenish()); + + lease = await wait1; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task FailsWhenQueuingMoreThanLimit_OldestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + var wait = limiter.WaitAsync(1); + + var failedLease = await limiter.WaitAsync(1); + Assert.False(failedLease.IsAcquired); + } + + [Fact] + public override async Task DropsOldestWhenQueuingMoreThanLimit_NewestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(1); + var wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(1); + var lease1 = await wait; + Assert.False(lease1.IsAcquired); + Assert.False(wait2.IsCompleted); + + limiter.TryReplenish(); + limiter.TryReplenish(); + + lease = await wait2; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task DropsMultipleOldestWhenQueuingMoreThanLimit_NewestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 2, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(2); + Assert.True(lease.IsAcquired); + var wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(1); + Assert.False(wait2.IsCompleted); + + var wait3 = limiter.WaitAsync(2); + var lease1 = await wait; + var lease2 = await wait2; + Assert.False(lease1.IsAcquired); + Assert.False(lease2.IsAcquired); + Assert.False(wait3.IsCompleted); + + limiter.TryReplenish(); + limiter.TryReplenish(); + + lease = await wait3; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task DropsRequestedLeaseIfPermitCountGreaterThanQueueLimitAndNoAvailability_NewestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(2); + Assert.True(lease.IsAcquired); + + // Fill queue + var wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + var lease1 = await limiter.WaitAsync(2); + Assert.False(lease1.IsAcquired); + + limiter.TryReplenish(); + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task QueueAvailableAfterQueueLimitHitAndResources_BecomeAvailable() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(3, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.Zero, 3, autoReplenishment: false)); + var lease = limiter.Acquire(2); + var wait = limiter.WaitAsync(2); + + var failedLease = await limiter.WaitAsync(2); + Assert.False(failedLease.IsAcquired); + + limiter.TryReplenish(); + limiter.TryReplenish(); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + lease = await wait; + Assert.True(lease.IsAcquired); + + wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + limiter.TryReplenish(); + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task LargeAcquiresAndQueuesDoNotIntegerOverflow() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(int.MaxValue, QueueProcessingOrder.NewestFirst, int.MaxValue, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(int.MaxValue); + Assert.True(lease.IsAcquired); + + // Fill queue + var wait = limiter.WaitAsync(3); + Assert.False(wait.IsCompleted); + + var wait2 = limiter.WaitAsync(int.MaxValue); + Assert.False(wait2.IsCompleted); + + var lease1 = await wait; + Assert.False(lease1.IsAcquired); + + limiter.TryReplenish(); + limiter.TryReplenish(); + var lease2 = await wait2; + Assert.True(lease2.IsAcquired); + } + + [Fact] + public override void ThrowsWhenAcquiringMoreThanLimit() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + Assert.Throws(() => limiter.Acquire(2)); + } + + [Fact] + public override async Task ThrowsWhenWaitingForMoreThanLimit() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + await Assert.ThrowsAsync(async () => await limiter.WaitAsync(2)); + } + + [Fact] + public override void ThrowsWhenAcquiringLessThanZero() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + Assert.Throws(() => limiter.Acquire(-1)); + } + + [Fact] + public override async Task ThrowsWhenWaitingForLessThanZero() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + await Assert.ThrowsAsync(async () => await limiter.WaitAsync(-1)); + } + + [Fact] + public override void AcquireZero_WithAvailability() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + + using var lease = limiter.Acquire(0); + Assert.True(lease.IsAcquired); + } + + [Fact] + public override void AcquireZero_WithoutAvailability() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var lease2 = limiter.Acquire(0); + Assert.False(lease2.IsAcquired); + lease2.Dispose(); + } + + [Fact] + public override async Task WaitAsyncZero_WithAvailability() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + + using var lease = await limiter.WaitAsync(0); + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task WaitAsyncZero_WithoutAvailabilityWaitsForAvailability() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = await limiter.WaitAsync(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(0); + Assert.False(wait.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + Assert.True(limiter.TryReplenish()); + using var lease2 = await wait; + Assert.True(lease2.IsAcquired); + } + + [Fact] + public override async Task CanDequeueMultipleResourcesAtOnce() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 4, + TimeSpan.Zero, 2, autoReplenishment: false)); + using var lease = await limiter.WaitAsync(2); + Assert.True(lease.IsAcquired); + + var wait1 = limiter.WaitAsync(1); + var wait2 = limiter.WaitAsync(1); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + Assert.True(limiter.TryReplenish()); + + var lease1 = await wait1; + var lease2 = await wait2; + Assert.True(lease1.IsAcquired); + Assert.True(lease2.IsAcquired); + } + + [Fact] + public override async Task CanCancelWaitAsyncAfterQueuing() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(2); + Assert.True(lease.IsAcquired); + + var cts = new CancellationTokenSource(); + var wait = limiter.WaitAsync(1, cts.Token); + + cts.Cancel(); + var ex = await Assert.ThrowsAsync(() => wait.AsTask()); + Assert.Equal(cts.Token, ex.CancellationToken); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + Assert.Equal(0, limiter.GetAvailablePermits()); + } + + [Fact] + public override async Task CanCancelWaitAsyncBeforeQueuing() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(2); + Assert.True(lease.IsAcquired); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Assert.ThrowsAsync(() => limiter.WaitAsync(1, cts.Token).AsTask()); + Assert.Equal(cts.Token, ex.CancellationToken); + + lease.Dispose(); + Assert.True(limiter.TryReplenish()); + + Assert.Equal(0, limiter.GetAvailablePermits()); + } + + [Fact] + public override async Task CancelUpdatesQueueLimit() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(2); + Assert.True(lease.IsAcquired); + + var cts = new CancellationTokenSource(); + var wait = limiter.WaitAsync(1, cts.Token); + + cts.Cancel(); + var ex = await Assert.ThrowsAsync(() => wait.AsTask()); + Assert.Equal(cts.Token, ex.CancellationToken); + + wait = limiter.WaitAsync(1); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + Assert.Equal(1, limiter.GetAvailablePermits()); + } + + [Fact] + public override void NoMetadataOnAcquiredLease() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, 2, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + Assert.False(lease.TryGetMetadata(MetadataName.RetryAfter, out _)); + } + + [Fact] + public override void MetadataNamesContainsAllMetadata() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.Zero, 1, autoReplenishment: false)); + using var lease = limiter.Acquire(1); + Assert.Collection(lease.MetadataNames, metadataName => Assert.Equal(metadataName, MetadataName.RetryAfter.Name)); + } + + [Fact] + public override async Task DisposeReleasesQueuedAcquires() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.Zero, 1, autoReplenishment: false)); + var lease = limiter.Acquire(1); + var wait1 = limiter.WaitAsync(1); + var wait2 = limiter.WaitAsync(1); + var wait3 = limiter.WaitAsync(1); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + Assert.False(wait3.IsCompleted); + + limiter.Dispose(); + + lease = await wait1; + Assert.False(lease.IsAcquired); + lease = await wait2; + Assert.False(lease.IsAcquired); + lease = await wait3; + Assert.False(lease.IsAcquired); + + // Throws after disposal + Assert.Throws(() => limiter.Acquire(1)); + await Assert.ThrowsAsync(() => limiter.WaitAsync(1).AsTask()); + } + + [Fact] + public override async Task DisposeAsyncReleasesQueuedAcquires() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.Zero, 2, autoReplenishment: false)); + var lease = limiter.Acquire(1); + var wait1 = limiter.WaitAsync(1); + var wait2 = limiter.WaitAsync(1); + var wait3 = limiter.WaitAsync(1); + Assert.False(wait1.IsCompleted); + Assert.False(wait2.IsCompleted); + Assert.False(wait3.IsCompleted); + + await limiter.DisposeAsync(); + + lease = await wait1; + Assert.False(lease.IsAcquired); + lease = await wait2; + Assert.False(lease.IsAcquired); + lease = await wait3; + Assert.False(lease.IsAcquired); + + // Throws after disposal + Assert.Throws(() => limiter.Acquire(1)); + await Assert.ThrowsAsync(() => limiter.WaitAsync(1).AsTask()); + } + + [Fact] + public void TryReplenishWithAutoReplenish_ReturnsFalse() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.FromSeconds(1), 1, autoReplenishment: true)); + Assert.Equal(2, limiter.GetAvailablePermits()); + Assert.False(limiter.TryReplenish()); + Assert.Equal(2, limiter.GetAvailablePermits()); + } + + [Fact] + public async Task AutoReplenish_ReplenishesCounters() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 1, + TimeSpan.FromMilliseconds(1000), 2, autoReplenishment: true)); + Assert.Equal(2, limiter.GetAvailablePermits()); + limiter.Acquire(2); + + var lease = await limiter.WaitAsync(1); + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CanAcquireResourcesWithWaitAsyncWithQueuedItemsIfNewestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 2, + TimeSpan.Zero, 3, autoReplenishment: false)); + + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + Assert.Equal(1, limiter.GetAvailablePermits()); + lease = await limiter.WaitAsync(1); + Assert.True(lease.IsAcquired); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + Assert.True(limiter.TryReplenish()); + + Assert.False(wait.IsCompleted); + + Assert.True(limiter.TryReplenish()); + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CannotAcquireResourcesWithWaitAsyncWithQueuedItemsIfOldestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(3, QueueProcessingOrder.OldestFirst, 5, + TimeSpan.Zero, 2, autoReplenishment: false)); + + var lease = limiter.Acquire(3); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + var wait2 = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + Assert.False(wait2.IsCompleted); + + limiter.TryReplenish(); + + Assert.False(wait.IsCompleted); + Assert.False(wait2.IsCompleted); + + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + + limiter.TryReplenish(); + limiter.TryReplenish(); + + lease = await wait2; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CanAcquireResourcesWithAcquireWithQueuedItemsIfNewestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.NewestFirst, 3, + TimeSpan.Zero, 2, autoReplenishment: false)); + + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + Assert.False(wait.IsCompleted); + + limiter.TryReplenish(); + limiter.TryReplenish(); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override async Task CannotAcquireResourcesWithAcquireWithQueuedItemsIfOldestFirst() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(2, QueueProcessingOrder.OldestFirst, 3, + TimeSpan.Zero, 2, autoReplenishment: false)); + + var lease = limiter.Acquire(1); + Assert.True(lease.IsAcquired); + + var wait = limiter.WaitAsync(2); + Assert.False(wait.IsCompleted); + + lease = limiter.Acquire(1); + Assert.False(lease.IsAcquired); + + limiter.TryReplenish(); + Assert.True(limiter.TryReplenish()); + + lease = await wait; + Assert.True(lease.IsAcquired); + } + + [Fact] + public override void NullIdleDurationWhenActive() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.FromMilliseconds(2), 1, autoReplenishment: false)); + limiter.Acquire(1); + Assert.Null(limiter.IdleDuration); + } + + [Fact] + public override async Task IdleDurationUpdatesWhenIdle() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(3, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.FromMilliseconds(2), 2, autoReplenishment: false)); + Assert.NotNull(limiter.IdleDuration); + var previousDuration = limiter.IdleDuration; + await Task.Delay(15); + Assert.True(previousDuration < limiter.IdleDuration); + } + + [Fact] + public override void IdleDurationUpdatesWhenChangingFromActive() + { + var limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + TimeSpan.Zero, 2, autoReplenishment: false)); + limiter.Acquire(1); + limiter.TryReplenish(); + limiter.TryReplenish(); + Assert.NotNull(limiter.IdleDuration); + } + + [Fact] + public void ReplenishingRateLimiterPropertiesHaveCorrectValues() + { + var replenishPeriod = TimeSpan.FromMinutes(1); + using ReplenishingRateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + replenishPeriod, 1, autoReplenishment: true)); + Assert.True(limiter.IsAutoReplenishing); + Assert.Equal(replenishPeriod, limiter.ReplenishmentPeriod); + + replenishPeriod = TimeSpan.FromSeconds(2); + using ReplenishingRateLimiter limiter2 = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 2, + replenishPeriod, 1, autoReplenishment: false)); + Assert.False(limiter2.IsAutoReplenishing); + Assert.Equal(replenishPeriod, limiter2.ReplenishmentPeriod); + } + } +} diff --git a/src/libraries/System.Threading.RateLimiting/tests/System.Threading.RateLimiting.Tests.csproj b/src/libraries/System.Threading.RateLimiting/tests/System.Threading.RateLimiting.Tests.csproj index 4bb1f2a3392643..0cef547da6ab17 100644 --- a/src/libraries/System.Threading.RateLimiting/tests/System.Threading.RateLimiting.Tests.csproj +++ b/src/libraries/System.Threading.RateLimiting/tests/System.Threading.RateLimiting.Tests.csproj @@ -6,6 +6,8 @@ + +