From 2627c4518692020274c5b4a9e17dff7624fa1f3d Mon Sep 17 00:00:00 2001 From: martintmk <103487740+martintmk@users.noreply.github.com> Date: Tue, 11 Apr 2023 09:28:14 +0200 Subject: [PATCH] Introduce RetryResilienceStrategy (#1101) --- .../Retry/RetryConstantsTests.cs | 18 ++ .../Retry/RetryDelayArgumentsTests.cs | 3 +- .../Retry/RetryDelayGeneratorTests.cs | 4 +- .../Retry/RetryHelperTests.cs | 61 ++++++ ...esilienceStrategyBuilderExtensionsTests.cs | 70 +++++++ .../Retry/RetryResilienceStrategyTests.cs | 176 ++++++++++++++++++ .../Retry/RetryStrategyOptionsTests.cs | 33 ++++ .../Strategy/OnRetryArgumentsTests.cs | 3 +- .../Strategy/SimpleEventTests.cs | 2 +- .../Telemetry/NullResilienceTelemetryTests.cs | 4 +- .../Utils/FakeTimeProvider.cs | 6 + .../Utils/TimeSpanAttributeTests.cs | 46 +++++ .../Utils/ValidationContextExtensions.cs | 33 ++++ src/Polly.Core/Retry/OnRetryArguments.cs | 8 +- src/Polly.Core/Retry/RetryBackoffType.cs | 41 ++++ src/Polly.Core/Retry/RetryConstants.cs | 18 ++ src/Polly.Core/Retry/RetryDelayArguments.cs | 8 +- src/Polly.Core/Retry/RetryDelayGenerator.cs | 2 +- src/Polly.Core/Retry/RetryHelper.cs | 31 +++ .../Retry/RetryResilienceStrategy.cs | 104 +++++++++++ ...etryResilienceStrategyBuilderExtensions.cs | 103 ++++++++++ src/Polly.Core/Retry/RetryStrategyOptions.cs | 42 ++++- src/Polly.Core/Strategy/SimpleEvent.cs | 4 +- .../Telemetry/NullResilienceTelemetry.cs | 11 +- .../Telemetry/ResilienceTelemetry.cs | 14 +- src/Polly.Core/Timeout/OnTimeoutEvent.cs | 2 + src/Polly.Core/Utils/TimeSpanAttribute.cs | 52 ++++++ .../Utils/ValidationContextExtensions.cs | 18 ++ 28 files changed, 886 insertions(+), 31 deletions(-) create mode 100644 src/Polly.Core.Tests/Retry/RetryConstantsTests.cs create mode 100644 src/Polly.Core.Tests/Retry/RetryHelperTests.cs create mode 100644 src/Polly.Core.Tests/Retry/RetryResilienceStrategyBuilderExtensionsTests.cs create mode 100644 src/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs create mode 100644 src/Polly.Core.Tests/Utils/TimeSpanAttributeTests.cs create mode 100644 src/Polly.Core.Tests/Utils/ValidationContextExtensions.cs create mode 100644 src/Polly.Core/Retry/RetryBackoffType.cs create mode 100644 src/Polly.Core/Retry/RetryConstants.cs create mode 100644 src/Polly.Core/Retry/RetryHelper.cs create mode 100644 src/Polly.Core/Retry/RetryResilienceStrategy.cs create mode 100644 src/Polly.Core/Retry/RetryResilienceStrategyBuilderExtensions.cs create mode 100644 src/Polly.Core/Utils/TimeSpanAttribute.cs create mode 100644 src/Polly.Core/Utils/ValidationContextExtensions.cs diff --git a/src/Polly.Core.Tests/Retry/RetryConstantsTests.cs b/src/Polly.Core.Tests/Retry/RetryConstantsTests.cs new file mode 100644 index 00000000000..73f402c4f66 --- /dev/null +++ b/src/Polly.Core.Tests/Retry/RetryConstantsTests.cs @@ -0,0 +1,18 @@ +using Polly.Retry; + +namespace Polly.Core.Tests.Retry; + +public class RetryConstantsTests +{ + [Fact] + public void EnsureDefaults() + { + RetryConstants.DefaultBackoffType.Should().Be(RetryBackoffType.Exponential); + RetryConstants.DefaultBaseDelay.Should().Be(TimeSpan.FromSeconds(2)); + RetryConstants.DefaultRetryCount.Should().Be(3); + RetryConstants.MaxRetryCount.Should().Be(100); + RetryConstants.InfiniteRetryCount.Should().Be(-1); + RetryConstants.StrategyType.Should().Be("Retry"); + RetryConstants.OnRetryEvent.Should().Be("OnRetry"); + } +} diff --git a/src/Polly.Core.Tests/Retry/RetryDelayArgumentsTests.cs b/src/Polly.Core.Tests/Retry/RetryDelayArgumentsTests.cs index 927d9a4be35..84dc24bf6f3 100644 --- a/src/Polly.Core.Tests/Retry/RetryDelayArgumentsTests.cs +++ b/src/Polly.Core.Tests/Retry/RetryDelayArgumentsTests.cs @@ -7,9 +7,10 @@ public class RetryDelayArgumentsTests [Fact] public void Ctor_Ok() { - var args = new RetryDelayArguments(ResilienceContext.Get(), 2); + var args = new RetryDelayArguments(ResilienceContext.Get(), 2, TimeSpan.FromSeconds(2)); args.Context.Should().NotBeNull(); args.Attempt.Should().Be(2); + args.DelayHint.Should().Be(TimeSpan.FromSeconds(2)); } } diff --git a/src/Polly.Core.Tests/Retry/RetryDelayGeneratorTests.cs b/src/Polly.Core.Tests/Retry/RetryDelayGeneratorTests.cs index 7e33fb1ab46..803136692f4 100644 --- a/src/Polly.Core.Tests/Retry/RetryDelayGeneratorTests.cs +++ b/src/Polly.Core.Tests/Retry/RetryDelayGeneratorTests.cs @@ -14,7 +14,7 @@ public async Task NoGeneratorRegisteredForType_EnsureDefaultValue() var result = await new RetryDelayGenerator() .SetGenerator((_, _) => TimeSpan.Zero) .CreateHandler()! - .Generate(new Outcome(true), new RetryDelayArguments(ResilienceContext.Get(), 0)); + .Generate(new Outcome(true), new RetryDelayArguments(ResilienceContext.Get(), 0, TimeSpan.FromSeconds(2))); result.Should().Be(TimeSpan.MinValue); } @@ -28,7 +28,7 @@ public async Task GeneratorRegistered_EnsureValueNotIgnored(TimeSpan delay) var result = await new RetryDelayGenerator() .SetGenerator((_, _) => delay) .CreateHandler()! - .Generate(new Outcome(0), new RetryDelayArguments(ResilienceContext.Get(), 0)); + .Generate(new Outcome(0), new RetryDelayArguments(ResilienceContext.Get(), 0, TimeSpan.FromSeconds(2))); result.Should().Be(delay); } diff --git a/src/Polly.Core.Tests/Retry/RetryHelperTests.cs b/src/Polly.Core.Tests/Retry/RetryHelperTests.cs new file mode 100644 index 00000000000..ff2896da8e5 --- /dev/null +++ b/src/Polly.Core.Tests/Retry/RetryHelperTests.cs @@ -0,0 +1,61 @@ +using System; +using Polly.Retry; + +namespace Polly.Core.Tests.Retry; + +public class RetryHelperTests +{ + [Fact] + public void IsValidDelay_Ok() + { + RetryHelper.IsValidDelay(TimeSpan.Zero).Should().BeTrue(); + RetryHelper.IsValidDelay(TimeSpan.FromSeconds(1)).Should().BeTrue(); + RetryHelper.IsValidDelay(TimeSpan.MaxValue).Should().BeTrue(); + RetryHelper.IsValidDelay(TimeSpan.MinValue).Should().BeFalse(); + RetryHelper.IsValidDelay(TimeSpan.FromMilliseconds(-1)).Should().BeFalse(); + } + + [Fact] + public void UnsupportedRetryBackoffType_Throws() + { + RetryBackoffType type = (RetryBackoffType)99; + + Assert.Throws(() => RetryHelper.GetRetryDelay(type, 0, TimeSpan.FromSeconds(1))); + } + + [Fact] + public void Constant_Ok() + { + RetryHelper.GetRetryDelay(RetryBackoffType.Constant, 0, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + RetryHelper.GetRetryDelay(RetryBackoffType.Constant, 1, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + RetryHelper.GetRetryDelay(RetryBackoffType.Constant, 2, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + + RetryHelper.GetRetryDelay(RetryBackoffType.Constant, 0, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(1)); + RetryHelper.GetRetryDelay(RetryBackoffType.Constant, 1, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(1)); + RetryHelper.GetRetryDelay(RetryBackoffType.Constant, 2, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Linear_Ok() + { + RetryHelper.GetRetryDelay(RetryBackoffType.Linear, 0, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + RetryHelper.GetRetryDelay(RetryBackoffType.Linear, 1, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + RetryHelper.GetRetryDelay(RetryBackoffType.Linear, 2, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + + RetryHelper.GetRetryDelay(RetryBackoffType.Linear, 0, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(1)); + RetryHelper.GetRetryDelay(RetryBackoffType.Linear, 1, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(2)); + RetryHelper.GetRetryDelay(RetryBackoffType.Linear, 2, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(3)); + } + + [Fact] + public void Exponential_Ok() + { + RetryHelper.GetRetryDelay(RetryBackoffType.Exponential, 0, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + RetryHelper.GetRetryDelay(RetryBackoffType.Exponential, 1, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + RetryHelper.GetRetryDelay(RetryBackoffType.Exponential, 2, TimeSpan.Zero).Should().Be(TimeSpan.Zero); + + RetryHelper.GetRetryDelay(RetryBackoffType.Exponential, 0, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(1)); + RetryHelper.GetRetryDelay(RetryBackoffType.Exponential, 1, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(2)); + RetryHelper.GetRetryDelay(RetryBackoffType.Exponential, 2, TimeSpan.FromSeconds(1)).Should().Be(TimeSpan.FromSeconds(4)); + } +} diff --git a/src/Polly.Core.Tests/Retry/RetryResilienceStrategyBuilderExtensionsTests.cs b/src/Polly.Core.Tests/Retry/RetryResilienceStrategyBuilderExtensionsTests.cs new file mode 100644 index 00000000000..0dcf3f0cfe6 --- /dev/null +++ b/src/Polly.Core.Tests/Retry/RetryResilienceStrategyBuilderExtensionsTests.cs @@ -0,0 +1,70 @@ +using System.ComponentModel.DataAnnotations; +using Polly.Builder; +using Polly.Retry; +using Xunit; + +namespace Polly.Core.Tests.Retry; + +public class RetryResilienceStrategyBuilderExtensionsTests +{ + public static readonly TheoryData> OverloadsData = new() + { + builder => + { + builder.AddRetry(retry=>retry.Result(10)); + AssertStrategy(builder, RetryBackoffType.Exponential, 3, TimeSpan.FromSeconds(2)); + }, + builder => + { + builder.AddRetry(retry=>retry.Result(10), RetryBackoffType.Linear); + AssertStrategy(builder, RetryBackoffType.Linear, 3, TimeSpan.FromSeconds(2)); + }, + builder => + { + builder.AddRetry(retry=>retry.Result(10), RetryBackoffType.Linear, 2, TimeSpan.FromSeconds(1)); + AssertStrategy(builder, RetryBackoffType.Linear, 2, TimeSpan.FromSeconds(1)); + }, + }; + + [MemberData(nameof(OverloadsData))] + [Theory] + public void AddRetry_Overloads_Ok(Action configure) + { + var builder = new ResilienceStrategyBuilder(); + var options = new RetryStrategyOptions(); + + builder.Invoking(b => configure(b)).Should().NotThrow(); + } + + [Fact] + public void AddRetry_DefaultOptions_Ok() + { + var builder = new ResilienceStrategyBuilder(); + var options = new RetryStrategyOptions(); + + builder.AddRetry(options); + + AssertStrategy(builder, options.BackoffType, options.RetryCount, options.BaseDelay); + } + + private static void AssertStrategy(ResilienceStrategyBuilder builder, RetryBackoffType type, int retries, TimeSpan delay) + { + var strategy = (RetryResilienceStrategy)builder.Build(); + + strategy.BackoffType.Should().Be(type); + strategy.RetryCount.Should().Be(retries); + strategy.BaseDelay.Should().Be(delay); + } + + [Fact] + public void AddRetry_InvalidOptions_Throws() + { + var builder = new ResilienceStrategyBuilder(); + + builder + .Invoking(b => b.AddRetry(new RetryStrategyOptions { ShouldRetry = null! })) + .Should() + .Throw() + .WithMessage("The retry strategy options are invalid.*"); + } +} diff --git a/src/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs b/src/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs new file mode 100644 index 00000000000..60e78120a24 --- /dev/null +++ b/src/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs @@ -0,0 +1,176 @@ +using Moq; +using Polly.Retry; +using Polly.Telemetry; + +namespace Polly.Core.Tests.Retry; + +public class RetryResilienceStrategyTests +{ + private readonly RetryStrategyOptions _options = new(); + private readonly FakeTimeProvider _timeProvider = new(); + private readonly Mock _telemetry = new(); + + [Fact] + public void ShouldRetryEmpty_Skipped() + { + bool called = false; + _options.OnRetry.Add(() => called = true); + SetupNoDelay(); + var sut = CreateSut(); + + sut.Execute(_ => 0, default); + + called.Should().BeFalse(); + } + + [Fact] + public void Retry_RetryCount_Respected() + { + int calls = 0; + _options.OnRetry.Add(() => calls++); + _options.ShouldRetry.Result(0); + _options.RetryCount = 12; + SetupNoDelay(); + var sut = CreateSut(); + + sut.Execute(_ => 0, default); + + calls.Should().Be(12); + } + + [Fact] + public void RetryException_RetryCount_Respected() + { + int calls = 0; + _options.OnRetry.Add((args, _) => + { + args.Exception.Should().BeOfType(); + calls++; + }); + _options.ShouldRetry.Exception(); + _options.RetryCount = 3; + SetupNoDelay(); + var sut = CreateSut(); + + Assert.Throws(() => sut.Execute(_ => throw new InvalidOperationException(), default)); + + calls.Should().Be(3); + } + + [Fact] + public void Retry_Infinite_Respected() + { + int calls = 0; + _options.BackoffType = RetryBackoffType.Constant; + _options.OnRetry.Add((_, args) => + { + if (args.Attempt > RetryConstants.MaxRetryCount) + { + throw new InvalidOperationException(); + } + + calls++; + }); + _options.ShouldRetry.Result(0); + _options.RetryCount = RetryStrategyOptions.InfiniteRetryCount; + SetupNoDelay(); + var sut = CreateSut(); + + Assert.Throws(() => sut.Execute(_ => 0, default)); + + calls.Should().Be(RetryConstants.MaxRetryCount + 1); + } + + [Fact] + public void RetryDelayGenerator_Respected() + { + int calls = 0; + _options.OnRetry.Add(() => calls++); + _options.ShouldRetry.Result(0); + _options.RetryCount = 3; + _options.BackoffType = RetryBackoffType.Constant; + _options.RetryDelayGenerator.SetGenerator((_, _) => TimeSpan.FromMilliseconds(123)); + _timeProvider.SetupDelay(TimeSpan.FromMilliseconds(123)); + + var sut = CreateSut(); + + sut.Execute(_ => 0, default); + + _timeProvider.Verify(v => v.Delay(TimeSpan.FromMilliseconds(123), default), Times.Exactly(3)); + } + + [Fact] + public void OnRetry_EnsureCorrectArguments() + { + var attempts = new List(); + var delays = new List(); + _options.OnRetry.Add((outcome, args) => + { + attempts.Add(args.Attempt); + delays.Add(args.RetryDelay); + + outcome.Exception.Should().BeNull(); + outcome.Result.Should().Be(0); + }); + + _options.ShouldRetry.Result(0); + _options.RetryCount = 3; + _options.BackoffType = RetryBackoffType.Linear; + _timeProvider.SetupAnyDelay(); + + var sut = CreateSut(); + + sut.Execute(_ => 0, default); + + attempts.Should().HaveCount(3); + attempts[0].Should().Be(0); + attempts[1].Should().Be(1); + attempts[2].Should().Be(2); + + delays[0].Should().Be(TimeSpan.FromSeconds(2)); + delays[1].Should().Be(TimeSpan.FromSeconds(4)); + delays[2].Should().Be(TimeSpan.FromSeconds(6)); + } + + [Fact] + public void RetryDelayGenerator_EnsureCorrectArguments() + { + var attempts = new List(); + var hints = new List(); + _options.RetryDelayGenerator.SetGenerator((outcome, args) => + { + attempts.Add(args.Attempt); + hints.Add(args.DelayHint); + + outcome.Exception.Should().BeNull(); + outcome.Result.Should().Be(0); + + return TimeSpan.Zero; + }); + + _options.ShouldRetry.Result(0); + _options.RetryCount = 3; + _options.BackoffType = RetryBackoffType.Linear; + _timeProvider.SetupAnyDelay(); + + var sut = CreateSut(); + + sut.Execute(_ => 0, default); + + attempts.Should().HaveCount(3); + attempts[0].Should().Be(0); + attempts[1].Should().Be(1); + attempts[2].Should().Be(2); + + hints[0].Should().Be(TimeSpan.FromSeconds(2)); + hints[1].Should().Be(TimeSpan.FromSeconds(4)); + hints[2].Should().Be(TimeSpan.FromSeconds(6)); + } + + private void SetupNoDelay() => _options.RetryDelayGenerator.SetGenerator((_, _) => TimeSpan.Zero); + + private RetryResilienceStrategy CreateSut() + { + return new RetryResilienceStrategy(_options, _timeProvider.Object, _telemetry.Object); + } +} diff --git a/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs b/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs index 7d387798262..2b79e9a3e07 100644 --- a/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs +++ b/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs @@ -1,4 +1,6 @@ +using System.ComponentModel.DataAnnotations; using Polly.Retry; +using Polly.Utils; namespace Polly.Core.Tests.Retry; @@ -17,5 +19,36 @@ public void Ctor_Ok() options.OnRetry.Should().NotBeNull(); options.OnRetry.IsEmpty.Should().BeTrue(); + + options.RetryCount.Should().Be(3); + options.BackoffType.Should().Be(RetryBackoffType.Exponential); + options.BaseDelay.Should().Be(TimeSpan.FromSeconds(2)); + } + + [Fact] + public void InvalidOptions() + { + var options = new RetryStrategyOptions + { + ShouldRetry = null!, + RetryDelayGenerator = null!, + OnRetry = null!, + RetryCount = -3, + BaseDelay = TimeSpan.MinValue + }; + + options.Invoking(o => ValidationHelper.ValidateObject(o, "Invalid Options")) + .Should() + .Throw() + .WithMessage(""" + Invalid Options + + Validation Errors: + The field RetryCount must be between -1 and 100. + The field BaseDelay must be >= to 00:00:00. + The ShouldRetry field is required. + The RetryDelayGenerator field is required. + The OnRetry field is required. + """); } } diff --git a/src/Polly.Core.Tests/Strategy/OnRetryArgumentsTests.cs b/src/Polly.Core.Tests/Strategy/OnRetryArgumentsTests.cs index 8378b293162..aa57f6a9a0c 100644 --- a/src/Polly.Core.Tests/Strategy/OnRetryArgumentsTests.cs +++ b/src/Polly.Core.Tests/Strategy/OnRetryArgumentsTests.cs @@ -7,9 +7,10 @@ public class OnRetryArgumentsTests [Fact] public void Ctor_Ok() { - var args = new OnRetryArguments(ResilienceContext.Get(), 2); + var args = new OnRetryArguments(ResilienceContext.Get(), 2, TimeSpan.FromSeconds(3)); args.Context.Should().NotBeNull(); args.Attempt.Should().Be(2); + args.RetryDelay.Should().Be(TimeSpan.FromSeconds(3)); } } diff --git a/src/Polly.Core.Tests/Strategy/SimpleEventTests.cs b/src/Polly.Core.Tests/Strategy/SimpleEventTests.cs index 45440b7fdda..8bbafa0cbcc 100644 --- a/src/Polly.Core.Tests/Strategy/SimpleEventTests.cs +++ b/src/Polly.Core.Tests/Strategy/SimpleEventTests.cs @@ -1,4 +1,4 @@ -using Polly.Timeout; +using Polly.Strategy; namespace Polly.Core.Tests.Timeout; diff --git a/src/Polly.Core.Tests/Telemetry/NullResilienceTelemetryTests.cs b/src/Polly.Core.Tests/Telemetry/NullResilienceTelemetryTests.cs index 9e23618e963..f3d4b4b045e 100644 --- a/src/Polly.Core.Tests/Telemetry/NullResilienceTelemetryTests.cs +++ b/src/Polly.Core.Tests/Telemetry/NullResilienceTelemetryTests.cs @@ -1,3 +1,4 @@ +using Polly.Strategy; using Polly.Telemetry; namespace Polly.Core.Tests.Telemetry; @@ -17,8 +18,7 @@ public void Report_ShouldNotThrow() .Invoking(v => { NullResilienceTelemetry.Instance.Report("dummy", ResilienceContext.Get()); - NullResilienceTelemetry.Instance.Report("dummy", 10, ResilienceContext.Get()); - NullResilienceTelemetry.Instance.ReportException("dummy", new InvalidOperationException(), ResilienceContext.Get()); + NullResilienceTelemetry.Instance.Report("dummy", new Outcome(10), ResilienceContext.Get()); }) .Should() .NotThrow(); diff --git a/src/Polly.Core.Tests/Utils/FakeTimeProvider.cs b/src/Polly.Core.Tests/Utils/FakeTimeProvider.cs index 1ce87e966ee..3a0a2c55b03 100644 --- a/src/Polly.Core.Tests/Utils/FakeTimeProvider.cs +++ b/src/Polly.Core.Tests/Utils/FakeTimeProvider.cs @@ -15,6 +15,12 @@ public FakeTimeProvider() { } + public FakeTimeProvider SetupAnyDelay(CancellationToken cancellationToken = default) + { + Setup(x => x.Delay(It.IsAny(), cancellationToken)).Returns(Task.CompletedTask); + return this; + } + public FakeTimeProvider SetupDelay(TimeSpan delay, CancellationToken cancellationToken = default) { Setup(x => x.Delay(delay, cancellationToken)).Returns(Task.CompletedTask); diff --git a/src/Polly.Core.Tests/Utils/TimeSpanAttributeTests.cs b/src/Polly.Core.Tests/Utils/TimeSpanAttributeTests.cs new file mode 100644 index 00000000000..02430df73ef --- /dev/null +++ b/src/Polly.Core.Tests/Utils/TimeSpanAttributeTests.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Polly.Core.Tests.Utils; + +public class TimeSpanAttributeTests +{ + [Fact] + public void InvalidValue_Skipped() + { + var attr = new TimeSpanAttribute("00:00:01"); + + attr.GetValidationResult(new object(), new ValidationContext(TimeSpan.FromSeconds(1)) { DisplayName = "A" }) + .Should().Be(ValidationResult.Success); + } + + [Fact] + public void InvalidMinValue_Validated() + { + var attr = new TimeSpanAttribute("00:00:01"); + + attr.GetValidationResult(TimeSpan.FromSeconds(0), new ValidationContext(TimeSpan.FromSeconds(0)) { DisplayName = "A" })! + .ErrorMessage.Should().Be("The field A must be >= to 00:00:01."); + + attr.GetValidationResult(TimeSpan.FromSeconds(1), new ValidationContext(TimeSpan.FromSeconds(1)) { DisplayName = "A" }) + .Should().Be(ValidationResult.Success); + } + + [Fact] + public void InvalidMaxValue_Validated() + { + var attr = new TimeSpanAttribute("00:00:01", "00:00:03"); + + attr + .GetValidationResult(TimeSpan.FromSeconds(0), new ValidationContext(TimeSpan.FromSeconds(0)) { DisplayName = "A" })! + .ErrorMessage.Should().Be("The field A must be >= to 00:00:01."); + attr.GetValidationResult(TimeSpan.FromSeconds(1), new ValidationContext(TimeSpan.FromSeconds(1)) { DisplayName = "A" }) + .Should().Be(ValidationResult.Success); + + attr + .GetValidationResult(TimeSpan.FromSeconds(4), new ValidationContext(TimeSpan.FromSeconds(4)) { DisplayName = "A" })! + .ErrorMessage.Should().Be("The field A must be <= to 00:00:03."); + attr.GetValidationResult(TimeSpan.FromSeconds(3), new ValidationContext(TimeSpan.FromSeconds(3)) { DisplayName = "A" }) + .Should().Be(ValidationResult.Success); + } +} diff --git a/src/Polly.Core.Tests/Utils/ValidationContextExtensions.cs b/src/Polly.Core.Tests/Utils/ValidationContextExtensions.cs new file mode 100644 index 00000000000..962da7c14c0 --- /dev/null +++ b/src/Polly.Core.Tests/Utils/ValidationContextExtensions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Polly.Core.Tests.Utils; + +public class ValidationContextExtensions +{ + [Fact] + public void GetMemberName_Ok() + { + ValidationContext? context = null; + context.GetMemberName().Should().BeNull(); + + context = new ValidationContext(new object()); + context.GetMemberName().Should().BeNull(); + + context = new ValidationContext(new object()) { MemberName = "X" }; + context.GetMemberName().Should().NotBeNull(); + context.GetMemberName()![0].Should().Be("X"); + } + + [Fact] + public void GetDisplayName_Ok() + { + ValidationContext? context = null; + context.GetDisplayName().Should().Be(""); + + context = new ValidationContext(new object()); + context.GetDisplayName().Should().Be("Object"); + + context = new ValidationContext(new object()) { DisplayName = "X" }; + context.GetDisplayName().Should().Be("X"); + } +} diff --git a/src/Polly.Core/Retry/OnRetryArguments.cs b/src/Polly.Core/Retry/OnRetryArguments.cs index af04edd63d6..717db164c91 100644 --- a/src/Polly.Core/Retry/OnRetryArguments.cs +++ b/src/Polly.Core/Retry/OnRetryArguments.cs @@ -9,10 +9,11 @@ namespace Polly.Retry; /// public readonly struct OnRetryArguments : IResilienceArguments { - internal OnRetryArguments(ResilienceContext context, int attempt) + internal OnRetryArguments(ResilienceContext context, int attempt, TimeSpan retryDelay) { Attempt = attempt; Context = context; + RetryDelay = retryDelay; } /// @@ -23,6 +24,11 @@ internal OnRetryArguments(ResilienceContext context, int attempt) /// public int Attempt { get; } + /// + /// Gets the delay before the next retry. + /// + public TimeSpan RetryDelay { get; } + /// public ResilienceContext Context { get; } } diff --git a/src/Polly.Core/Retry/RetryBackoffType.cs b/src/Polly.Core/Retry/RetryBackoffType.cs new file mode 100644 index 00000000000..9e036121966 --- /dev/null +++ b/src/Polly.Core/Retry/RetryBackoffType.cs @@ -0,0 +1,41 @@ +namespace Polly.Retry; + +/// +/// The backoff type used by the retry strategy. +/// +public enum RetryBackoffType +{ + /// + /// The constant retry type. + /// + /// + /// 200ms, 200ms, 200ms, etc. + /// + /// + /// Ensures a constant wait duration before each retry attempt. + /// For concurrent database access with a possibility of conflicting updates, + /// retrying the failures in a constant manner allows for consistent transient failure mitigation. + /// + Constant, + + /// + /// The linear retry type. + /// + /// + /// 100ms, 200ms, 300ms, 400ms, etc. + /// + /// + /// Generates sleep durations in an linear manner. + /// In the case randomization introduced by the jitter and exponential growth are not appropriate, + /// the linear growth allows for more precise control over the delay intervals. + /// + Linear, + + /// + /// The exponential delay type with the power of 2. + /// + /// + /// 200ms, 400ms, 800ms. + /// + Exponential, +} diff --git a/src/Polly.Core/Retry/RetryConstants.cs b/src/Polly.Core/Retry/RetryConstants.cs new file mode 100644 index 00000000000..d0302ea7f32 --- /dev/null +++ b/src/Polly.Core/Retry/RetryConstants.cs @@ -0,0 +1,18 @@ +namespace Polly.Retry; + +internal static class RetryConstants +{ + public const string StrategyType = "Retry"; + + public const string OnRetryEvent = "OnRetry"; + + public const RetryBackoffType DefaultBackoffType = RetryBackoffType.Exponential; + + public const int DefaultRetryCount = 3; + + public const int MaxRetryCount = 100; + + public const int InfiniteRetryCount = -1; + + public static readonly TimeSpan DefaultBaseDelay = TimeSpan.FromSeconds(2); +} diff --git a/src/Polly.Core/Retry/RetryDelayArguments.cs b/src/Polly.Core/Retry/RetryDelayArguments.cs index 3fa9c0d050e..2ce59f96241 100644 --- a/src/Polly.Core/Retry/RetryDelayArguments.cs +++ b/src/Polly.Core/Retry/RetryDelayArguments.cs @@ -9,9 +9,10 @@ namespace Polly.Retry; /// public readonly struct RetryDelayArguments : IResilienceArguments { - internal RetryDelayArguments(ResilienceContext context, int attempt) + internal RetryDelayArguments(ResilienceContext context, int attempt, TimeSpan delayHint) { Attempt = attempt; + DelayHint = delayHint; Context = context; } @@ -23,6 +24,11 @@ internal RetryDelayArguments(ResilienceContext context, int attempt) /// public int Attempt { get; } + /// + /// Gets the delay suggested by retry strategy. + /// + public TimeSpan DelayHint { get; } + /// public ResilienceContext Context { get; } } diff --git a/src/Polly.Core/Retry/RetryDelayGenerator.cs b/src/Polly.Core/Retry/RetryDelayGenerator.cs index deb12f6061d..668d8f3f468 100644 --- a/src/Polly.Core/Retry/RetryDelayGenerator.cs +++ b/src/Polly.Core/Retry/RetryDelayGenerator.cs @@ -14,5 +14,5 @@ public sealed class RetryDelayGenerator : OutcomeGenerator TimeSpan.MinValue; /// - protected override bool IsValid(TimeSpan value) => value >= TimeSpan.Zero; + protected override bool IsValid(TimeSpan value) => RetryHelper.IsValidDelay(value); } diff --git a/src/Polly.Core/Retry/RetryHelper.cs b/src/Polly.Core/Retry/RetryHelper.cs new file mode 100644 index 00000000000..2caad215bb6 --- /dev/null +++ b/src/Polly.Core/Retry/RetryHelper.cs @@ -0,0 +1,31 @@ +using System; + +namespace Polly.Retry; + +internal static class RetryHelper +{ + private const double ExponentialFactor = 2.0; + + public static bool IsValidDelay(TimeSpan delay) => delay >= TimeSpan.Zero; + + public static TimeSpan GetRetryDelay(RetryBackoffType type, int attempt, TimeSpan baseDelay) + { + if (baseDelay == TimeSpan.Zero) + { + return baseDelay; + } + + return type switch + { + RetryBackoffType.Constant => baseDelay, +#if !NETCOREAPP + RetryBackoffType.Linear => TimeSpan.FromMilliseconds((attempt + 1) * baseDelay.TotalMilliseconds), + RetryBackoffType.Exponential => TimeSpan.FromMilliseconds(Math.Pow(ExponentialFactor, attempt) * baseDelay.TotalMilliseconds), +#else + RetryBackoffType.Linear => (attempt + 1) * baseDelay, + RetryBackoffType.Exponential => Math.Pow(ExponentialFactor, attempt) * baseDelay, +#endif + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "The retry backoff type is not supported.") + }; + } +} diff --git a/src/Polly.Core/Retry/RetryResilienceStrategy.cs b/src/Polly.Core/Retry/RetryResilienceStrategy.cs new file mode 100644 index 00000000000..03d74703dd2 --- /dev/null +++ b/src/Polly.Core/Retry/RetryResilienceStrategy.cs @@ -0,0 +1,104 @@ +using System; +using Polly.Strategy; +using Polly.Telemetry; + +namespace Polly.Retry; + +internal class RetryResilienceStrategy : ResilienceStrategy +{ + private readonly TimeProvider _timeProvider; + private readonly ResilienceTelemetry _telemetry; + private readonly OutcomeEvent.Handler? _onRetry; + private readonly OutcomeGenerator.Handler? _delayGenerator; + private readonly OutcomePredicate.Handler? _shouldRetry; + + public RetryResilienceStrategy(RetryStrategyOptions options, TimeProvider timeProvider, ResilienceTelemetry telemetry) + { + _timeProvider = timeProvider; + _telemetry = telemetry; + _onRetry = options.OnRetry.CreateHandler(); + _delayGenerator = options.RetryDelayGenerator.CreateHandler(); + _shouldRetry = options.ShouldRetry.CreateHandler(); + + BackoffType = options.BackoffType; + BaseDelay = options.BaseDelay; + RetryCount = options.RetryCount; + } + + public TimeSpan BaseDelay { get; } + + public RetryBackoffType BackoffType { get; } + + public int RetryCount { get; } + + protected internal override async ValueTask ExecuteCoreAsync(Func> callback, ResilienceContext context, TState state) + { + if (_shouldRetry == null) + { + return await callback(context, state).ConfigureAwait(context.ContinueOnCapturedContext); + } + + int attempt = 0; + + while (true) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + Outcome outcome; + + try + { + var result = await callback(context, state).ConfigureAwait(context.ContinueOnCapturedContext); + outcome = new Outcome(result); + + if (IsLastAttempt(attempt) || !await _shouldRetry.ShouldHandle(outcome, new ShouldRetryArguments(context, attempt)).ConfigureAwait(context.ContinueOnCapturedContext)) + { + return result; + } + } + catch (Exception e) + { + outcome = new Outcome(e); + + if (IsLastAttempt(attempt) || !await _shouldRetry.ShouldHandle(outcome, new ShouldRetryArguments(context, attempt)).ConfigureAwait(context.ContinueOnCapturedContext)) + { + throw; + } + } + + var delay = RetryHelper.GetRetryDelay(BackoffType, attempt, BaseDelay); + if (_delayGenerator != null) + { + var newDelay = await _delayGenerator.Generate(outcome, new RetryDelayArguments(context, attempt, delay)).ConfigureAwait(false); + if (RetryHelper.IsValidDelay(newDelay)) + { + delay = newDelay; + } + } + + _telemetry.Report(RetryConstants.OnRetryEvent, outcome, context); + + if (_onRetry != null) + { + await _onRetry.Handle(outcome, new OnRetryArguments(context, attempt, delay)).ConfigureAwait(context.ContinueOnCapturedContext); + } + + if (delay > TimeSpan.Zero) + { + await _timeProvider.DelayAsync(delay, context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + attempt++; + } + } + + private bool IsLastAttempt(int attempt) + { + if (RetryCount == RetryStrategyOptions.InfiniteRetryCount) + { + return false; + } + + return attempt >= RetryCount; + } +} diff --git a/src/Polly.Core/Retry/RetryResilienceStrategyBuilderExtensions.cs b/src/Polly.Core/Retry/RetryResilienceStrategyBuilderExtensions.cs new file mode 100644 index 00000000000..e78d4636134 --- /dev/null +++ b/src/Polly.Core/Retry/RetryResilienceStrategyBuilderExtensions.cs @@ -0,0 +1,103 @@ +using System; +using Polly.Builder; + +namespace Polly.Retry; + +/// +/// Retry extension methods for the . +/// +public static class RetryResilienceStrategyBuilderExtensions +{ + /// + /// Adds a retry strategy to the builder. + /// + /// The builder instance. + /// A predicate that defines the retry conditions. + /// The builder instance with the retry strategy added. + public static ResilienceStrategyBuilder AddRetry( + this ResilienceStrategyBuilder builder, + Action shouldRetry) + { + Guard.NotNull(builder); + Guard.NotNull(shouldRetry); + + var options = new RetryStrategyOptions(); + shouldRetry(options.ShouldRetry); + + return builder.AddRetry(options); + } + + /// + /// Adds a retry strategy to the builder. + /// + /// The builder instance. + /// A predicate that defines the retry conditions. + /// The backoff type to use for the retry strategy. + /// The builder instance with the retry strategy added. + public static ResilienceStrategyBuilder AddRetry( + this ResilienceStrategyBuilder builder, + Action shouldRetry, + RetryBackoffType backoffType) + { + Guard.NotNull(builder); + Guard.NotNull(shouldRetry); + + var options = new RetryStrategyOptions + { + BackoffType = backoffType, + RetryCount = RetryConstants.DefaultRetryCount, + BaseDelay = RetryConstants.DefaultBaseDelay + }; + + shouldRetry(options.ShouldRetry); + + return builder.AddRetry(options); + } + + /// + /// Adds a retry strategy to the builder. + /// + /// The builder instance. + /// A predicate that defines the retry conditions. + /// The backoff type to use for the retry strategy. + /// The number of retries to attempt before giving up. + /// The base delay between retries. + /// The builder instance with the retry strategy added. + public static ResilienceStrategyBuilder AddRetry( + this ResilienceStrategyBuilder builder, + Action shouldRetry, + RetryBackoffType backoffType, + int retryCount, + TimeSpan baseDelay) + { + Guard.NotNull(builder); + Guard.NotNull(shouldRetry); + + var options = new RetryStrategyOptions + { + BackoffType = backoffType, + RetryCount = retryCount, + BaseDelay = baseDelay + }; + + shouldRetry(options.ShouldRetry); + + return builder.AddRetry(options); + } + + /// + /// Adds a retry strategy to the builder. + /// + /// The builder instance. + /// The retry strategy options. + /// The builder instance with the retry strategy added. + public static ResilienceStrategyBuilder AddRetry(this ResilienceStrategyBuilder builder, RetryStrategyOptions options) + { + Guard.NotNull(builder); + Guard.NotNull(options); + + ValidationHelper.ValidateObject(options, "The retry strategy options are invalid."); + + return builder.AddStrategy(context => new RetryResilienceStrategy(options, context.TimeProvider, context.Telemetry)); + } +} diff --git a/src/Polly.Core/Retry/RetryStrategyOptions.cs b/src/Polly.Core/Retry/RetryStrategyOptions.cs index 570d5cfc7e8..f003d0eccfc 100644 --- a/src/Polly.Core/Retry/RetryStrategyOptions.cs +++ b/src/Polly.Core/Retry/RetryStrategyOptions.cs @@ -1,12 +1,52 @@ using System.ComponentModel.DataAnnotations; +using Polly.Builder; namespace Polly.Retry; /// /// Represents the options used to configure a retry strategy. /// -public class RetryStrategyOptions +public class RetryStrategyOptions : ResilienceStrategyOptions { + /// + /// Initializes a new instance of the class. + /// + public RetryStrategyOptions() => StrategyType = RetryConstants.StrategyType; + + /// + /// Value that represents infinite retries. + /// + public const int InfiniteRetryCount = RetryConstants.InfiniteRetryCount; + + /// + /// Gets or sets the maximum number of retries to use, in addition to the original call. + /// + /// + /// Defaults to 3 retries. For infinite retries use InfiniteRetry (-1). + /// + [Range(InfiniteRetryCount, RetryConstants.MaxRetryCount)] + public int RetryCount { get; set; } = RetryConstants.DefaultRetryCount; + + /// + /// Gets or sets the type of the back-off. + /// + /// + /// Defaults to . + /// + public RetryBackoffType BackoffType { get; set; } = RetryConstants.DefaultBackoffType; + + /// + /// Gets or sets the delay between retries based on the backoff type, . + /// + /// + /// Defaults to 2 seconds. + /// For this represents the median delay to target before the first retry. + /// For the it represents the initial delay, the following delays increasing linearly with this value. + /// In case of it represents the constant delay between retries. + /// + [TimeSpan("00:00:00", "1.00:00:00")] + public TimeSpan BaseDelay { get; set; } = RetryConstants.DefaultBaseDelay; + /// /// Gets or sets the instance used to determine if a retry should be performed. /// diff --git a/src/Polly.Core/Strategy/SimpleEvent.cs b/src/Polly.Core/Strategy/SimpleEvent.cs index 2391b08311b..a5c6ab92989 100644 --- a/src/Polly.Core/Strategy/SimpleEvent.cs +++ b/src/Polly.Core/Strategy/SimpleEvent.cs @@ -1,6 +1,4 @@ -using Polly.Strategy; - -namespace Polly.Timeout; +namespace Polly.Strategy; /// /// This class holds a list of callbacks that are invoked when some event occurs. diff --git a/src/Polly.Core/Telemetry/NullResilienceTelemetry.cs b/src/Polly.Core/Telemetry/NullResilienceTelemetry.cs index a295bac3aee..e382607d637 100644 --- a/src/Polly.Core/Telemetry/NullResilienceTelemetry.cs +++ b/src/Polly.Core/Telemetry/NullResilienceTelemetry.cs @@ -1,3 +1,5 @@ +using Polly.Strategy; + namespace Polly.Telemetry; /// @@ -20,12 +22,7 @@ public override void Report(string eventName, ResilienceContext context) } /// - public override void Report(string eventName, TResult result, ResilienceContext context) - { - } - - /// - public override void ReportException(string eventName, Exception exception, ResilienceContext context) + public override void Report(string eventName, Outcome outcome, ResilienceContext context) { } -} \ No newline at end of file +} diff --git a/src/Polly.Core/Telemetry/ResilienceTelemetry.cs b/src/Polly.Core/Telemetry/ResilienceTelemetry.cs index cb6c58f5a6e..0a538abac69 100644 --- a/src/Polly.Core/Telemetry/ResilienceTelemetry.cs +++ b/src/Polly.Core/Telemetry/ResilienceTelemetry.cs @@ -1,3 +1,5 @@ +using Polly.Strategy; + namespace Polly.Telemetry; #pragma warning disable S1694 // An abstract class should have both abstract and concrete methods @@ -22,15 +24,7 @@ public abstract class ResilienceTelemetry /// /// The type of the result. /// The event name. - /// The result associated with the event. - /// The context associated with the event. - public abstract void Report(string eventName, TResult result, ResilienceContext context); - - /// - /// Reports an event that occurred in the resilience strategy. - /// - /// The event name. - /// The exception associated with the event. + /// The outcome associated with the event. /// The context associated with the event. - public abstract void ReportException(string eventName, Exception exception, ResilienceContext context); + public abstract void Report(string eventName, Outcome outcome, ResilienceContext context); } diff --git a/src/Polly.Core/Timeout/OnTimeoutEvent.cs b/src/Polly.Core/Timeout/OnTimeoutEvent.cs index e1825c2aacb..2c3518e9e7f 100644 --- a/src/Polly.Core/Timeout/OnTimeoutEvent.cs +++ b/src/Polly.Core/Timeout/OnTimeoutEvent.cs @@ -1,3 +1,5 @@ +using Polly.Strategy; + namespace Polly.Timeout; /// diff --git a/src/Polly.Core/Utils/TimeSpanAttribute.cs b/src/Polly.Core/Utils/TimeSpanAttribute.cs new file mode 100644 index 00000000000..792269feb73 --- /dev/null +++ b/src/Polly.Core/Utils/TimeSpanAttribute.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; + +#pragma warning disable CA1019 // Define accessors for attribute arguments + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] +internal sealed class TimeSpanAttribute : ValidationAttribute +{ + public TimeSpan Minimum => TimeSpan.Parse(_min, CultureInfo.InvariantCulture); + + public TimeSpan? Maximum => _max == null ? null : TimeSpan.Parse(_max, CultureInfo.InvariantCulture); + + private readonly string _min; + private readonly string? _max; + + public TimeSpanAttribute(string min) + { + _min = min; + _max = null; + } + + public TimeSpanAttribute(string min, string max) + { + _min = min; + _max = max; + } + + protected override ValidationResult IsValid(object? value, ValidationContext? validationContext) + { + var min = Minimum; + var max = Maximum; + + if (value is TimeSpan ts) + { + if (ts < min) + { + return new ValidationResult($"The field {validationContext.GetDisplayName()} must be >= to {min}.", validationContext.GetMemberName()); + } + + if (max.HasValue) + { + if (ts > max.Value) + { + return new ValidationResult($"The field {validationContext.GetDisplayName()} must be <= to {max}.", validationContext.GetMemberName()); + } + } + } + + return ValidationResult.Success!; + } +} diff --git a/src/Polly.Core/Utils/ValidationContextExtensions.cs b/src/Polly.Core/Utils/ValidationContextExtensions.cs new file mode 100644 index 00000000000..3fee8f585cd --- /dev/null +++ b/src/Polly.Core/Utils/ValidationContextExtensions.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +internal static class ValidationContextExtensions +{ + public static string[]? GetMemberName(this ValidationContext? validationContext) + { +#pragma warning disable S1168 // Empty arrays and collections should be returned instead of null + return validationContext?.MemberName is { } memberName + ? new[] { memberName } + : null; +#pragma warning restore S1168 // Empty arrays and collections should be returned instead of null + } + + public static string GetDisplayName(this ValidationContext? validationContext) + { + return validationContext?.DisplayName ?? string.Empty; + } +}