diff --git a/Directory.Packages.props b/Directory.Packages.props index 8ffe9f01af3..a6cd86686b2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/src/Polly.Core/Retry/RetryHelper.cs b/src/Polly.Core/Retry/RetryHelper.cs index 9736a83375c..d5b66a51210 100644 --- a/src/Polly.Core/Retry/RetryHelper.cs +++ b/src/Polly.Core/Retry/RetryHelper.cs @@ -45,37 +45,16 @@ public static TimeSpan GetRetryDelay( } } - private static TimeSpan GetRetryDelayCore(DelayBackoffType type, bool jitter, int attempt, TimeSpan baseDelay, ref double state, Func randomizer) +#pragma warning disable IDE0047 // Remove unnecessary parentheses which offer less mental gymnastics + internal static TimeSpan ApplyJitter(TimeSpan delay, Func randomizer) { - if (baseDelay == TimeSpan.Zero) - { - return baseDelay; - } - - if (jitter) - { - return type switch - { - DelayBackoffType.Constant => ApplyJitter(baseDelay, randomizer), - DelayBackoffType.Linear => ApplyJitter(TimeSpan.FromMilliseconds((attempt + 1) * baseDelay.TotalMilliseconds), randomizer), - DelayBackoffType.Exponential => DecorrelatedJitterBackoffV2(attempt, baseDelay, ref state, randomizer), - _ => throw new ArgumentOutOfRangeException(nameof(type), type, "The retry backoff type is not supported.") - }; - } + var offset = (delay.TotalMilliseconds * JitterFactor) / 2; + var randomDelay = (delay.TotalMilliseconds * JitterFactor * randomizer()) - offset; + var newDelay = delay.TotalMilliseconds + randomDelay; - return type switch - { - DelayBackoffType.Constant => baseDelay, -#if !NETCOREAPP - DelayBackoffType.Linear => TimeSpan.FromMilliseconds((attempt + 1) * baseDelay.TotalMilliseconds), - DelayBackoffType.Exponential => TimeSpan.FromMilliseconds(Math.Pow(ExponentialFactor, attempt) * baseDelay.TotalMilliseconds), -#else - DelayBackoffType.Linear => (attempt + 1) * baseDelay, - DelayBackoffType.Exponential => Math.Pow(ExponentialFactor, attempt) * baseDelay, -#endif - _ => throw new ArgumentOutOfRangeException(nameof(type), type, "The retry backoff type is not supported.") - }; + return TimeSpan.FromMilliseconds(newDelay); } +#pragma warning disable IDE0047 // Remove unnecessary parentheses which offer less mental gymnastics /// /// Generates sleep durations in an exponentially backing-off, jittered manner, making sure to mitigate any correlations. @@ -93,7 +72,7 @@ private static TimeSpan GetRetryDelayCore(DelayBackoffType type, bool jitter, in /// /// This code was adopted from https://github.com/Polly-Contrib/Polly.Contrib.WaitAndRetry/blob/master/src/Polly.Contrib.WaitAndRetry/Backoff.DecorrelatedJitterV2.cs. /// - private static TimeSpan DecorrelatedJitterBackoffV2(int attempt, TimeSpan baseDelay, ref double prev, Func randomizer) + internal static TimeSpan DecorrelatedJitterBackoffV2(int attempt, TimeSpan baseDelay, ref double prev, Func randomizer) { // The original author/credit for this jitter formula is @george-polevoy . // Jitter formula used with permission as described at https://github.com/App-vNext/Polly/issues/530#issuecomment-526555979 @@ -131,14 +110,35 @@ private static TimeSpan DecorrelatedJitterBackoffV2(int attempt, TimeSpan baseDe return TimeSpan.FromTicks(ticks); } -#pragma warning disable IDE0047 // Remove unnecessary parentheses which offer less mental gymnastics - private static TimeSpan ApplyJitter(TimeSpan delay, Func randomizer) + private static TimeSpan GetRetryDelayCore(DelayBackoffType type, bool jitter, int attempt, TimeSpan baseDelay, ref double state, Func randomizer) { - var offset = (delay.TotalMilliseconds * JitterFactor) / 2; - var randomDelay = (delay.TotalMilliseconds * JitterFactor * randomizer()) - offset; - var newDelay = delay.TotalMilliseconds + randomDelay; + if (baseDelay == TimeSpan.Zero) + { + return baseDelay; + } - return TimeSpan.FromMilliseconds(newDelay); + if (jitter) + { + return type switch + { + DelayBackoffType.Constant => ApplyJitter(baseDelay, randomizer), + DelayBackoffType.Linear => ApplyJitter(TimeSpan.FromMilliseconds((attempt + 1) * baseDelay.TotalMilliseconds), randomizer), + DelayBackoffType.Exponential => DecorrelatedJitterBackoffV2(attempt, baseDelay, ref state, randomizer), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "The retry backoff type is not supported.") + }; + } + + return type switch + { + DelayBackoffType.Constant => baseDelay, +#if !NETCOREAPP + DelayBackoffType.Linear => TimeSpan.FromMilliseconds((attempt + 1) * baseDelay.TotalMilliseconds), + DelayBackoffType.Exponential => TimeSpan.FromMilliseconds(Math.Pow(ExponentialFactor, attempt) * baseDelay.TotalMilliseconds), +#else + DelayBackoffType.Linear => (attempt + 1) * baseDelay, + DelayBackoffType.Exponential => Math.Pow(ExponentialFactor, attempt) * baseDelay, +#endif + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "The retry backoff type is not supported.") + }; } -#pragma warning restore IDE0047 // Remove unnecessary parentheses which offer less mental gymnastics } diff --git a/test/Polly.Core.Tests/Polly.Core.Tests.csproj b/test/Polly.Core.Tests/Polly.Core.Tests.csproj index fed35451ec5..d8c1c4bd18c 100644 --- a/test/Polly.Core.Tests/Polly.Core.Tests.csproj +++ b/test/Polly.Core.Tests/Polly.Core.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/test/Polly.Core.Tests/Retry/RetryHelperTests.cs b/test/Polly.Core.Tests/Retry/RetryHelperTests.cs index a2cd64f414d..c2529eeb8a7 100644 --- a/test/Polly.Core.Tests/Retry/RetryHelperTests.cs +++ b/test/Polly.Core.Tests/Retry/RetryHelperTests.cs @@ -1,3 +1,5 @@ +using FsCheck; +using FsCheck.Fluent; using Polly.Contrib.WaitAndRetry; using Polly.Retry; using Polly.Utils; @@ -261,7 +263,38 @@ public void ExponentialWithJitter_EnsureRandomness() delays1.ShouldAllBe(delay => delay > TimeSpan.Zero); } - private static IReadOnlyList GetExponentialWithJitterBackoff(bool contrib, TimeSpan baseDelay, int retryCount, Func? randomizer = null) +#if !NETFRAMEWORK + [FsCheck.Xunit.Property(Arbitrary = [typeof(Arbitraries)], MaxTest = 10_000)] + public void ApplyJitter_Meets_Specification(TimeSpan value) + { + var delta = value / 2; + var floor = value - delta; + var ceiling = value + delta; + + var actual = RetryHelper.ApplyJitter(value, RandomUtil.NextDouble); + + actual.ShouldBeGreaterThan(floor); + actual.ShouldBeLessThanOrEqualTo(ceiling); + } + + [FsCheck.Xunit.Property(Arbitrary = [typeof(Arbitraries)], MaxTest = 10_000)] + public void DecorrelatedJitterBackoffV2_Meets_Specification(TimeSpan value, int attempt) + { + var rawCeiling = value.Ticks * Math.Pow(2, attempt) * 4; + var clamped = (long)Math.Clamp(rawCeiling, value.Ticks, TimeSpan.MaxValue.Ticks); + + var floor = value; + var ceiling = TimeSpan.FromTicks(clamped - 1); + + var _ = default(double); + var actual = RetryHelper.DecorrelatedJitterBackoffV2(attempt, value, ref _, RandomUtil.NextDouble); + + actual.ShouldBeGreaterThan(floor); + actual.ShouldBeLessThanOrEqualTo(ceiling); + } +#endif + + private static List GetExponentialWithJitterBackoff(bool contrib, TimeSpan baseDelay, int retryCount, Func? randomizer = null) { if (contrib) { @@ -279,4 +312,26 @@ private static IReadOnlyList GetExponentialWithJitterBackoff(bool cont return result; } + + public static class Arbitraries + { + public static Arbitrary PositiveInteger() + { + var minimum = 1; + var maximum = 2056; + var generator = Gen.Choose(minimum, maximum); + + return Arb.From(generator); + } + + public static Arbitrary PositiveTimeSpan() + { + var minimum = (int)TimeSpan.FromMilliseconds(1).Ticks; + var maximum = (int)TimeSpan.FromMinutes(60).Ticks; + var generator = Gen.Choose(minimum, maximum) + .Select((p) => TimeSpan.FromTicks(p)); + + return Arb.From(generator); + } + } }