diff --git a/src/Polly.Core.Tests/Polly.Core.Tests.csproj b/src/Polly.Core.Tests/Polly.Core.Tests.csproj index ba461576479..477effa9186 100644 --- a/src/Polly.Core.Tests/Polly.Core.Tests.csproj +++ b/src/Polly.Core.Tests/Polly.Core.Tests.csproj @@ -7,7 +7,7 @@ enable true 100 - $(NoWarn);SA1600;SA1204 + $(NoWarn);SA1600;SA1204;SA1602 [Polly.Core]* diff --git a/src/Polly.Core.Tests/Retry/RetryDelayArgumentsTests.cs b/src/Polly.Core.Tests/Retry/RetryDelayArgumentsTests.cs new file mode 100644 index 00000000000..927d9a4be35 --- /dev/null +++ b/src/Polly.Core.Tests/Retry/RetryDelayArgumentsTests.cs @@ -0,0 +1,15 @@ +using Polly.Retry; + +namespace Polly.Core.Tests.Retry; + +public class RetryDelayArgumentsTests +{ + [Fact] + public void Ctor_Ok() + { + var args = new RetryDelayArguments(ResilienceContext.Get(), 2); + + args.Context.Should().NotBeNull(); + args.Attempt.Should().Be(2); + } +} diff --git a/src/Polly.Core.Tests/Retry/RetryDelayGeneratorTests.cs b/src/Polly.Core.Tests/Retry/RetryDelayGeneratorTests.cs new file mode 100644 index 00000000000..7e33fb1ab46 --- /dev/null +++ b/src/Polly.Core.Tests/Retry/RetryDelayGeneratorTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; +using Polly.Retry; +using Polly.Strategy; +using Xunit; + +namespace Polly.Core.Tests.Retry; + +public class RetryDelayGeneratorTests +{ + [Fact] + public async Task NoGeneratorRegisteredForType_EnsureDefaultValue() + { + var result = await new RetryDelayGenerator() + .SetGenerator((_, _) => TimeSpan.Zero) + .CreateHandler()! + .Generate(new Outcome(true), new RetryDelayArguments(ResilienceContext.Get(), 0)); + + result.Should().Be(TimeSpan.MinValue); + } + + public static readonly TheoryData ValidDelays = new() { TimeSpan.Zero, TimeSpan.FromMilliseconds(123) }; + + [MemberData(nameof(ValidDelays))] + [Theory] + public async Task GeneratorRegistered_EnsureValueNotIgnored(TimeSpan delay) + { + var result = await new RetryDelayGenerator() + .SetGenerator((_, _) => delay) + .CreateHandler()! + .Generate(new Outcome(0), new RetryDelayArguments(ResilienceContext.Get(), 0)); + + result.Should().Be(delay); + } +} diff --git a/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs b/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs index 82120482660..aca9027fecb 100644 --- a/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs +++ b/src/Polly.Core.Tests/Retry/RetryStrategyOptionsTests.cs @@ -11,5 +11,8 @@ public void Ctor_Ok() options.ShouldRetry.Should().NotBeNull(); options.ShouldRetry.IsEmpty.Should().BeTrue(); + + options.RetryDelayGenerator.Should().NotBeNull(); + options.RetryDelayGenerator.IsEmpty.Should().BeTrue(); } } diff --git a/src/Polly.Core.Tests/Strategy/OutcomeGeneratorTests.cs b/src/Polly.Core.Tests/Strategy/OutcomeGeneratorTests.cs new file mode 100644 index 00000000000..29909c68eed --- /dev/null +++ b/src/Polly.Core.Tests/Strategy/OutcomeGeneratorTests.cs @@ -0,0 +1,148 @@ +using System; +using System.Threading.Tasks; +using Polly.Strategy; + +namespace Polly.Core.Tests.Strategy; + +public class OutcomeGeneratorTests +{ + private readonly DummyGenerator _sut = new(); + + [Fact] + public void Empty_Ok() + { + _sut.IsEmpty.Should().BeTrue(); + + _sut.SetGenerator((_, _) => GeneratedValue.Invalid); + + _sut.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void CreateHandler_Empty_ReturnsNull() + { + _sut.CreateHandler().Should().BeNull(); + } + + public static readonly TheoryData> Data = new() + { + sut => + { + sut.SetGenerator((_, _) => GeneratedValue.Valid1); + InvokeHandler(sut, new Outcome(0), GeneratedValue.Valid1); + }, + sut => + { + sut.SetGenerator((_, _) => new ValueTask(GeneratedValue.Valid1)); + InvokeHandler(sut, new Outcome(0), GeneratedValue.Valid1); + }, + sut => + { + sut.SetGenerator((_, _) => GeneratedValue.Valid1); + InvokeHandler(sut, new Outcome(true), GeneratedValue.Default); + }, + sut => + { + sut.SetGenerator((_, _) => new ValueTask(GeneratedValue.Valid1)); + InvokeHandler(sut, new Outcome(true), GeneratedValue.Default); + }, + sut => + { + sut.SetGenerator((_, _) => GeneratedValue.Invalid); + InvokeHandler(sut, new Outcome(0), GeneratedValue.Default); + }, + sut => + { + sut.SetGenerator((_, _) => new ValueTask(GeneratedValue.Invalid)); + InvokeHandler(sut, new Outcome(0), GeneratedValue.Default); + }, + sut => + { + sut.SetGenerator((_, _) => GeneratedValue.Valid1); + sut.SetGenerator((_, _) => GeneratedValue.Valid2); + InvokeHandler(sut, new Outcome(0), GeneratedValue.Valid2); + }, + sut => + { + sut.SetGenerator((_, _) => new ValueTask(GeneratedValue.Valid1)); + sut.SetGenerator((_, _) => new ValueTask(GeneratedValue.Valid2)); + InvokeHandler(sut, new Outcome(0), GeneratedValue.Valid2); + }, + sut => + { + sut.SetGenerator((_, _) => GeneratedValue.Valid1); + sut.SetGenerator((_, _) => GeneratedValue.Valid2); + InvokeHandler(sut, new Outcome(0), GeneratedValue.Valid1); + InvokeHandler(sut, new Outcome(true), GeneratedValue.Valid2); + }, + sut => + { + sut.SetGenerator((_, _) => GeneratedValue.Valid1); + sut.SetGenerator((_, _) => GeneratedValue.Valid1); + InvokeHandler(sut, new Outcome(true), GeneratedValue.Default); + }, + }; + + [MemberData(nameof(Data))] + [Theory] + public void ResultHandler_SinglePredicate_Ok(Action callback) + { + _sut.Invoking(s => callback(s)).Should().NotThrow(); + callback(_sut); + } + + [Fact] + public void AddResultHandlers_DifferentResultType_NotInvoked() + { + var callbacks = new List(); + + for (var i = 0; i < 10; i++) + { + var index = i; + + _sut.SetGenerator((_, _) => + { + callbacks.Add(index); + return GeneratedValue.Valid1; + }); + + _sut.SetGenerator((_, _) => + { + callbacks.Add(index); + return GeneratedValue.Valid1; + }); + } + + InvokeHandler(_sut, new Outcome(1), GeneratedValue.Valid1); + + callbacks.Distinct().Should().HaveCount(1); + } + + private static void InvokeHandler(DummyGenerator sut, Outcome outcome, GeneratedValue expectedResult) + { + var args = new Args(); + sut.CreateHandler()!.Generate(outcome, args).AsTask().Result.Should().Be(expectedResult); + } + + public sealed class DummyGenerator : OutcomeGenerator + { + protected override GeneratedValue DefaultValue => GeneratedValue.Default; + + protected override bool IsValid(GeneratedValue value) => value == GeneratedValue.Valid1 || value == GeneratedValue.Valid2; + } + + public enum GeneratedValue + { + Default, + Valid1, + Valid2, + Invalid + } + + public class Args : IResilienceArguments + { + public Args() => Context = ResilienceContext.Get(); + + public ResilienceContext Context { get; private set; } + } +} diff --git a/src/Polly.Core/Retry/RetryDelayArguments.cs b/src/Polly.Core/Retry/RetryDelayArguments.cs new file mode 100644 index 00000000000..3fa9c0d050e --- /dev/null +++ b/src/Polly.Core/Retry/RetryDelayArguments.cs @@ -0,0 +1,28 @@ +using Polly.Strategy; + +namespace Polly.Retry; + +#pragma warning disable CA1815 // Override equals and operator equals on value types + +/// +/// Represents the arguments used in for generating the next retry delay. +/// +public readonly struct RetryDelayArguments : IResilienceArguments +{ + internal RetryDelayArguments(ResilienceContext context, int attempt) + { + Attempt = attempt; + Context = context; + } + + /// + /// Gets the zero-based attempt number. + /// + /// + /// The first attempt is 0, the second attempt is 1, and so on. + /// + public int Attempt { get; } + + /// + public ResilienceContext Context { get; } +} diff --git a/src/Polly.Core/Retry/RetryDelayGenerator.cs b/src/Polly.Core/Retry/RetryDelayGenerator.cs new file mode 100644 index 00000000000..deb12f6061d --- /dev/null +++ b/src/Polly.Core/Retry/RetryDelayGenerator.cs @@ -0,0 +1,18 @@ +using Polly.Strategy; + +namespace Polly.Retry; + +/// +/// This class generates the customized retries used in retry strategy. +/// +/// +/// If the generator returns a negative value, it's value is ignored. +/// +public sealed class RetryDelayGenerator : OutcomeGenerator +{ + /// + protected override TimeSpan DefaultValue => TimeSpan.MinValue; + + /// + protected override bool IsValid(TimeSpan value) => value >= TimeSpan.Zero; +} diff --git a/src/Polly.Core/Retry/RetryStrategyOptions.cs b/src/Polly.Core/Retry/RetryStrategyOptions.cs index a0270620dad..da1e346ec04 100644 --- a/src/Polly.Core/Retry/RetryStrategyOptions.cs +++ b/src/Polly.Core/Retry/RetryStrategyOptions.cs @@ -10,6 +10,18 @@ public class RetryStrategyOptions /// /// Gets or sets the instance used to determine if a retry should be performed. /// + /// + /// By default, the predicate is empty and no results or exceptions are retried. + /// [Required] public ShouldRetryPredicate ShouldRetry { get; set; } = new(); + + /// + /// Gets or sets the instance that is used to generated the delay between retries. + /// + /// + /// By default, the generator is empty and it does not affect the delay between retries. + /// + [Required] + public RetryDelayGenerator RetryDelayGenerator { get; set; } = new(); } diff --git a/src/Polly.Core/Strategy/OutcomeGenerator.Handler.cs b/src/Polly.Core/Strategy/OutcomeGenerator.Handler.cs new file mode 100644 index 00000000000..e2ee79b899b --- /dev/null +++ b/src/Polly.Core/Strategy/OutcomeGenerator.Handler.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; + +namespace Polly.Strategy; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1005 // Avoid excessive parameters on generic types +#pragma warning disable S2436 // Types and methods should not have too many generic parameters + +public abstract partial class OutcomeGenerator +{ + /// + /// The resulting handler for the outcome. + /// + public abstract class Handler + { + private protected Handler(TGeneratedValue defaultValue, Predicate isValid) + { + DefaultValue = defaultValue; + IsValid = isValid; + } + + internal TGeneratedValue DefaultValue { get; } + + internal Predicate IsValid { get; } + + /// + /// Determines if the handler should handle the outcome. + /// + /// The result type to add a predicate for. + /// The operation outcome. + /// The arguments. + /// The result of the handle operation. + public abstract ValueTask Generate(Outcome outcome, TArgs args); + } + + private sealed class TypeHandler : Handler + { + private readonly Type _type; + private readonly object _generator; + + public TypeHandler( + Type type, + object generator, + TGeneratedValue defaultValue, + Predicate isValid) + : base(defaultValue, isValid) + { + _type = type; + _generator = generator; + } + + public override async ValueTask Generate(Outcome outcome, TArgs args) + { + if (typeof(TResult) == _type) + { + var value = await ((Func, TArgs, ValueTask>)_generator)(outcome, args).ConfigureAwait(args.Context.ContinueOnCapturedContext); + + if (IsValid(value)) + { + return value; + } + + return DefaultValue; + } + + return DefaultValue; + } + } + + private sealed class TypesHandler : Handler + { + private readonly Dictionary _generators; + + public TypesHandler( + IEnumerable> generators, + TGeneratedValue defaultValue, + Predicate isValid) + : base(defaultValue, isValid) + => _generators = generators.ToDictionary(v => v.Key, v => new TypeHandler(v.Key, v.Value, defaultValue, isValid)); + + public override ValueTask Generate(Outcome outcome, TArgs args) + { + if (_generators.TryGetValue(typeof(TResult), out var handler)) + { + return handler.Generate(outcome, args); + } + + return new ValueTask(DefaultValue); + } + } +} diff --git a/src/Polly.Core/Strategy/OutcomeGenerator.cs b/src/Polly.Core/Strategy/OutcomeGenerator.cs new file mode 100644 index 00000000000..8abde15e8fd --- /dev/null +++ b/src/Polly.Core/Strategy/OutcomeGenerator.cs @@ -0,0 +1,81 @@ +using System; +using Polly.Strategy; + +namespace Polly.Strategy; + +#pragma warning disable S2436 // Types and methods should not have too many generic parameters +#pragma warning disable CA1005 // Too many generic arguments, but this is the only way to make the API work without unnecessary extensions + +/// +/// The base class for all generators that generate a value based on the . +/// +/// The type of generated value. +/// The arguments the generator uses. +/// The class that implements . +public abstract partial class OutcomeGenerator + where TArgs : IResilienceArguments + where TSelf : OutcomeGenerator +{ + private readonly Dictionary _generators = new(); + + /// + /// Gets the default value returned by the generator. + /// + protected abstract TGeneratedValue DefaultValue { get; } + + /// + /// Gets a value indicating whether the generator is empty. + /// + public bool IsEmpty => _generators.Count == 0; + + /// + /// Adds a result generator for the specified result type. + /// + /// The result type to add a generator for. + /// The generator to determine if the result should be retried. + /// The current updated instance. + public TSelf SetGenerator(Func, TArgs, TGeneratedValue> generator) + { + Guard.NotNull(generator); + + return SetGenerator((outcome, args) => new ValueTask(generator(outcome, args))); + } + + /// + /// Adds a result generator for the specified result type. + /// + /// The result type to add a generator for. + /// The generator to determine if the result should be retried. + /// The current updated instance. + public TSelf SetGenerator(Func, TArgs, ValueTask> generator) + { + Guard.NotNull(generator); + + _generators[typeof(TResult)] = generator; + + return (TSelf)this; + } + + /// + /// Determines if the generated value is valid. + /// + /// The value returned by the user-provided generator. + /// True if generated value is valid, false otherwise. + protected abstract bool IsValid(TGeneratedValue value); + + /// + /// Creates a handler for the specified generators. + /// + /// Handler instance or null if no generators are registered. + protected internal Handler? CreateHandler() + { + var pairs = _generators.ToArray(); + + return pairs.Length switch + { + 0 => null, + 1 => new TypeHandler(pairs[0].Key, pairs[0].Value, DefaultValue, IsValid), + _ => new TypesHandler(pairs, DefaultValue, IsValid) + }; + } +}