Repository: App-vNext/Polly

Issue:
Limiting max retry delay when using standard backoff types
With current API it's not possible to limit max retry delay while using built-in backoff types.

We have a scenario where we infinitely retry failing background operations until recovery. Once the failures start, we want to backoff exponentially using the built-in generators, but eventually limit the calculated delay to some maximum value. 

Consider adding `TimeSpan MaxRetryDelay?` property to the retry options that will cap the generated delay value. It might be applicable both for built-in and externally provided delay generators.

cc @martintmk 


Hints:
I think this is a valid scenario. The changes should be relatively small so we can still squeeze this into v8. 

Minor comments only:

- `MaxRetryDelay` should be `MaxDelay` - because we have the `Delay` property
- Maybe not apply the cap on external generators? (as the generation is fully under their control)

cc @martincostello 

Patch:
diff --git a/src/Polly.Core/PublicAPI.Unshipped.txt b/src/Polly.Core/PublicAPI.Unshipped.txt
index 74c19f8c4ed..96906d8113d 100644
--- a/src/Polly.Core/PublicAPI.Unshipped.txt
+++ b/src/Polly.Core/PublicAPI.Unshipped.txt
@@ -309,6 +309,8 @@ Polly.Retry.RetryStrategyOptions<TResult>.Delay.get -> System.TimeSpan
 Polly.Retry.RetryStrategyOptions<TResult>.Delay.set -> void
 Polly.Retry.RetryStrategyOptions<TResult>.DelayGenerator.get -> System.Func<Polly.Retry.RetryDelayGeneratorArguments<TResult>, System.Threading.Tasks.ValueTask<System.TimeSpan?>>?
 Polly.Retry.RetryStrategyOptions<TResult>.DelayGenerator.set -> void
+Polly.Retry.RetryStrategyOptions<TResult>.MaxDelay.get -> System.TimeSpan?
+Polly.Retry.RetryStrategyOptions<TResult>.MaxDelay.set -> void
 Polly.Retry.RetryStrategyOptions<TResult>.MaxRetryAttempts.get -> int
 Polly.Retry.RetryStrategyOptions<TResult>.MaxRetryAttempts.set -> void
 Polly.Retry.RetryStrategyOptions<TResult>.OnRetry.get -> System.Func<Polly.Retry.OnRetryArguments<TResult>, System.Threading.Tasks.ValueTask>?
diff --git a/src/Polly.Core/Retry/RetryHelper.cs b/src/Polly.Core/Retry/RetryHelper.cs
index c2afdcf13bd..333a85d9b88 100644
--- a/src/Polly.Core/Retry/RetryHelper.cs
+++ b/src/Polly.Core/Retry/RetryHelper.cs
@@ -8,11 +8,26 @@ internal static class RetryHelper
 
     public static bool IsValidDelay(TimeSpan delay) => delay >= TimeSpan.Zero;
 
-    public static TimeSpan GetRetryDelay(DelayBackoffType type, bool jitter, int attempt, TimeSpan baseDelay, ref double state, Func<double> randomizer)
+    public static TimeSpan GetRetryDelay(
+        DelayBackoffType type,
+        bool jitter,
+        int attempt,
+        TimeSpan baseDelay,
+        TimeSpan? maxDelay,
+        ref double state,
+        Func<double> randomizer)
     {
         try
         {
-            return GetRetryDelayCore(type, jitter, attempt, baseDelay, ref state, randomizer);
+            var delay = GetRetryDelayCore(type, jitter, attempt, baseDelay, ref state, randomizer);
+
+            // stryker disable once equality : no means to test this
+            if (maxDelay is TimeSpan maxDelayValue && delay > maxDelayValue)
+            {
+                return maxDelay.Value;
+            }
+
+            return delay;
         }
         catch (OverflowException)
         {
@@ -89,7 +104,7 @@ private static TimeSpan DecorrelatedJitterBackoffV2(int attempt, TimeSpan baseDe
         long targetTicksFirstDelay = baseDelay.Ticks;
 
         double t = attempt + randomizer();
-        double next = Math.Pow(2, t) * Math.Tanh(Math.Sqrt(PFactor * t));
+        double next = Math.Pow(ExponentialFactor, t) * Math.Tanh(Math.Sqrt(PFactor * t));
 
         double formulaIntrinsicValue = next - prev;
         prev = next;
@@ -98,5 +113,11 @@ private static TimeSpan DecorrelatedJitterBackoffV2(int attempt, TimeSpan baseDe
     }
 
     private static TimeSpan ApplyJitter(TimeSpan delay, Func<double> randomizer)
-        => TimeSpan.FromMilliseconds(delay.TotalMilliseconds + ((delay.TotalMilliseconds * JitterFactor) * randomizer()));
+    {
+        var offset = (delay.TotalMilliseconds * JitterFactor) / 2;
+        var randomDelay = (delay.TotalMilliseconds * JitterFactor * randomizer()) - offset;
+        var newDelay = delay.TotalMilliseconds + randomDelay;
+
+        return TimeSpan.FromMilliseconds(newDelay);
+    }
 }
diff --git a/src/Polly.Core/Retry/RetryResilienceStrategy.cs b/src/Polly.Core/Retry/RetryResilienceStrategy.cs
index 9eb5f1068c0..0b9dfbacde1 100644
--- a/src/Polly.Core/Retry/RetryResilienceStrategy.cs
+++ b/src/Polly.Core/Retry/RetryResilienceStrategy.cs
@@ -15,6 +15,7 @@ public RetryResilienceStrategy(
     {
         ShouldHandle = options.ShouldHandle;
         BaseDelay = options.Delay;
+        MaxDelay = options.MaxDelay;
         BackoffType = options.BackoffType;
         RetryCount = options.MaxRetryAttempts;
         OnRetry = options.OnRetry;
@@ -28,6 +29,8 @@ public RetryResilienceStrategy(
 
     public TimeSpan BaseDelay { get; }
 
+    public TimeSpan? MaxDelay { get; }
+
     public DelayBackoffType BackoffType { get; }
 
     public int RetryCount { get; }
@@ -61,7 +64,7 @@ protected internal override async ValueTask<Outcome<T>> ExecuteCore<TState>(Func
                 return outcome;
             }
 
-            var delay = RetryHelper.GetRetryDelay(BackoffType, UseJitter, attempt, BaseDelay, ref retryState, _randomizer);
+            var delay = RetryHelper.GetRetryDelay(BackoffType, UseJitter, attempt, BaseDelay, MaxDelay, ref retryState, _randomizer);
             if (DelayGenerator is not null)
             {
                 var delayArgs = new RetryDelayGeneratorArguments<T>(context, outcome, attempt);
diff --git a/src/Polly.Core/Retry/RetryStrategyOptions.TResult.cs b/src/Polly.Core/Retry/RetryStrategyOptions.TResult.cs
index fa568480694..70375d07f6c 100644
--- a/src/Polly.Core/Retry/RetryStrategyOptions.TResult.cs
+++ b/src/Polly.Core/Retry/RetryStrategyOptions.TResult.cs
@@ -3,6 +3,8 @@
 
 namespace Polly.Retry;
 
+#pragma warning disable IL2026 // Addressed with DynamicDependency on ValidationHelper.Validate method
+
 /// <summary>
 /// Represents the options used to configure a retry strategy.
 /// </summary>
@@ -49,7 +51,6 @@ public class RetryStrategyOptions<TResult> : ResilienceStrategyOptions
     /// </value>
     public bool UseJitter { get; set; }
 
-#pragma warning disable IL2026 // Addressed with DynamicDependency on ValidationHelper.Validate method
     /// <summary>
     /// Gets or sets the base delay between retries.
     /// </summary>
@@ -73,7 +74,20 @@ public class RetryStrategyOptions<TResult> : ResilienceStrategyOptions
     /// </value>
     [Range(typeof(TimeSpan), "00:00:00", "1.00:00:00")]
     public TimeSpan Delay { get; set; } = RetryConstants.DefaultBaseDelay;
-#pragma warning restore IL2026
+
+    /// <summary>
+    /// Gets or sets the maximum delay between retries.
+    /// </summary>
+    /// <remarks>
+    /// This property is used to cap maximum delay between retries. It is useful when you want to limit the maximum delay after certain
+    /// number of between retries when it could reach a unreasonably high values, especially if <see cref="DelayBackoffType.Exponential"/> backoff is used.
+    /// If not specified, the delay is not capped. This property is ignored for delays generated by <see cref="DelayGenerator"/>.
+    /// </remarks>
+    /// <value>
+    /// The default value is <see langword="null"/>.
+    /// </value>
+    [Range(typeof(TimeSpan), "00:00:00", "1.00:00:00")]
+    public TimeSpan? MaxDelay { get; set; }
 
     /// <summary>
     /// Gets or sets a predicate that determines whether the retry should be executed for a given outcome.


Test patch:
diff --git a/test/Polly.Core.Tests/Retry/RetryHelperTests.cs b/test/Polly.Core.Tests/Retry/RetryHelperTests.cs
index f5d78eac08e..0deb85d2f7c 100644
--- a/test/Polly.Core.Tests/Retry/RetryHelperTests.cs
+++ b/test/Polly.Core.Tests/Retry/RetryHelperTests.cs
@@ -28,57 +28,108 @@ public void UnsupportedRetryBackoffType_Throws(bool jitter)
         Assert.Throws<ArgumentOutOfRangeException>(() =>
         {
             double state = 0;
-            return RetryHelper.GetRetryDelay(type, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer);
+            return RetryHelper.GetRetryDelay(type, jitter, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer);
         });
     }
 
-    [InlineData(true)]
-    [InlineData(false)]
-    [Theory]
-    public void Constant_Ok(bool jitter)
+    [Fact]
+    public void Constant_Ok()
     {
         double state = 0;
-        if (jitter)
-        {
-            _randomizer = () => 0.5;
-        }
 
-        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 0, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 1, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 2, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
+    }
 
-        var expected = !jitter ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(1.25);
+    [Fact]
+    public void Constant_Jitter_Ok()
+    {
+        double state = 0;
 
-        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, jitter, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+
+        _randomizer = () => 0.0;
+        RetryHelper
+            .GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(0.75));
+
+        _randomizer = () => 0.4;
+        RetryHelper
+            .GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(0.95));
+
+        _randomizer = () => 0.6;
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, true, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(1.05));
+
+        _randomizer = () => 1.0;
+        RetryHelper
+            .GetRetryDelay(DelayBackoffType.Constant, true, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(1.25));
     }
 
-    [InlineData(true)]
-    [InlineData(false)]
-    [Theory]
-    public void Linear_Ok(bool jitter)
+    [Fact]
+    public void Linear_Ok()
     {
         double state = 0;
 
-        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 0, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 1, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 2, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
 
-        if (jitter)
-        {
-            _randomizer = () => 0.5;
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, false, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(3));
+    }
 
-            RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1.25));
-            RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2.5));
-            RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(3.75));
-        }
-        else
-        {
-            RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
-            RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
-            RetryHelper.GetRetryDelay(DelayBackoffType.Linear, jitter, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(3));
-        }
+    [Fact]
+    public void Linear_Jitter_Ok()
+    {
+        double state = 0;
+
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+
+        _randomizer = () => 0.0;
+        RetryHelper
+            .GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(2.25));
+
+        _randomizer = () => 0.4;
+        RetryHelper
+            .GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(2.85));
+
+        _randomizer = () => 0.5;
+        RetryHelper
+            .GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(3));
+
+        _randomizer = () => 0.6;
+        RetryHelper.GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(3.15));
+
+        _randomizer = () => 1.0;
+        RetryHelper
+            .GetRetryDelay(DelayBackoffType.Linear, true, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer)
+            .Should()
+            .Be(TimeSpan.FromSeconds(3.75));
     }
 
     [Fact]
@@ -86,13 +137,38 @@ public void Exponential_Ok()
     {
         double state = 0;
 
-        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
-        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.Zero, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.Zero, null, ref state, _randomizer).Should().Be(TimeSpan.Zero);
+
+        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
+        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
+        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.FromSeconds(1), null, ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(4));
+    }
+
+    [InlineData(DelayBackoffType.Linear, false)]
+    [InlineData(DelayBackoffType.Exponential, false)]
+    [InlineData(DelayBackoffType.Constant, false)]
+    [InlineData(DelayBackoffType.Linear, true)]
+    [InlineData(DelayBackoffType.Exponential, true)]
+    [InlineData(DelayBackoffType.Constant, true)]
+    [Theory]
+    public void MaxDelay_Ok(DelayBackoffType type, bool jitter)
+    {
+        _randomizer = () => 0.5;
+        var expected = TimeSpan.FromSeconds(1);
+        double state = 0;
+
+        RetryHelper.GetRetryDelay(type, jitter, 2, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(expected);
+    }
+
+    [Fact]
+    public void MaxDelay_DelayLessThanMaxDelay_Respected()
+    {
+        double state = 0;
+        var expected = TimeSpan.FromSeconds(1);
 
-        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 0, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(1));
-        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(2));
-        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 2, TimeSpan.FromSeconds(1), ref state, _randomizer).Should().Be(TimeSpan.FromSeconds(4));
+        RetryHelper.GetRetryDelay(DelayBackoffType.Constant, false, 2, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), ref state, _randomizer).Should().Be(expected);
     }
 
     [Fact]
@@ -100,7 +176,7 @@ public void GetRetryDelay_Overflow_ReturnsMaxTimeSpan()
     {
         double state = 0;
 
-        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1000, TimeSpan.FromDays(1), ref state, _randomizer).Should().Be(TimeSpan.MaxValue);
+        RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, false, 1000, TimeSpan.FromDays(1), null, ref state, _randomizer).Should().Be(TimeSpan.MaxValue);
     }
 
     [InlineData(1)]
@@ -144,7 +220,7 @@ private static IReadOnlyList<TimeSpan> GetExponentialWithJitterBackoff(bool cont
 
         for (int i = 0; i < retryCount; i++)
         {
-            result.Add(RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, true, i, baseDelay, ref state, random));
+            result.Add(RetryHelper.GetRetryDelay(DelayBackoffType.Exponential, true, i, baseDelay, null, ref state, random));
         }
 
         return result;
diff --git a/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs b/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs
index c41a69ca185..d0727bda922 100644
--- a/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs
+++ b/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs
@@ -243,6 +243,30 @@ public async void OnRetry_EnsureCorrectArguments()
         delays[2].Should().Be(TimeSpan.FromSeconds(6));
     }
 
+    [Fact]
+    public async void MaxDelay_EnsureRespected()
+    {
+        var delays = new List<TimeSpan>();
+        _options.OnRetry = args =>
+        {
+            delays.Add(args.RetryDelay);
+            return default;
+        };
+
+        _options.ShouldHandle = args => PredicateResult.True();
+        _options.MaxRetryAttempts = 3;
+        _options.BackoffType = DelayBackoffType.Linear;
+        _options.MaxDelay = TimeSpan.FromMilliseconds(123);
+
+        var sut = CreateSut();
+
+        await ExecuteAndAdvance(sut);
+
+        delays[0].Should().Be(TimeSpan.FromMilliseconds(123));
+        delays[1].Should().Be(TimeSpan.FromMilliseconds(123));
+        delays[2].Should().Be(TimeSpan.FromMilliseconds(123));
+    }
+
     [Fact]
     public async Task OnRetry_EnsureExecutionTime()
     {
diff --git a/test/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs b/test/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs
index c787301799f..f0d3b8f1c7c 100644
--- a/test/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs
+++ b/test/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs
@@ -44,7 +44,8 @@ public void InvalidOptions()
             DelayGenerator = null!,
             OnRetry = null!,
             MaxRetryAttempts = -3,
-            Delay = TimeSpan.MinValue
+            Delay = TimeSpan.MinValue,
+            MaxDelay = TimeSpan.FromSeconds(-10)
         };
 
         options.Invoking(o => ValidationHelper.ValidateObject(new(o, "Invalid Options")))
@@ -56,6 +57,7 @@ Invalid Options
             Validation Errors:
             The field MaxRetryAttempts must be between 1 and 2147483647.
             The field Delay must be between 00:00:00 and 1.00:00:00.
+            The field MaxDelay must be between 00:00:00 and 1.00:00:00.
             The ShouldHandle field is required.
             """);
     }