diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c7ff6c08d0..cf8f847b16e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,6 @@
+## 7.2.0
+- Add RateLimit policy
+
## 7.1.0
- Add SourceLink debugger support.
- Bug fix: PolicyRegistry with .NET Core services.AddPolicyRegistry() overload (affects Polly v7.0.1-3 only)
diff --git a/GitVersionConfig.yaml b/GitVersionConfig.yaml
index 6dd7305ac44..6605b1cfb8e 100644
--- a/GitVersionConfig.yaml
+++ b/GitVersionConfig.yaml
@@ -1 +1 @@
-next-version: 7.1.0
+next-version: 7.2.0
diff --git a/README.md b/README.md
index 904adcaa2d4..fdc07482d8d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Polly
-Polly is a .NET resilience and transient-fault-handling library that allows developers to express policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, and Fallback in a fluent and thread-safe manner.
+Polly is a .NET resilience and transient-fault-handling library that allows developers to express policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, Rate-limiting and Fallback in a fluent and thread-safe manner.
Polly targets .NET Standard 1.1 ([coverage](https://docs.microsoft.com/en-us/dotnet/standard/net-standard#net-implementation-support): .NET Framework 4.5-4.6.1, .NET Core 1.0, Mono, Xamarin, UWP, WP8.1+) and .NET Standard 2.0+ ([coverage](https://docs.microsoft.com/en-us/dotnet/standard/net-standard#net-implementation-support): .NET Framework 4.6.1, .NET Core 2.0+, and later Mono, Xamarin and UWP targets).
@@ -29,7 +29,8 @@ Polly offers multiple resilience policies:
|**Retry**
(policy family)
([quickstart](#retry) ; [deep](https://github.com/App-vNext/Polly/wiki/Retry))|Many faults are transient and may self-correct after a short delay.| "Maybe it's just a blip" | Allows configuring automatic retries. |
|**Circuit-breaker**
(policy family)
([quickstart](#circuit-breaker) ; [deep](https://github.com/App-vNext/Polly/wiki/Circuit-Breaker))|When a system is seriously struggling, failing fast is better than making users/callers wait.
Protecting a faulting system from overload can help it recover. | "Stop doing it if it hurts"
"Give that system a break" | Breaks the circuit (blocks executions) for a period, when faults exceed some pre-configured threshold. |
|**Timeout**
([quickstart](#timeout) ; [deep](https://github.com/App-vNext/Polly/wiki/Timeout))|Beyond a certain wait, a success result is unlikely.| "Don't wait forever" |Guarantees the caller won't have to wait beyond the timeout. |
-|**Bulkhead Isolation**
([quickstart](#bulkhead) ; [deep](https://github.com/App-vNext/Polly/wiki/Bulkhead))|When a process faults, multiple failing calls backing up can easily swamp resource (eg threads/CPU) in a host.
A faulting downstream system can also cause 'backed-up' failing calls upstream.
Both risk a faulting process bringing down a wider system. | "One fault shouldn't sink the whole ship" |Constrains the governed actions to a fixed-size resource pool, isolating their potential to affect others. |
+|**Bulkhead Isolation**
([quickstart](#bulkhead) ; [deep](https://github.com/App-vNext/Polly/wiki/Bulkhead))|When a process faults, multiple failing calls can stack up (if unbounded) and can easily swamp resource (threads/ CPU/ memory) in a host.
This can affect performance more widely by starving other operations of resource, bringing down the host, or causing cascading failures upstream. | "One fault shouldn't sink the whole ship" |Constrains the governed actions to a fixed-size resource pool, isolating their potential to affect others. |
+|**Rate-limit**
([quickstart](#ratelimit) ; [deep](https://github.com/App-vNext/Polly/wiki/RateLimit))|Limiting the rate a system handles requests is another way to control load.
This can apply to the way your system accepts incoming calls, and/or to the way you call downstream services. | "Slow down a bit, will you?" |Constrains executions to not exceed a certain rate. |
|**Cache**
([quickstart](#cache) ; [deep](https://github.com/App-vNext/Polly/wiki/Cache))|Some proportion of requests may be similar.| "You've asked that one before" |Provides a response from cache if known.
Stores responses automatically in cache, when first retrieved. |
|**Fallback**
([quickstart](#fallback) ; [deep](https://github.com/App-vNext/Polly/wiki/Fallback))|Things will still fail - plan what you will do when that happens.| "Degrade gracefully" |Defines an alternative value to be returned (or action to be executed) on failure. |
|**PolicyWrap**
([quickstart](#policywrap) ; [deep](https://github.com/App-vNext/Polly/wiki/PolicyWrap))|Different faults require different strategies; resilience means using a combination.| "Defence in depth" |Allows any of the above policies to be combined flexibly. |
@@ -607,6 +608,12 @@ Bulkhead policies throw `BulkheadRejectedException` if items are queued to the b
For more detail see: [Bulkhead policy documentation](https://github.com/App-vNext/Polly/wiki/Bulkhead) on wiki.
+### Rate-Limit
+
+**TODO: Documentation to be completed**
+
+
+
### Cache
```csharp
diff --git a/src/Polly.Specs/Bulkhead/BulkheadSpecsHelper.cs b/src/Polly.Specs/Bulkhead/BulkheadSpecsHelper.cs
index e3ff5a703bd..793188286e6 100644
--- a/src/Polly.Specs/Bulkhead/BulkheadSpecsHelper.cs
+++ b/src/Polly.Specs/Bulkhead/BulkheadSpecsHelper.cs
@@ -1,4 +1,5 @@
using System;
+using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions.Execution;
@@ -33,7 +34,7 @@ public BulkheadSpecsHelper(ITestOutputHelper testOutputHelper)
/// The action containing fluent assertions, which must succeed within the timespan.
protected void Within(TimeSpan timeSpan, Action actionContainingAssertions)
{
- DateTime timeoutTime = DateTime.UtcNow.Add(timeSpan);
+ Stopwatch watch = Stopwatch.StartNew();
while (true)
{
try
@@ -45,7 +46,7 @@ protected void Within(TimeSpan timeSpan, Action actionContainingAssertions)
{
if (!(e is AssertionFailedException || e is XunitException)) { throw; }
- TimeSpan remaining = timeoutTime - DateTime.UtcNow;
+ TimeSpan remaining = timeSpan - watch.Elapsed;
if (remaining <= TimeSpan.Zero) { throw; }
statusChanged.WaitOne(remaining);
diff --git a/src/Polly.Specs/Helpers/Bulkhead/TraceableAction.cs b/src/Polly.Specs/Helpers/Bulkhead/TraceableAction.cs
index 48a075c4944..57dd337f9a1 100644
--- a/src/Polly.Specs/Helpers/Bulkhead/TraceableAction.cs
+++ b/src/Polly.Specs/Helpers/Bulkhead/TraceableAction.cs
@@ -38,8 +38,10 @@ public TraceableAction(int id, AutoResetEvent statusChanged, ITestOutputHelper t
_testOutputHelper = testOutputHelper;
}
- // Note re TaskCreationOptions.LongRunning: Testing the parallelization of the bulkhead policy efficiently requires the ability to start large numbers of parallel tasks in a short space of time. The ThreadPool's algorithm of only injecting extra threads (when necessary) at a rate of two-per-second however makes high-volume tests using the ThreadPool both slow and flaky. For PCL tests further, ThreadPool.SetMinThreads(...) is not available, to mitigate this. Using TaskCreationOptions.LongRunning allows us to force tasks to be started near-instantly on non-ThreadPool threads.
- // Similarly, we use ConfigureAwait(true) when awaiting, to avoid continuations being scheduled onto a ThreadPool thread, which may only be injected too slowly in high-volume tests.
+ // Note re TaskCreationOptions.LongRunning: Testing the parallelization of the bulkhead policy efficiently requires the ability to start large numbers of parallel tasks in a short space of time.
+ // The ThreadPool's algorithm of only injecting extra threads (when necessary) at a rate of two-per-second however makes high-volume tests using the ThreadPool both slow and flaky. In the days of PCL, further, ThreadPool.SetMinThreads(...) was not available to mitigate this.
+ // Using TaskCreationOptions.LongRunning allows us to force tasks to be started near-instantly on non-ThreadPool threads.
+ // Similarly, we use ConfigureAwait(true) when awaiting, to avoid continuations being scheduled onto a ThreadPool thread, which may only be injected too slowly in high-volume tests.
public Task ExecuteOnBulkhead(BulkheadPolicy bulkhead)
{
diff --git a/src/Polly.Specs/Helpers/RateLimit/IRateLimiterExtensions.cs b/src/Polly.Specs/Helpers/RateLimit/IRateLimiterExtensions.cs
new file mode 100644
index 00000000000..64da0965670
--- /dev/null
+++ b/src/Polly.Specs/Helpers/RateLimit/IRateLimiterExtensions.cs
@@ -0,0 +1,40 @@
+using System;
+using FluentAssertions;
+using Polly.RateLimit;
+
+namespace Polly.Specs.Helpers.RateLimit
+{
+ internal static class IRateLimiterExtensions
+ {
+ public static void ShouldPermitAnExecution(this IRateLimiter rateLimiter)
+ {
+ (bool permitExecution, TimeSpan retryAfter) canExecute = rateLimiter.PermitExecution();
+
+ canExecute.permitExecution.Should().BeTrue();
+ canExecute.retryAfter.Should().Be(TimeSpan.Zero);
+ }
+
+ public static void ShouldPermitNExecutions(this IRateLimiter rateLimiter, long numberOfExecutions)
+ {
+ for (int execution = 0; execution < numberOfExecutions; execution++)
+ {
+ rateLimiter.ShouldPermitAnExecution();
+ }
+ }
+
+ public static void ShouldNotPermitAnExecution(this IRateLimiter rateLimiter, TimeSpan? retryAfter = null)
+ {
+ (bool permitExecution, TimeSpan retryAfter) canExecute = rateLimiter.PermitExecution();
+
+ canExecute.permitExecution.Should().BeFalse();
+ if (retryAfter == null)
+ {
+ canExecute.retryAfter.Should().BeGreaterThan(TimeSpan.Zero);
+ }
+ else
+ {
+ canExecute.retryAfter.Should().Be(retryAfter.Value);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Polly.Specs/Helpers/RateLimit/ResultClassWithRetryAfter.cs b/src/Polly.Specs/Helpers/RateLimit/ResultClassWithRetryAfter.cs
new file mode 100644
index 00000000000..fcb1c72c46d
--- /dev/null
+++ b/src/Polly.Specs/Helpers/RateLimit/ResultClassWithRetryAfter.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace Polly.Specs.Helpers.RateLimit
+{
+ internal class ResultClassWithRetryAfter : ResultClass
+ {
+ public TimeSpan RetryAfter { get; }
+
+ public ResultClassWithRetryAfter(ResultPrimitive result)
+ : base(result)
+ {
+ RetryAfter = TimeSpan.Zero;
+ }
+
+ public ResultClassWithRetryAfter(TimeSpan retryAfter)
+ : base(ResultPrimitive.Undefined)
+ {
+ RetryAfter = retryAfter;
+ }
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/AsyncRateLimitPolicySpecs.cs b/src/Polly.Specs/RateLimit/AsyncRateLimitPolicySpecs.cs
new file mode 100644
index 00000000000..24b70fa2e50
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/AsyncRateLimitPolicySpecs.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Threading.Tasks;
+using Polly.RateLimit;
+using Polly.Specs.Helpers;
+using Polly.Specs.Helpers.RateLimit;
+using Polly.Utilities;
+using Xunit;
+
+namespace Polly.Specs.RateLimit
+{
+ [Collection(Polly.Specs.Helpers.Constants.SystemClockDependentTestCollection)]
+ public class AsyncRateLimitPolicySpecs : RateLimitPolicySpecsBase, IDisposable
+ {
+ public void Dispose()
+ {
+ SystemClock.Reset();
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan)
+ {
+ return Policy.RateLimitAsync(numberOfExecutions, perTimeSpan);
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan, int maxBurst)
+ {
+ return Policy.RateLimitAsync(numberOfExecutions, perTimeSpan, maxBurst);
+ }
+
+ protected override (bool, TimeSpan) TryExecuteThroughPolicy(IRateLimitPolicy policy)
+ {
+ if (policy is AsyncRateLimitPolicy typedPolicy)
+ {
+ try
+ {
+ typedPolicy.ExecuteAsync(() => Task.FromResult(new ResultClassWithRetryAfter(ResultPrimitive.Good))).GetAwaiter().GetResult();
+ return (true, TimeSpan.Zero);
+ }
+ catch (RateLimitRejectedException e)
+ {
+ return (false, e.RetryAfter);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected policy type in test construction.");
+ }
+ }
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/AsyncRateLimitPolicyTResultSpecs.cs b/src/Polly.Specs/RateLimit/AsyncRateLimitPolicyTResultSpecs.cs
new file mode 100644
index 00000000000..19073d3f3ed
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/AsyncRateLimitPolicyTResultSpecs.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Threading.Tasks;
+using Polly.RateLimit;
+using Polly.Specs.Helpers;
+using Polly.Specs.Helpers.RateLimit;
+using Polly.Utilities;
+using Xunit;
+
+namespace Polly.Specs.RateLimit
+{
+ [Collection(Polly.Specs.Helpers.Constants.SystemClockDependentTestCollection)]
+ public class AsyncRateLimitPolicyTResultSpecs : RateLimitPolicyTResultSpecsBase, IDisposable
+ {
+ public void Dispose()
+ {
+ SystemClock.Reset();
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan)
+ {
+ return Policy.RateLimitAsync(numberOfExecutions, perTimeSpan);
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan, int maxBurst)
+ {
+ return Policy.RateLimitAsync(numberOfExecutions, perTimeSpan, maxBurst);
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan, int maxBurst,
+ Func retryAfterFactory)
+ {
+ return Policy.RateLimitAsync(numberOfExecutions, perTimeSpan, maxBurst, retryAfterFactory);
+ }
+
+ protected override (bool, TimeSpan) TryExecuteThroughPolicy(IRateLimitPolicy policy)
+ {
+ if (policy is AsyncRateLimitPolicy typedPolicy)
+ {
+ try
+ {
+ typedPolicy.ExecuteAsync(() => Task.FromResult(new ResultClassWithRetryAfter(ResultPrimitive.Good))).GetAwaiter().GetResult();
+ return (true, TimeSpan.Zero);
+ }
+ catch (RateLimitRejectedException e)
+ {
+ return (false, e.RetryAfter);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected policy type in test construction.");
+ }
+ }
+
+ protected override TResult TryExecuteThroughPolicy(IRateLimitPolicy policy, Context context, TResult resultIfExecutionPermitted)
+ {
+ if (policy is AsyncRateLimitPolicy typedPolicy)
+ {
+ return typedPolicy.ExecuteAsync(ctx => Task.FromResult(resultIfExecutionPermitted), context).GetAwaiter().GetResult();
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected policy type in test construction.");
+ }
+ }
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/LockBasedTokenBucketRateLimiterTests.cs b/src/Polly.Specs/RateLimit/LockBasedTokenBucketRateLimiterTests.cs
new file mode 100644
index 00000000000..775f47361f6
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/LockBasedTokenBucketRateLimiterTests.cs
@@ -0,0 +1,11 @@
+using System;
+using Polly.RateLimit;
+
+namespace Polly.Specs.RateLimit
+{
+ public class LockBasedTokenBucketRateLimiterTests : TokenBucketRateLimiterTestsBase
+ {
+ internal override IRateLimiter GetRateLimiter(TimeSpan onePer, long bucketCapacity)
+ => new LockBasedTokenBucketRateLimiter(onePer, bucketCapacity);
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/LockFreeTokenBucketRateLimiterTests.cs b/src/Polly.Specs/RateLimit/LockFreeTokenBucketRateLimiterTests.cs
new file mode 100644
index 00000000000..31376594f63
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/LockFreeTokenBucketRateLimiterTests.cs
@@ -0,0 +1,11 @@
+using System;
+using Polly.RateLimit;
+
+namespace Polly.Specs.RateLimit
+{
+ public class LockFreeTokenBucketRateLimiterTests : TokenBucketRateLimiterTestsBase
+ {
+ internal override IRateLimiter GetRateLimiter(TimeSpan onePer, long bucketCapacity)
+ => new LockFreeTokenBucketRateLimiter(onePer, bucketCapacity);
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/RateLimitPolicySpecs.cs b/src/Polly.Specs/RateLimit/RateLimitPolicySpecs.cs
new file mode 100644
index 00000000000..dec7aa35f0d
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/RateLimitPolicySpecs.cs
@@ -0,0 +1,48 @@
+using System;
+using Polly.RateLimit;
+using Polly.Specs.Helpers;
+using Polly.Specs.Helpers.RateLimit;
+using Polly.Utilities;
+using Xunit;
+
+namespace Polly.Specs.RateLimit
+{
+ [Collection(Polly.Specs.Helpers.Constants.SystemClockDependentTestCollection)]
+ public class RateLimitPolicySpecs : RateLimitPolicySpecsBase, IDisposable
+ {
+ public void Dispose()
+ {
+ SystemClock.Reset();
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan)
+ {
+ return Policy.RateLimit(numberOfExecutions, perTimeSpan);
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan, int maxBurst)
+ {
+ return Policy.RateLimit(numberOfExecutions, perTimeSpan, maxBurst);
+ }
+
+ protected override (bool, TimeSpan) TryExecuteThroughPolicy(IRateLimitPolicy policy)
+ {
+ if (policy is RateLimitPolicy typedPolicy)
+ {
+ try
+ {
+ typedPolicy.Execute(() => new ResultClassWithRetryAfter(ResultPrimitive.Good));
+ return (true, TimeSpan.Zero);
+ }
+ catch (RateLimitRejectedException e)
+ {
+ return (false, e.RetryAfter);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected policy type in test construction.");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Polly.Specs/RateLimit/RateLimitPolicySpecsBase.cs b/src/Polly.Specs/RateLimit/RateLimitPolicySpecsBase.cs
new file mode 100644
index 00000000000..261d8dbb7c2
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/RateLimitPolicySpecsBase.cs
@@ -0,0 +1,296 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Polly.RateLimit;
+using Xunit;
+
+namespace Polly.Specs.RateLimit
+{
+ public abstract class RateLimitPolicySpecsBase : RateLimitSpecsBase
+ {
+ protected abstract IRateLimitPolicy GetPolicyViaSyntax(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan);
+
+ protected abstract IRateLimitPolicy GetPolicyViaSyntax(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst);
+
+ protected abstract (bool, TimeSpan) TryExecuteThroughPolicy(IRateLimitPolicy policy);
+
+ protected void ShouldPermitAnExecution(IRateLimitPolicy policy)
+ {
+ (bool permitExecution, TimeSpan retryAfter) canExecute = TryExecuteThroughPolicy(policy);
+
+ canExecute.permitExecution.Should().BeTrue();
+ canExecute.retryAfter.Should().Be(TimeSpan.Zero);
+ }
+
+ protected void ShouldPermitNExecutions(IRateLimitPolicy policy, long numberOfExecutions)
+ {
+ for (int execution = 0; execution < numberOfExecutions; execution++)
+ {
+ ShouldPermitAnExecution(policy);
+ }
+ }
+
+ protected void ShouldNotPermitAnExecution(IRateLimitPolicy policy, TimeSpan? retryAfter = null)
+ {
+ (bool permitExecution, TimeSpan retryAfter) canExecute = TryExecuteThroughPolicy(policy);
+
+ canExecute.permitExecution.Should().BeFalse();
+ if (retryAfter == null)
+ {
+ canExecute.retryAfter.Should().BeGreaterThan(TimeSpan.Zero);
+ }
+ else
+ {
+ canExecute.retryAfter.Should().Be(retryAfter.Value);
+ }
+ }
+
+ [Fact]
+ public void Syntax_should_throw_for_perTimeSpan_zero()
+ {
+ Action invalidSyntax = () => GetPolicyViaSyntax(1, TimeSpan.Zero);
+
+ invalidSyntax.ShouldThrow().And.ParamName.Should().Be("perTimeSpan");
+ }
+
+ [Fact]
+ public void Syntax_should_throw_for_numberOfExecutions_negative()
+ {
+ Action invalidSyntax = () => GetPolicyViaSyntax(-1, TimeSpan.FromSeconds(1));
+
+ invalidSyntax.ShouldThrow().And.ParamName.Should().Be("numberOfExecutions");
+ }
+
+ [Fact]
+ public void Syntax_should_throw_for_numberOfExecutions_zero()
+ {
+ Action invalidSyntax = () => GetPolicyViaSyntax(0, TimeSpan.FromSeconds(1));
+
+ invalidSyntax.ShouldThrow().And.ParamName.Should().Be("numberOfExecutions");
+ }
+
+ [Fact]
+ public void Syntax_should_throw_for_perTimeSpan_negative()
+ {
+ Action invalidSyntax = () => GetPolicyViaSyntax(1, TimeSpan.FromTicks(-1));
+
+ invalidSyntax.ShouldThrow().And.ParamName.Should().Be("perTimeSpan");
+ }
+
+ [Fact]
+ public void Syntax_should_throw_for_maxBurst_negative()
+ {
+ Action invalidSyntax = () => GetPolicyViaSyntax(1, TimeSpan.FromSeconds(1), -1);
+
+ invalidSyntax.ShouldThrow().And.ParamName.Should().Be("maxBurst");
+ }
+
+ [Fact]
+ public void Syntax_should_throw_for_maxBurst_zero()
+ {
+ Action invalidSyntax = () => GetPolicyViaSyntax(1, TimeSpan.FromSeconds(1), 0);
+
+ invalidSyntax.ShouldThrow().And.ParamName.Should().Be("maxBurst");
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(5)]
+ public void Given_bucket_capacity_one_and_time_not_advanced_ratelimiter_specifies_correct_wait_until_next_execution(int onePerSeconds)
+ {
+ FixClock();
+
+ // Arrange
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetPolicyViaSyntax(1, onePer);
+
+ // Assert - first execution after initialising should always be permitted.
+ ShouldPermitAnExecution(rateLimiter);
+
+ // Arrange
+ // (do nothing - time not advanced)
+
+ // Assert - should be blocked - time not advanced.
+ ShouldNotPermitAnExecution(rateLimiter, onePer);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(50)]
+ public void Given_bucket_capacity_N_and_time_not_advanced_ratelimiter_permits_executions_up_to_bucket_capacity(int bucketCapacity)
+ {
+ FixClock();
+
+ // Arrange.
+ TimeSpan onePer = TimeSpan.FromSeconds(1);
+ var rateLimiter = GetPolicyViaSyntax(1, onePer, bucketCapacity);
+
+ // Act - should be able to successfully take bucketCapacity items.
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity);
+
+ // Assert - should not be able to take any items (given time not advanced).
+ ShouldNotPermitAnExecution(rateLimiter, onePer);
+ }
+
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(2, 1)]
+ [InlineData(5, 1)]
+ [InlineData(1, 10)]
+ [InlineData(2, 10)]
+ [InlineData(5, 10)]
+ public void Given_any_bucket_capacity_ratelimiter_permits_another_execution_per_interval(int onePerSeconds, int bucketCapacity)
+ {
+ FixClock();
+
+ // Arrange
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetPolicyViaSyntax(1, onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity);
+ ShouldNotPermitAnExecution(rateLimiter);
+
+ // Act-Assert - repeatedly advance the clock towards the interval but not quite - then to the interval
+ int experimentRepeats = bucketCapacity * 3;
+ TimeSpan shortfallFromInterval = TimeSpan.FromTicks(1);
+ TimeSpan notQuiteInterval = onePer - shortfallFromInterval;
+ for (int i = 0; i < experimentRepeats; i++)
+ {
+ // Arrange - Advance clock not quite to the interval
+ AdvanceClock(notQuiteInterval.Ticks);
+
+ // Assert - should not quite be able to issue another token
+ ShouldNotPermitAnExecution(rateLimiter, shortfallFromInterval);
+
+ // Arrange - Advance clock to the interval
+ AdvanceClock(shortfallFromInterval.Ticks);
+
+ // Act
+ ShouldPermitAnExecution(rateLimiter);
+
+ // Assert - but cannot get another token straight away
+ ShouldNotPermitAnExecution(rateLimiter);
+ }
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(100)]
+ public void Given_any_bucket_capacity_rate_limiter_permits_full_bucket_burst_after_exact_elapsed_time(int bucketCapacity)
+ {
+ FixClock();
+
+ // Arrange
+ int onePerSeconds = 1;
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetPolicyViaSyntax(1, onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity);
+ ShouldNotPermitAnExecution(rateLimiter);
+
+ // Arrange - advance exactly enough to permit a full bucket burst
+ AdvanceClock(onePer.Ticks * bucketCapacity);
+
+ // Assert - expect full bucket capacity but no more
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity);
+ ShouldNotPermitAnExecution(rateLimiter);
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(100)]
+ public void Given_any_bucket_capacity_rate_limiter_permits_half_full_bucket_burst_after_half_required_refill_time_elapsed(int bucketCapacity)
+ {
+ (bucketCapacity % 2).Should().Be(0);
+
+ FixClock();
+
+ // Arrange
+ int onePerSeconds = 1;
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetPolicyViaSyntax(1, onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity);
+ ShouldNotPermitAnExecution(rateLimiter);
+
+ // Arrange - advance multiple times enough to permit a full bucket burst
+ AdvanceClock(onePer.Ticks * (bucketCapacity / 2));
+
+ // Assert - expect full bucket capacity but no more
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity / 2);
+ ShouldNotPermitAnExecution(rateLimiter);
+ }
+
+ [Theory]
+ [InlineData(100, 2)]
+ [InlineData(100, 5)]
+ public void Given_any_bucket_capacity_rate_limiter_permits_only_full_bucket_burst_even_if_multiple_required_refill_time_elapsed(int bucketCapacity, int multipleRefillTimePassed)
+ {
+ multipleRefillTimePassed.Should().BeGreaterThan(1);
+
+ FixClock();
+
+ // Arrange
+ int onePerSeconds = 1;
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetPolicyViaSyntax(1, onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity);
+ ShouldNotPermitAnExecution(rateLimiter);
+
+ // Arrange - advance multiple times enough to permit a full bucket burst
+ AdvanceClock(onePer.Ticks * bucketCapacity * multipleRefillTimePassed);
+
+ // Assert - expect full bucket capacity but no more
+ ShouldPermitNExecutions(rateLimiter, bucketCapacity);
+ ShouldNotPermitAnExecution(rateLimiter);
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(5)]
+ [InlineData(100)]
+ public void Given_immediate_parallel_contention_ratelimiter_still_only_permits_one(int parallelContention)
+ {
+ FixClock();
+
+ // Arrange
+ TimeSpan onePer = TimeSpan.FromSeconds(1);
+ var rateLimiter = GetPolicyViaSyntax(1, onePer);
+
+ // Arrange - parallel tasks all waiting on a manual reset event.
+ ManualResetEventSlim gate = new ManualResetEventSlim();
+ Task<(bool permitExecution, TimeSpan retryAfter)>[] tasks = new Task<(bool, TimeSpan)>[parallelContention];
+ for (int i = 0; i < parallelContention; i++)
+ {
+ tasks[i] = Task.Run(() =>
+ {
+ gate.Wait();
+ return TryExecuteThroughPolicy(rateLimiter);
+ });
+ }
+
+ // Act - release gate.
+ gate.Set();
+ Within(TimeSpan.FromSeconds(10 /* high to allow for slow-running on time-slicing CI servers */), () => tasks.All(t => t.IsCompleted).Should().BeTrue());
+
+ // Assert - one should have permitted execution, n-1 not.
+ var results = tasks.Select(t => t.Result).ToList();
+ results.Count(r => r.permitExecution).Should().Be(1);
+ results.Count(r => !r.permitExecution).Should().Be(parallelContention - 1);
+ }
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/RateLimitPolicyTResultSpecs.cs b/src/Polly.Specs/RateLimit/RateLimitPolicyTResultSpecs.cs
new file mode 100644
index 00000000000..e67df0c2fca
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/RateLimitPolicyTResultSpecs.cs
@@ -0,0 +1,66 @@
+using System;
+using Polly.RateLimit;
+using Polly.Specs.Helpers;
+using Polly.Specs.Helpers.RateLimit;
+using Polly.Utilities;
+using Xunit;
+
+namespace Polly.Specs.RateLimit
+{
+ [Collection(Polly.Specs.Helpers.Constants.SystemClockDependentTestCollection)]
+ public class RateLimitPolicyTResultSpecs : RateLimitPolicyTResultSpecsBase, IDisposable
+ {
+ public void Dispose()
+ {
+ SystemClock.Reset();
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan)
+ {
+ return Policy.RateLimit(numberOfExecutions, perTimeSpan);
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan, int maxBurst)
+ {
+ return Policy.RateLimit(numberOfExecutions, perTimeSpan, maxBurst);
+ }
+
+ protected override IRateLimitPolicy GetPolicyViaSyntax(int numberOfExecutions, TimeSpan perTimeSpan, int maxBurst,
+ Func retryAfterFactory)
+ {
+ return Policy.RateLimit(numberOfExecutions, perTimeSpan, maxBurst, retryAfterFactory);
+ }
+
+ protected override (bool, TimeSpan) TryExecuteThroughPolicy(IRateLimitPolicy policy)
+ {
+ if (policy is RateLimitPolicy typedPolicy)
+ {
+ try
+ {
+ typedPolicy.Execute(() => new ResultClassWithRetryAfter(ResultPrimitive.Good));
+ return (true, TimeSpan.Zero);
+ }
+ catch (RateLimitRejectedException e)
+ {
+ return (false, e.RetryAfter);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected policy type in test construction.");
+ }
+ }
+
+ protected override TResult TryExecuteThroughPolicy(IRateLimitPolicy policy, Context context, TResult resultIfExecutionPermitted)
+ {
+ if (policy is RateLimitPolicy typedPolicy)
+ {
+ return typedPolicy.Execute(ctx => resultIfExecutionPermitted, context);
+ }
+ else
+ {
+ throw new InvalidOperationException("Unexpected policy type in test construction.");
+ }
+ }
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/RateLimitPolicyTResultSpecsBase.cs b/src/Polly.Specs/RateLimit/RateLimitPolicyTResultSpecsBase.cs
new file mode 100644
index 00000000000..3fc16eb21ef
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/RateLimitPolicyTResultSpecsBase.cs
@@ -0,0 +1,57 @@
+using System;
+using FluentAssertions;
+using Polly.RateLimit;
+using Polly.Specs.Helpers;
+using Polly.Specs.Helpers.RateLimit;
+using Xunit;
+
+namespace Polly.Specs.RateLimit
+{
+ public abstract class RateLimitPolicyTResultSpecsBase : RateLimitPolicySpecsBase
+ {
+ protected abstract IRateLimitPolicy GetPolicyViaSyntax(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst,
+ Func retryAfterFactory);
+
+ protected abstract TResult TryExecuteThroughPolicy(IRateLimitPolicy policy, Context context, TResult resultIfExecutionPermitted);
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(5)]
+ public void Ratelimiter_specifies_correct_wait_until_next_execution_by_custom_factory_passing_correct_context(int onePerSeconds)
+ {
+ FixClock();
+
+ // Arrange
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ Context contextPassedToRetryAfter = null;
+ Func retryAfterFactory = (t, ctx) =>
+ {
+ contextPassedToRetryAfter = ctx;
+ return new ResultClassWithRetryAfter(t);
+ };
+ var rateLimiter = GetPolicyViaSyntax(1, onePer, 1, retryAfterFactory);
+
+ // Arrange - drain first permitted execution after initialising.
+ ShouldPermitAnExecution(rateLimiter);
+
+ // Arrange
+ // (do nothing - time not advanced)
+
+ // Act - try another execution.
+ Context contextToPassIn = new Context();
+ var resultExpectedBlocked = TryExecuteThroughPolicy(rateLimiter, contextToPassIn, new ResultClassWithRetryAfter(ResultPrimitive.Good));
+
+ // Assert - should be blocked - time not advanced.
+ resultExpectedBlocked.ResultCode.Should().NotBe(ResultPrimitive.Good);
+ // Result should be expressed per the retryAfterFactory.
+ resultExpectedBlocked.RetryAfter.Should().Be(onePer);
+ // Context should have been passed to the retryAfterFactory.
+ contextPassedToRetryAfter.Should().NotBeNull();
+ contextPassedToRetryAfter.Should().BeSameAs(contextToPassIn);
+ }
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/RateLimitSpecsBase.cs b/src/Polly.Specs/RateLimit/RateLimitSpecsBase.cs
new file mode 100644
index 00000000000..b5bcd58360a
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/RateLimitSpecsBase.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using FluentAssertions.Execution;
+using Polly.Utilities;
+using Xunit.Sdk;
+
+namespace Polly.Specs.RateLimit
+{
+ public abstract class RateLimitSpecsBase
+ {
+ ///
+ /// Asserts that the actionContainingAssertions will succeed without or , within the given timespan. Checks are made each time a status-change pulse is received from the s executing through the bulkhead.
+ ///
+ /// The allowable timespan.
+ /// The action containing fluent assertions, which must succeed within the timespan.
+ protected void Within(TimeSpan timeSpan, Action actionContainingAssertions)
+ {
+ TimeSpan retryInterval = TimeSpan.FromSeconds(0.2);
+
+ Stopwatch watch = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ actionContainingAssertions.Invoke();
+ break;
+ }
+ catch (Exception e)
+ {
+ if (!(e is AssertionFailedException || e is XunitException)) { throw; }
+
+ if (watch.Elapsed > timeSpan) { throw; }
+
+ Thread.Sleep(retryInterval);
+ }
+ }
+ }
+
+ protected static void FixClock()
+ {
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ SystemClock.DateTimeOffsetUtcNow = () => now;
+ }
+
+ protected static void AdvanceClock(TimeSpan advance)
+ {
+ DateTimeOffset now = SystemClock.DateTimeOffsetUtcNow();
+ SystemClock.DateTimeOffsetUtcNow = () => now + advance;
+ }
+
+ protected static void AdvanceClock(long advanceTicks) => AdvanceClock(TimeSpan.FromTicks(advanceTicks));
+ }
+}
diff --git a/src/Polly.Specs/RateLimit/TokenBucketRateLimiterTestsBase.cs b/src/Polly.Specs/RateLimit/TokenBucketRateLimiterTestsBase.cs
new file mode 100644
index 00000000000..c296f0d5bf7
--- /dev/null
+++ b/src/Polly.Specs/RateLimit/TokenBucketRateLimiterTestsBase.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Polly.RateLimit;
+using Polly.Specs.Helpers.RateLimit;
+using Polly.Utilities;
+using Xunit;
+
+namespace Polly.Specs.RateLimit
+{
+ [Collection(Polly.Specs.Helpers.Constants.SystemClockDependentTestCollection)]
+ public abstract class TokenBucketRateLimiterTestsBase : RateLimitSpecsBase, IDisposable
+ {
+ internal abstract IRateLimiter GetRateLimiter(TimeSpan onePer, long bucketCapacity);
+
+ public void Dispose()
+ {
+ SystemClock.Reset();
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(5)]
+ public void Given_bucket_capacity_one_and_time_not_advanced_ratelimiter_specifies_correct_wait_until_next_execution(int onePerSeconds)
+ {
+ FixClock();
+
+ // Arrange
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetRateLimiter(onePer, 1);
+
+ // Assert - first execution after initialising should always be permitted.
+ rateLimiter.ShouldPermitAnExecution();
+
+ // Arrange
+ // (do nothing - time not advanced)
+
+ // Assert - should be blocked - time not advanced.
+ rateLimiter.ShouldNotPermitAnExecution(onePer);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(50)]
+ public void Given_bucket_capacity_N_and_time_not_advanced_ratelimiter_permits_executions_up_to_bucket_capacity(int bucketCapacity)
+ {
+ FixClock();
+
+ // Arrange.
+ TimeSpan onePer = TimeSpan.FromSeconds(1);
+ var rateLimiter = GetRateLimiter(onePer, bucketCapacity);
+
+ // Act - should be able to successfully take bucketCapacity items.
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity);
+
+ // Assert - should not be able to take any items (given time not advanced).
+ rateLimiter.ShouldNotPermitAnExecution(onePer);
+ }
+
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(2, 1)]
+ [InlineData(5, 1)]
+ [InlineData(1, 10)]
+ [InlineData(2, 10)]
+ [InlineData(5, 10)]
+ public void Given_any_bucket_capacity_ratelimiter_permits_another_execution_per_interval(int onePerSeconds, int bucketCapacity)
+ {
+ FixClock();
+
+ // Arrange
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetRateLimiter(onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity);
+ rateLimiter.ShouldNotPermitAnExecution();
+
+ // Act-Assert - repeatedly advance the clock towards the interval but not quite - then to the interval
+ int experimentRepeats = bucketCapacity * 3;
+ TimeSpan shortfallFromInterval = TimeSpan.FromTicks(1);
+ TimeSpan notQuiteInterval = onePer - shortfallFromInterval;
+ for (int i = 0; i < experimentRepeats; i++)
+ {
+ // Arrange - Advance clock not quite to the interval
+ AdvanceClock(notQuiteInterval.Ticks);
+
+ // Assert - should not quite be able to issue another token
+ rateLimiter.ShouldNotPermitAnExecution(shortfallFromInterval);
+
+ // Arrange - Advance clock to the interval
+ AdvanceClock(shortfallFromInterval.Ticks);
+
+ // Act
+ rateLimiter.ShouldPermitAnExecution();
+
+ // Assert - but cannot get another token straight away
+ rateLimiter.ShouldNotPermitAnExecution();
+ }
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(100)]
+ public void Given_any_bucket_capacity_rate_limiter_permits_full_bucket_burst_after_exact_elapsed_time(int bucketCapacity)
+ {
+ FixClock();
+
+ // Arrange
+ int onePerSeconds = 1;
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetRateLimiter(onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity);
+ rateLimiter.ShouldNotPermitAnExecution();
+
+ // Arrange - advance exactly enough to permit a full bucket burst
+ AdvanceClock(onePer.Ticks * bucketCapacity);
+
+ // Assert - expect full bucket capacity but no more
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity);
+ rateLimiter.ShouldNotPermitAnExecution();
+ }
+
+ [Theory]
+ [InlineData(10)]
+ [InlineData(100)]
+ public void Given_any_bucket_capacity_rate_limiter_permits_half_full_bucket_burst_after_half_required_refill_time_elapsed(int bucketCapacity)
+ {
+ (bucketCapacity % 2).Should().Be(0);
+
+ FixClock();
+
+ // Arrange
+ int onePerSeconds = 1;
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetRateLimiter(onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity);
+ rateLimiter.ShouldNotPermitAnExecution();
+
+ // Arrange - advance multiple times enough to permit a full bucket burst
+ AdvanceClock(onePer.Ticks * (bucketCapacity / 2));
+
+ // Assert - expect full bucket capacity but no more
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity / 2);
+ rateLimiter.ShouldNotPermitAnExecution();
+ }
+
+ [Theory]
+ [InlineData(100, 2)]
+ [InlineData(100, 5)]
+ public void Given_any_bucket_capacity_rate_limiter_permits_only_full_bucket_burst_even_if_multiple_required_refill_time_elapsed(int bucketCapacity, int multipleRefillTimePassed)
+ {
+ multipleRefillTimePassed.Should().BeGreaterThan(1);
+
+ FixClock();
+
+ // Arrange
+ int onePerSeconds = 1;
+ TimeSpan onePer = TimeSpan.FromSeconds(onePerSeconds);
+ var rateLimiter = GetRateLimiter(onePer, bucketCapacity);
+
+ // Arrange - spend the initial bucket capacity.
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity);
+ rateLimiter.ShouldNotPermitAnExecution();
+
+ // Arrange - advance multiple times enough to permit a full bucket burst
+ AdvanceClock(onePer.Ticks * bucketCapacity * multipleRefillTimePassed);
+
+ // Assert - expect full bucket capacity but no more
+ rateLimiter.ShouldPermitNExecutions(bucketCapacity);
+ rateLimiter.ShouldNotPermitAnExecution();
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(5)]
+ [InlineData(100)]
+ public void Given_immediate_parallel_contention_ratelimiter_still_only_permits_one(int parallelContention)
+ {
+ FixClock();
+
+ // Arrange
+ TimeSpan onePer = TimeSpan.FromSeconds(1);
+ var rateLimiter = GetRateLimiter(onePer, 1);
+
+ // Arrange - parallel tasks all waiting on a manual reset event.
+ ManualResetEventSlim gate = new ManualResetEventSlim();
+ Task<(bool permitExecution, TimeSpan retryAfter)>[] tasks = new Task<(bool, TimeSpan)>[parallelContention];
+ for (int i = 0; i < parallelContention; i++)
+ {
+ tasks[i] = Task.Run(() =>
+ {
+ gate.Wait();
+ return rateLimiter.PermitExecution();
+ });
+ }
+
+ // Act - release gate.
+ gate.Set();
+ Within(TimeSpan.FromSeconds(10 /* high to allow for slow-running on time-slicing CI servers */), () => tasks.All(t => t.IsCompleted).Should().BeTrue());
+
+ // Assert - one should have permitted execution, n-1 not.
+ var results = tasks.Select(t => t.Result).ToList();
+ results.Count(r => r.permitExecution).Should().Be(1);
+ results.Count(r => !r.permitExecution).Should().Be(parallelContention - 1);
+ }
+ }
+}
diff --git a/src/Polly/Polly.csproj b/src/Polly/Polly.csproj
index 0a4ab26d758..1a713ed28d5 100644
--- a/src/Polly/Polly.csproj
+++ b/src/Polly/Polly.csproj
@@ -4,14 +4,14 @@
netstandard1.1;netstandard2.0
Polly
Polly
- 7.1.0
+ 7.2.0
7.0.0.0
- 7.1.0.0
- 7.1.0.0
- 7.1.0
+ 7.2.0.0
+ 7.2.0.0
+ 7.2.0
App vNext
Copyright (c) 2019, App vNext
- Polly is a library that allows developers to express resilience and transient fault handling policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, and Fallback in a fluent and thread-safe manner.
+ Polly is a library that allows developers to express resilience and transient fault handling policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, Rate-Limit and Fallback in a fluent and thread-safe manner.
en-US
true
true
@@ -47,7 +47,7 @@
BSD-3-Clause
https://raw.github.com/App-vNext/Polly/master/Polly.png
https://github.com/App-vNext/Polly
- Exception Handling Resilience Transient Fault Policy Circuit Breaker CircuitBreaker Retry Wait Cache Cache-aside Bulkhead Fallback Timeout Throttle Parallelization
+ Exception Handling Resilience Transient Fault Policy Circuit Breaker CircuitBreaker Retry Wait Cache Cache-aside Bulkhead Rate-limit Fallback Timeout Throttle Parallelization
See https://github.com/App-vNext/Polly/blob/master/CHANGELOG.md for details
diff --git a/src/Polly/RateLimit/AsyncRateLimitEngine.cs b/src/Polly/RateLimit/AsyncRateLimitEngine.cs
new file mode 100644
index 00000000000..16f40caec77
--- /dev/null
+++ b/src/Polly/RateLimit/AsyncRateLimitEngine.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Polly.RateLimit
+{
+ internal static class AsyncRateLimitEngine
+ {
+ internal static async Task ImplementationAsync(
+ IRateLimiter rateLimiter,
+ Func retryAfterFactory,
+ Func> action,
+ Context context,
+ CancellationToken cancellationToken,
+ bool continueOnCapturedContext
+ )
+ {
+ (bool permit, TimeSpan retryAfter) = rateLimiter.PermitExecution();
+
+ if (permit)
+ {
+ return await action(context, cancellationToken).ConfigureAwait(continueOnCapturedContext);
+ }
+
+ if (retryAfterFactory != null)
+ {
+ return retryAfterFactory(retryAfter, context);
+ }
+
+ throw new RateLimitRejectedException(retryAfter);
+ }
+ }
+}
diff --git a/src/Polly/RateLimit/AsyncRateLimitPolicy.cs b/src/Polly/RateLimit/AsyncRateLimitPolicy.cs
new file mode 100644
index 00000000000..deb294f85a8
--- /dev/null
+++ b/src/Polly/RateLimit/AsyncRateLimitPolicy.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Polly.RateLimit
+{
+ ///
+ /// A rate-limit policy that can be applied to asynchronous delegates.
+ ///
+ public class AsyncRateLimitPolicy : AsyncPolicy, IRateLimitPolicy
+ {
+ private readonly IRateLimiter _rateLimiter;
+
+ internal AsyncRateLimitPolicy(IRateLimiter rateLimiter)
+ {
+ _rateLimiter = rateLimiter ?? throw new NullReferenceException(nameof(rateLimiter));
+ }
+
+ ///
+ [DebuggerStepThrough]
+ protected override Task ImplementationAsync(Func> action, Context context, CancellationToken cancellationToken,
+ bool continueOnCapturedContext)
+ => AsyncRateLimitEngine.ImplementationAsync(_rateLimiter, null, action, context, cancellationToken, continueOnCapturedContext);
+ }
+
+ ///
+ /// A rate-limit policy that can be applied to asynchronous delegates returning a value of type .
+ ///
+ public class AsyncRateLimitPolicy : AsyncPolicy, IRateLimitPolicy
+ {
+ private readonly IRateLimiter _rateLimiter;
+ private readonly Func _retryAfterFactory;
+
+ internal AsyncRateLimitPolicy(
+ IRateLimiter rateLimiter,
+ Func retryAfterFactory)
+ {
+ _rateLimiter = rateLimiter ?? throw new NullReferenceException(nameof(rateLimiter));
+ _retryAfterFactory = retryAfterFactory;
+ }
+
+ ///
+ [DebuggerStepThrough]
+ protected override Task ImplementationAsync(Func> action, Context context, CancellationToken cancellationToken,
+ bool continueOnCapturedContext)
+ => AsyncRateLimitEngine.ImplementationAsync(_rateLimiter, _retryAfterFactory, action, context, cancellationToken, continueOnCapturedContext);
+ }
+}
\ No newline at end of file
diff --git a/src/Polly/RateLimit/AsyncRateLimitSyntax.cs b/src/Polly/RateLimit/AsyncRateLimitSyntax.cs
new file mode 100644
index 00000000000..b3532c4f915
--- /dev/null
+++ b/src/Polly/RateLimit/AsyncRateLimitSyntax.cs
@@ -0,0 +1,43 @@
+using System;
+using Polly.RateLimit;
+
+namespace Polly
+{
+ public partial class Policy
+ {
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The policy instance.
+ public static AsyncRateLimitPolicy RateLimitAsync(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan)
+ {
+ return RateLimitAsync(numberOfExecutions, perTimeSpan, 1);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ /// This equates to the bucket-capacity of a token-bucket implementation.
+ /// The policy instance.
+ public static AsyncRateLimitPolicy RateLimitAsync(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst)
+ {
+ if (numberOfExecutions < 1) throw new ArgumentOutOfRangeException(nameof(numberOfExecutions), $"{nameof(numberOfExecutions)} per timespan must be an integer greater than or equal to 1.");
+ if (perTimeSpan <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(perTimeSpan), $"{nameof(perTimeSpan)} must be a positive timespan.");
+ if (maxBurst < 1) throw new ArgumentOutOfRangeException(nameof(maxBurst), $"{nameof(maxBurst)} must be an integer greater than or equal to 1.");
+
+ IRateLimiter rateLimiter = RateLimiterFactory.Create(TimeSpan.FromTicks(perTimeSpan.Ticks / numberOfExecutions), maxBurst);
+
+ return new AsyncRateLimitPolicy(rateLimiter);
+ }
+ }
+}
diff --git a/src/Polly/RateLimit/AsyncRateLimitTResultSyntax.cs b/src/Polly/RateLimit/AsyncRateLimitTResultSyntax.cs
new file mode 100644
index 00000000000..4dd97732231
--- /dev/null
+++ b/src/Polly/RateLimit/AsyncRateLimitTResultSyntax.cs
@@ -0,0 +1,83 @@
+using System;
+using Polly.RateLimit;
+
+namespace Polly
+{
+ public partial class Policy
+ {
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The policy instance.
+ public static AsyncRateLimitPolicy RateLimitAsync(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan)
+ {
+ return RateLimitAsync(numberOfExecutions, perTimeSpan, null);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// An (optional) factory to express the recommended retry-after time back to the caller, when an operation is rate-limited.
+ /// If null, a with property will be thrown to indicate rate-limiting.
+ /// The policy instance.
+ public static AsyncRateLimitPolicy RateLimitAsync(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ Func retryAfterFactory)
+ {
+ return RateLimitAsync(numberOfExecutions, perTimeSpan, 1, retryAfterFactory);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ /// This equates to the bucket-capacity of a token-bucket implementation.
+ /// The policy instance.
+ public static AsyncRateLimitPolicy RateLimitAsync(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst)
+ {
+ return RateLimitAsync(numberOfExecutions, perTimeSpan, maxBurst, null);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given,
+ /// with a maximum burst size of
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ /// This equates to the bucket-capacity of a token-bucket implementation.
+ /// An (optional) factory to use to express retry-after back to the caller, when an operation is rate-limited.
+ /// If null, a with property will be thrown to indicate rate-limiting.
+ /// The policy instance.
+ public static AsyncRateLimitPolicy RateLimitAsync(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst,
+ Func retryAfterFactory)
+ {
+ if (numberOfExecutions < 1) throw new ArgumentOutOfRangeException(nameof(numberOfExecutions), $"{nameof(numberOfExecutions)} per timespan must be an integer greater than or equal to 1.");
+ if (perTimeSpan <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(perTimeSpan), $"{nameof(perTimeSpan)} must be a positive timespan.");
+ if (maxBurst < 1) throw new ArgumentOutOfRangeException(nameof(maxBurst), $"{nameof(maxBurst)} must be an integer greater than or equal to 1.");
+
+ IRateLimiter rateLimiter = RateLimiterFactory.Create(TimeSpan.FromTicks(perTimeSpan.Ticks / numberOfExecutions), maxBurst);
+
+ return new AsyncRateLimitPolicy(rateLimiter, retryAfterFactory);
+ }
+ }
+}
diff --git a/src/Polly/RateLimit/IRateLimitPolicy.cs b/src/Polly/RateLimit/IRateLimitPolicy.cs
new file mode 100644
index 00000000000..08127fde2fc
--- /dev/null
+++ b/src/Polly/RateLimit/IRateLimitPolicy.cs
@@ -0,0 +1,17 @@
+namespace Polly.RateLimit
+{
+ ///
+ /// Defines properties and methods common to all RateLimit policies.
+ ///
+
+ public interface IRateLimitPolicy : IsPolicy
+ {
+ }
+
+ ///
+ /// Defines properties and methods common to all RateLimit policies generic-typed for executions returning results of type .
+ ///
+ public interface IRateLimitPolicy : IRateLimitPolicy
+ {
+ }
+}
diff --git a/src/Polly/RateLimit/IRateLimiter.cs b/src/Polly/RateLimit/IRateLimiter.cs
new file mode 100644
index 00000000000..79e724f3acb
--- /dev/null
+++ b/src/Polly/RateLimit/IRateLimiter.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Polly.RateLimit
+{
+ ///
+ /// Defines methods to be provided by a rate-limiter used in a Polly
+ ///
+ internal interface IRateLimiter
+ {
+ ///
+ /// Returns whether the execution is permitted; if not, returns what should be waited before retrying.
+ /// Calling this method consumes an execution permit if one is available: a caller receiving a return value true should make an execution.
+ ///
+ (bool permitExecution, TimeSpan retryAfter) PermitExecution();
+ }
+}
diff --git a/src/Polly/RateLimit/LockBasedTokenBucketRateLimiter.cs b/src/Polly/RateLimit/LockBasedTokenBucketRateLimiter.cs
new file mode 100644
index 00000000000..26feb3d05cd
--- /dev/null
+++ b/src/Polly/RateLimit/LockBasedTokenBucketRateLimiter.cs
@@ -0,0 +1,94 @@
+using System;
+using Polly.Utilities;
+
+namespace Polly.RateLimit
+{
+ ///
+ /// A lock-based token-bucket rate-limiter for a Polly .
+ ///
+ internal class LockBasedTokenBucketRateLimiter : IRateLimiter
+ {
+ private readonly long addTokenTickInterval;
+ private readonly long bucketCapacity;
+
+ private long currentTokens;
+
+ private long addNextTokenAtTicks;
+
+ private readonly object _lock = new object();
+
+ ///
+ /// Creates an instance of
+ ///
+ /// How often one execution is permitted.
+ /// The capacity of the token bucket.
+ /// This equates to the maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ ///
+ public LockBasedTokenBucketRateLimiter(TimeSpan onePer, long bucketCapacity)
+ {
+ if (onePer <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(onePer), $"The ${nameof(LockFreeTokenBucketRateLimiter)} must specify a positive TimeSpan for how often an execution is permitted.");
+ if (bucketCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(bucketCapacity), $"The ${bucketCapacity} must be greater than or equal to 1.");
+
+ addTokenTickInterval = onePer.Ticks;
+ this.bucketCapacity = bucketCapacity;
+
+ currentTokens = bucketCapacity;
+ addNextTokenAtTicks = SystemClock.DateTimeOffsetUtcNow().Ticks + addTokenTickInterval;
+ }
+
+ ///
+ /// Returns whether the execution is permitted; if not, returns what should be waited before retrying.
+ ///
+ public (bool permitExecution, TimeSpan retryAfter) PermitExecution()
+ {
+ using (TimedLock.Lock(_lock))
+ {
+ // Try to get a token.
+ if (--currentTokens >= 0)
+ {
+ // We got a token: permit execution!
+ return (true, TimeSpan.Zero);
+ }
+ else
+ {
+ // No tokens! We're rate-limited - unless we can refill the bucket.
+ long now = SystemClock.DateTimeOffsetUtcNow().Ticks;
+
+ long ticksTillAddNextToken = addNextTokenAtTicks - now;
+ if (ticksTillAddNextToken > 0)
+ {
+ // Not time to add tokens yet: we're rate-limited!
+ return (false, TimeSpan.FromTicks(ticksTillAddNextToken));
+ }
+ else
+ {
+ // Time to add tokens to the bucket!
+
+ // We definitely need to add one token. In fact, if we haven't hit this bit of code for a while, we might be due to add a bunch of tokens.
+ long tokensMissedAdding =
+ // Passing addNextTokenAtTicks merits one token
+ 1 +
+ // And any whole token tick intervals further each merit another.
+ (-ticksTillAddNextToken / addTokenTickInterval);
+
+ // We mustn't exceed bucket capacity though.
+ long tokensToAdd = Math.Min(bucketCapacity, tokensMissedAdding);
+
+ // Work out when tokens would next be due to be added, if we add these tokens.
+ long newAddNextTokenAtTicks = addNextTokenAtTicks + tokensToAdd * addTokenTickInterval;
+ // But if we were way overdue refilling the bucket (there was inactivity for a while), that value would be out-of-date: the next time we add tokens must be at least addTokenTickInterval from now.
+ newAddNextTokenAtTicks = Math.Max(newAddNextTokenAtTicks, now + addTokenTickInterval);
+
+ addNextTokenAtTicks = newAddNextTokenAtTicks;
+
+ // Theoretically we want to add tokensToAdd tokens. But in fact we don't do that. We want to claim one of those tokens for ourselves.
+ // So in fact we add (tokensToAdd - 1) tokens (ie we consume one), and return, permitting this execution.
+ currentTokens = tokensToAdd - 1;
+ return (true, TimeSpan.Zero);
+ }
+ }
+ }
+
+ }
+ }
+}
diff --git a/src/Polly/RateLimit/LockFreeTokenBucketRateLimiter.cs b/src/Polly/RateLimit/LockFreeTokenBucketRateLimiter.cs
new file mode 100644
index 00000000000..42571d735be
--- /dev/null
+++ b/src/Polly/RateLimit/LockFreeTokenBucketRateLimiter.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Threading;
+using Polly.Utilities;
+
+namespace Polly.RateLimit
+{
+ ///
+ /// A lock-free token-bucket rate-limiter for a Polly .
+ ///
+ internal class LockFreeTokenBucketRateLimiter : IRateLimiter
+ {
+ private readonly long addTokenTickInterval;
+ private readonly long bucketCapacity;
+
+ private long currentTokens;
+
+ private long addNextTokenAtTicks;
+
+#if !NETSTANDARD2_0
+ SpinWait spinner = new SpinWait();
+#endif
+
+ ///
+ /// Creates an instance of
+ ///
+ /// How often one execution is permitted.
+ /// The capacity of the token bucket.
+ /// This equates to the maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ ///
+ public LockFreeTokenBucketRateLimiter(TimeSpan onePer, long bucketCapacity)
+ {
+ if (onePer <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(onePer), $"The ${nameof(LockFreeTokenBucketRateLimiter)} must specify a positive TimeSpan for how often an execution is permitted.");
+ if (bucketCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(bucketCapacity), $"The ${bucketCapacity} must be greater than or equal to 1.");
+
+ addTokenTickInterval = onePer.Ticks;
+ this.bucketCapacity = bucketCapacity;
+
+ currentTokens = bucketCapacity;
+ addNextTokenAtTicks = SystemClock.DateTimeOffsetUtcNow().Ticks + addTokenTickInterval;
+ }
+
+ ///
+ /// Returns whether the execution is permitted; if not, returns what should be waited before retrying.
+ ///
+ public (bool permitExecution, TimeSpan retryAfter) PermitExecution()
+ {
+ while (true)
+ {
+ // Try to get a token.
+ long tokensAfterGrabOne = Interlocked.Decrement(ref currentTokens);
+
+ if (tokensAfterGrabOne >= 0)
+ {
+ // We got a token: permit execution!
+ return (true, TimeSpan.Zero);
+ }
+
+ // No tokens! We're rate-limited - unless we can refill the bucket.
+ long now = SystemClock.DateTimeOffsetUtcNow().Ticks;
+ long currentAddNextTokenAtTicks = Interlocked.Read(ref addNextTokenAtTicks);
+ long ticksTillAddNextToken = currentAddNextTokenAtTicks - now;
+
+ if (ticksTillAddNextToken > 0)
+ {
+ // Not time to add tokens yet: we're rate-limited!
+ return (false, TimeSpan.FromTicks(ticksTillAddNextToken));
+ }
+
+ // Time to add tokens to the bucket!
+
+ // We definitely need to add one token. In fact, if we haven't hit this bit of code for a while, we might be due to add a bunch of tokens.
+ long tokensMissedAdding =
+ // Passing addNextTokenAtTicks merits one token
+ 1 +
+ // And any whole token tick intervals further each merit another.
+ (-ticksTillAddNextToken / addTokenTickInterval);
+
+ // We mustn't exceed bucket capacity though.
+ long tokensToAdd = Math.Min(bucketCapacity, tokensMissedAdding);
+
+ // Work out when tokens would next be due to be added, if we add these tokens.
+ long newAddNextTokenAtTicks = currentAddNextTokenAtTicks + tokensToAdd * addTokenTickInterval;
+ // But if we were way overdue refilling the bucket (there was inactivity for a while), that value would be out-of-date: the next time we add tokens must be at least addTokenTickInterval from now.
+ newAddNextTokenAtTicks = Math.Max(newAddNextTokenAtTicks, now + addTokenTickInterval);
+
+ // Now see if we win the race to add these tokens. Other threads might be racing through this code at the same time: only one thread must add the tokens!
+ if (Interlocked.CompareExchange(ref addNextTokenAtTicks, newAddNextTokenAtTicks, currentAddNextTokenAtTicks) == currentAddNextTokenAtTicks)
+ {
+ // We won the race to add the tokens!
+
+ // Theoretically we want to add tokensToAdd tokens. But in fact we don't do that.
+ // We want to claim one of those tokens for ourselves - there's no way we're going to add it but let another thread snatch it from under our nose.
+ // (Doing that could leave this thread looping round adding tokens for ever which other threads just snatch - would lead to odd observed behaviour.)
+
+ // So in fact we add (tokensToAdd - 1) tokens (ie we consume one), and return, permitting this execution.
+
+ // The advantage of only adding tokens when the bucket is empty is that we can now hard set the new amount of tokens (Interlocked.Exchange) without caring if other threads have simultaneously been taking or adding tokens.
+ // (If we added a token per addTokenTickInterval to a non-empty bucket, the reasoning about not overflowing the bucket seems harder.)
+ Interlocked.Exchange(ref currentTokens, tokensToAdd - 1);
+ return (true, TimeSpan.Zero);
+ }
+ else
+ {
+ // We didn't win the race to add the tokens. BUT because it _was_ time to add tokens, another thread must have won that race and have added/be adding tokens, so there _may_ be more tokens, so loop and try again.
+
+ // We want any thread refilling the bucket to have a chance to do so before we try to grab the next token.
+#if NETSTANDARD2_0
+ Thread.Sleep(0);
+#else
+ spinner.SpinOnce();
+#endif
+ }
+ }
+ }
+ }
+}
diff --git a/src/Polly/RateLimit/RateLimitEngine.cs b/src/Polly/RateLimit/RateLimitEngine.cs
new file mode 100644
index 00000000000..a96cbb2835d
--- /dev/null
+++ b/src/Polly/RateLimit/RateLimitEngine.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Threading;
+
+namespace Polly.RateLimit
+{
+ internal static class RateLimitEngine
+ {
+ internal static TResult Implementation(
+ IRateLimiter rateLimiter,
+ Func retryAfterFactory,
+ Func action,
+ Context context,
+ CancellationToken cancellationToken
+ )
+ {
+ (bool permit, TimeSpan retryAfter) = rateLimiter.PermitExecution();
+
+ if (permit)
+ {
+ return action(context, cancellationToken);
+ }
+
+ if (retryAfterFactory != null)
+ {
+ return retryAfterFactory(retryAfter, context);
+ }
+
+ throw new RateLimitRejectedException(retryAfter);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Polly/RateLimit/RateLimitPolicy.cs b/src/Polly/RateLimit/RateLimitPolicy.cs
new file mode 100644
index 00000000000..a338c0ecd91
--- /dev/null
+++ b/src/Polly/RateLimit/RateLimitPolicy.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+
+namespace Polly.RateLimit
+{
+ ///
+ /// A rate-limit policy that can be applied to synchronous delegates.
+ ///
+ public class RateLimitPolicy : Policy, IRateLimitPolicy
+ {
+ private readonly IRateLimiter _rateLimiter;
+
+ internal RateLimitPolicy(IRateLimiter rateLimiter)
+ {
+ _rateLimiter = rateLimiter ?? throw new NullReferenceException(nameof(rateLimiter));
+ }
+
+ ///
+ [DebuggerStepThrough]
+ protected override TResult Implementation(Func action, Context context, CancellationToken cancellationToken)
+ => RateLimitEngine.Implementation(_rateLimiter, null, action, context, cancellationToken);
+ }
+
+ ///
+ /// A rate-limit policy that can be applied to synchronous delegates returning a value of type .
+ ///
+ public class RateLimitPolicy : Policy, IRateLimitPolicy
+ {
+ private readonly IRateLimiter _rateLimiter;
+ private readonly Func _retryAfterFactory;
+
+ internal RateLimitPolicy(
+ IRateLimiter rateLimiter,
+ Func retryAfterFactory)
+ {
+ _rateLimiter = rateLimiter ?? throw new NullReferenceException(nameof(rateLimiter));
+ _retryAfterFactory = retryAfterFactory;
+ }
+
+ ///
+ [DebuggerStepThrough]
+ protected override TResult Implementation(Func action, Context context, CancellationToken cancellationToken)
+ => RateLimitEngine.Implementation(_rateLimiter, _retryAfterFactory, action, context, cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/Polly/RateLimit/RateLimitRejectedException.cs b/src/Polly/RateLimit/RateLimitRejectedException.cs
new file mode 100644
index 00000000000..c26f39585aa
--- /dev/null
+++ b/src/Polly/RateLimit/RateLimitRejectedException.cs
@@ -0,0 +1,79 @@
+using System;
+#if NETSTANDARD2_0
+using System.Runtime.Serialization;
+#endif
+
+namespace Polly.RateLimit
+{
+ ///
+ /// Exception thrown when a delegate executed through a is rate-limited.
+ ///
+#if NETSTANDARD2_0
+ [Serializable]
+#endif
+ public class RateLimitRejectedException : ExecutionRejectedException
+ {
+ ///
+ /// The timespan after which the operation may be retried.
+ ///
+ public TimeSpan RetryAfter { get; private set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The timespan after which the operation may be retried.
+ public RateLimitRejectedException(TimeSpan retryAfter) : this(retryAfter, DefaultMessage(retryAfter))
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The timespan after which the operation may be retried.
+ /// The inner exception.
+ public RateLimitRejectedException(TimeSpan retryAfter, Exception innerException) : base(DefaultMessage(retryAfter), innerException)
+ {
+ SetRetryAfter(retryAfter);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The timespan after which the operation may be retried.
+ /// The message.
+ public RateLimitRejectedException(TimeSpan retryAfter, String message) : base(message)
+ {
+ SetRetryAfter(retryAfter);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message.
+ /// The timespan after which the operation may be retried.
+ /// The inner exception.
+ public RateLimitRejectedException(TimeSpan retryAfter, String message, Exception innerException) : base(message, innerException)
+ {
+ SetRetryAfter(retryAfter);
+ }
+
+ private void SetRetryAfter(TimeSpan retryAfter)
+ {
+ if (retryAfter < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(retryAfter), $"The {nameof(retryAfter)} parameter must be a TimeSpan greater than or equal to TimeSpan.Zero.");
+ RetryAfter = retryAfter;
+ }
+
+ private static string DefaultMessage(TimeSpan retryAfter) => $"The operation has been rate-limited and should be retried after ${retryAfter}";
+
+#if NETSTANDARD2_0
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The information.
+ /// The context.
+ protected RateLimitRejectedException(SerializationInfo info, StreamingContext context) : base(info, context)
+ {
+ }
+#endif
+ }
+}
diff --git a/src/Polly/RateLimit/RateLimitSyntax.cs b/src/Polly/RateLimit/RateLimitSyntax.cs
new file mode 100644
index 00000000000..5d16f9103fd
--- /dev/null
+++ b/src/Polly/RateLimit/RateLimitSyntax.cs
@@ -0,0 +1,43 @@
+using System;
+using Polly.RateLimit;
+
+namespace Polly
+{
+ public partial class Policy
+ {
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The policy instance.
+ public static RateLimitPolicy RateLimit(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan)
+ {
+ return RateLimit(numberOfExecutions, perTimeSpan, 1);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ /// This equates to the bucket-capacity of a token-bucket implementation.
+ /// The policy instance.
+ public static RateLimitPolicy RateLimit(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst)
+ {
+ if (numberOfExecutions < 1) throw new ArgumentOutOfRangeException(nameof(numberOfExecutions), $"{nameof(numberOfExecutions)} per timespan must be an integer greater than or equal to 1.");
+ if (perTimeSpan <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(perTimeSpan), $"{nameof(perTimeSpan)} must be a positive timespan.");
+ if (maxBurst < 1) throw new ArgumentOutOfRangeException(nameof(maxBurst), $"{nameof(maxBurst)} must be an integer greater than or equal to 1.");
+
+ IRateLimiter rateLimiter = RateLimiterFactory.Create(TimeSpan.FromTicks(perTimeSpan.Ticks / numberOfExecutions), maxBurst);
+
+ return new RateLimitPolicy(rateLimiter);
+ }
+ }
+}
diff --git a/src/Polly/RateLimit/RateLimitTResultSyntax.cs b/src/Polly/RateLimit/RateLimitTResultSyntax.cs
new file mode 100644
index 00000000000..9314ffe21dc
--- /dev/null
+++ b/src/Polly/RateLimit/RateLimitTResultSyntax.cs
@@ -0,0 +1,83 @@
+using System;
+using Polly.RateLimit;
+
+namespace Polly
+{
+ public partial class Policy
+ {
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The policy instance.
+ public static RateLimitPolicy RateLimit(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan)
+ {
+ return RateLimit(numberOfExecutions, perTimeSpan, null);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// An (optional) factory to express the recommended retry-after time back to the caller, when an operation is rate-limited.
+ /// If null, a with property will be thrown to indicate rate-limiting.
+ /// The policy instance.
+ public static RateLimitPolicy RateLimit(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ Func retryAfterFactory)
+ {
+ return RateLimit(numberOfExecutions, perTimeSpan, 1, retryAfterFactory);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given.
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ /// This equates to the bucket-capacity of a token-bucket implementation.
+ /// The policy instance.
+ public static RateLimitPolicy RateLimit(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst)
+ {
+ return RateLimit(numberOfExecutions, perTimeSpan, maxBurst, null);
+ }
+
+ ///
+ /// Builds a RateLimit that will rate-limit executions to per the timespan given,
+ /// with a maximum burst size of
+ ///
+ /// The type of return values this policy will handle.
+ /// The number of executions (call it N) permitted per timespan.
+ /// How often N executions are permitted.
+ /// The maximum number of executions that will be permitted in a single burst (for example if none have been executed for a while).
+ /// This equates to the bucket-capacity of a token-bucket implementation.
+ /// An (optional) factory to use to express retry-after back to the caller, when an operation is rate-limited.
+ /// If null, a with property will be thrown to indicate rate-limiting.
+ /// The policy instance.
+ public static RateLimitPolicy RateLimit(
+ int numberOfExecutions,
+ TimeSpan perTimeSpan,
+ int maxBurst,
+ Func retryAfterFactory)
+ {
+ if (numberOfExecutions < 1) throw new ArgumentOutOfRangeException(nameof(numberOfExecutions), $"{nameof(numberOfExecutions)} per timespan must be an integer greater than or equal to 1.");
+ if (perTimeSpan <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(perTimeSpan), $"{nameof(perTimeSpan)} must be a positive timespan.");
+ if (maxBurst < 1) throw new ArgumentOutOfRangeException(nameof(maxBurst), $"{nameof(maxBurst)} must be an integer greater than or equal to 1.");
+
+ IRateLimiter rateLimiter = RateLimiterFactory.Create(TimeSpan.FromTicks(perTimeSpan.Ticks / numberOfExecutions), maxBurst);
+
+ return new RateLimitPolicy(rateLimiter, retryAfterFactory);
+ }
+ }
+}
diff --git a/src/Polly/RateLimit/RateLimiterFactory.cs b/src/Polly/RateLimit/RateLimiterFactory.cs
new file mode 100644
index 00000000000..86adda09cb0
--- /dev/null
+++ b/src/Polly/RateLimit/RateLimiterFactory.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace Polly.RateLimit
+{
+ internal class RateLimiterFactory
+ {
+ public static IRateLimiter Create(TimeSpan onePer, int bucketCapacity)
+ => new LockFreeTokenBucketRateLimiter(onePer, bucketCapacity);
+/*
+ => new LockBasedTokenBucketRateLimiter(onePer, bucketCapacity);
+*/
+ }
+}