diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3cd86dbe6a..6310531a85 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,7 +71,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4 with: - files: ./artifacts/coverage-reports/Polly.Core.Tests/Cobertura.xml,./artifacts/coverage-reports/Polly.Specs/Cobertura.xml,./artifacts/coverage-reports/Polly.RateLimiting.Tests/Cobertura.xml,./artifacts/coverage-reports/Polly.Extensions.Tests/Cobertura.xml, + files: ./artifacts/coverage-reports/Polly.Core.Tests/Cobertura.xml,./artifacts/coverage-reports/Polly.Specs/Cobertura.xml,./artifacts/coverage-reports/Polly.RateLimiting.Tests/Cobertura.xml,./artifacts/coverage-reports/Polly.Extensions.Tests/Cobertura.xml,./artifacts/coverage-reports/Polly.Testing.Tests/Cobertura.xml, flags: ${{ matrix.os_name }} - name: Upload Mutation Report diff --git a/Polly.sln b/Polly.sln index dde4741a53..97851b37fd 100644 --- a/Polly.sln +++ b/Polly.sln @@ -52,6 +52,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{A6CC41B9-E EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B7BF406B-B06F-4025-83E6-7219C53196A6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Polly.Testing", "src\Polly.Testing\Polly.Testing.csproj", "{9AD2D6AD-56E4-49D6-B6F1-EE975D5760B9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Polly.Testing.Tests", "test\Polly.Testing.Tests\Polly.Testing.Tests.csproj", "{D333B5CE-982D-4C11-BDAF-4217AA02306E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -102,6 +106,14 @@ Global {C04DEE61-C1EA-4028-B457-CDBD304B8ED9}.Debug|Any CPU.Build.0 = Debug|Any CPU {C04DEE61-C1EA-4028-B457-CDBD304B8ED9}.Release|Any CPU.ActiveCfg = Release|Any CPU {C04DEE61-C1EA-4028-B457-CDBD304B8ED9}.Release|Any CPU.Build.0 = Release|Any CPU + {9AD2D6AD-56E4-49D6-B6F1-EE975D5760B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AD2D6AD-56E4-49D6-B6F1-EE975D5760B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AD2D6AD-56E4-49D6-B6F1-EE975D5760B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AD2D6AD-56E4-49D6-B6F1-EE975D5760B9}.Release|Any CPU.Build.0 = Release|Any CPU + {D333B5CE-982D-4C11-BDAF-4217AA02306E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D333B5CE-982D-4C11-BDAF-4217AA02306E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D333B5CE-982D-4C11-BDAF-4217AA02306E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D333B5CE-982D-4C11-BDAF-4217AA02306E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -119,6 +131,8 @@ Global {BCA09595-A4D3-4D74-AC80-3E7017E51B24} = {B7BF406B-B06F-4025-83E6-7219C53196A6} {06070F42-6738-4D0B-8D7E-9400B4030193} = {A6CC41B9-E0B9-44F8-916B-3E4A78DA3BFB} {C04DEE61-C1EA-4028-B457-CDBD304B8ED9} = {A6CC41B9-E0B9-44F8-916B-3E4A78DA3BFB} + {9AD2D6AD-56E4-49D6-B6F1-EE975D5760B9} = {B7BF406B-B06F-4025-83E6-7219C53196A6} + {D333B5CE-982D-4C11-BDAF-4217AA02306E} = {A6CC41B9-E0B9-44F8-916B-3E4A78DA3BFB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2E5D54CD-770A-4345-B585-1848FC2EA6F4} diff --git a/build.cake b/build.cake index 48abc0bf9a..c029e84de3 100644 --- a/build.cake +++ b/build.cake @@ -170,6 +170,7 @@ Task("__RunMutationTests") TestProject(File("../src/Polly.Core/Polly.Core.csproj"), File("./Polly.Core.Tests/Polly.Core.Tests.csproj"), "Polly.Core.csproj"); TestProject(File("../src/Polly.RateLimiting/Polly.RateLimiting.csproj"), File("./Polly.RateLimiting.Tests/Polly.RateLimiting.Tests.csproj"), "Polly.RateLimiting.csproj"); TestProject(File("../src/Polly.Extensions/Polly.Extensions.csproj"), File("./Polly.Extensions.Tests/Polly.Extensions.Tests.csproj"), "Polly.Extensions.csproj"); + TestProject(File("../src/Polly.Testing/Polly.Testing.csproj"), File("./Polly.Testing.Tests/Polly.Testing.Tests.csproj"), "Polly.Testing.csproj"); TestProject(File("../src/Polly/Polly.csproj"), File("./Polly.Specs/Polly.Specs.csproj"), "Polly.csproj"); context.Environment.WorkingDirectory = oldDirectory; @@ -221,6 +222,7 @@ Task("__CreateNuGetPackages") System.IO.Path.Combine(srcDir, "Polly", "Polly.csproj"), System.IO.Path.Combine(srcDir, "Polly.RateLimiting", "Polly.RateLimiting.csproj"), System.IO.Path.Combine(srcDir, "Polly.Extensions", "Polly.Extensions.csproj"), + System.IO.Path.Combine(srcDir, "Polly.Testing", "Polly.Testing.csproj"), }; Information("Building NuGet packages"); diff --git a/src/Polly.Core/Polly.Core.csproj b/src/Polly.Core/Polly.Core.csproj index c9747fcc2c..c4a3fbde39 100644 --- a/src/Polly.Core/Polly.Core.csproj +++ b/src/Polly.Core/Polly.Core.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Polly.Core/ResilienceStrategy.cs b/src/Polly.Core/ResilienceStrategy.cs index 6ab5449f12..717269aaa1 100644 --- a/src/Polly.Core/ResilienceStrategy.cs +++ b/src/Polly.Core/ResilienceStrategy.cs @@ -11,6 +11,8 @@ namespace Polly; /// public abstract partial class ResilienceStrategy { + internal ResilienceStrategyOptions? Options { get; set; } + /// /// Executes the specified callback. /// diff --git a/src/Polly.Core/ResilienceStrategyBuilderBase.cs b/src/Polly.Core/ResilienceStrategyBuilderBase.cs index c7083f1603..4aeb59ed87 100644 --- a/src/Polly.Core/ResilienceStrategyBuilderBase.cs +++ b/src/Polly.Core/ResilienceStrategyBuilderBase.cs @@ -137,15 +137,17 @@ private ResilienceStrategy CreateResilienceStrategy(Entry entry) builderName: BuilderName, builderInstanceName: InstanceName, builderProperties: Properties, - strategyName: entry.Properties.StrategyName, - strategyType: entry.Properties.StrategyType, + strategyName: entry.Options.StrategyName, + strategyType: entry.Options.StrategyType, timeProvider: TimeProvider, isGenericBuilder: IsGenericBuilder, diagnosticSource: DiagnosticSource, randomizer: Randomizer); - return entry.Factory(context); + var strategy = entry.Factory(context); + strategy.Options = entry.Options; + return strategy; } - private sealed record Entry(Func Factory, ResilienceStrategyOptions Properties); + private sealed record Entry(Func Factory, ResilienceStrategyOptions Options); } diff --git a/src/Polly.Testing/InnerStrategiesDescriptor.cs b/src/Polly.Testing/InnerStrategiesDescriptor.cs new file mode 100644 index 0000000000..14f171a73a --- /dev/null +++ b/src/Polly.Testing/InnerStrategiesDescriptor.cs @@ -0,0 +1,9 @@ +namespace Polly.Testing; + +/// +/// Describes the pipeline of a resilience strategy. +/// +/// The strategies the pipeline is composed of. +/// Gets a value indicating whether the pipeline has telemetry enabled. +/// Gets a value indicating whether the resilience strategy is reloadable. +public record class InnerStrategiesDescriptor(IReadOnlyList Strategies, bool HasTelemetry, bool IsReloadable); diff --git a/src/Polly.Testing/Polly.Testing.csproj b/src/Polly.Testing/Polly.Testing.csproj new file mode 100644 index 0000000000..d85ec8fba2 --- /dev/null +++ b/src/Polly.Testing/Polly.Testing.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + Polly.Testing + Polly.Testing + enable + true + Library + 100 + + + + + + + diff --git a/src/Polly.Testing/README.md b/src/Polly.Testing/README.md new file mode 100644 index 0000000000..fa1b42f68d --- /dev/null +++ b/src/Polly.Testing/README.md @@ -0,0 +1,28 @@ +# About Polly.Testing + +This package exposes APIs and utilities that can be used to assert on the composition of resilience strategies. + +``` csharp +// Build your resilience strategy. +ResilienceStrategy strategy = new ResilienceStrategyBuilder() + .AddRetry(new RetryStrategyOptions + { + RetryCount = 4 + }) + .AddTimeout(TimeSpan.FromSeconds(1)) + .ConfigureTelemetry(NullLoggerFactory.Instance) + .Build(); + +// Retrieve inner strategies. +InnerStrategiesDescriptor descriptor = strategy.GetInnerStrategies(); + +// Assert the composition. +Assert.True(descriptor.HasTelemetry); +Assert.Equal(2, descriptor.Strategies.Count); + +var retryOptions = Assert.IsType(descriptor.Strategies[0]); +Assert.Equal(4, retryOptions.RetryCount); + +var timeoutOptions = Assert.IsType(descriptor.Strategies[0]); +Assert.Equal(TimeSpan.FromSeconds(1), timeoutOptions.Timeout); +``` diff --git a/src/Polly.Testing/ResilienceStrategyDescriptor.cs b/src/Polly.Testing/ResilienceStrategyDescriptor.cs new file mode 100644 index 0000000000..04005d3249 --- /dev/null +++ b/src/Polly.Testing/ResilienceStrategyDescriptor.cs @@ -0,0 +1,10 @@ +namespace Polly.Testing; + +/// +/// This class provides additional information about a . +/// +/// The options used by the resilience strategy, if any. +/// The type of the strategy. +public record ResilienceStrategyDescriptor(ResilienceStrategyOptions? Options, Type StrategyType) +{ +} diff --git a/src/Polly.Testing/ResilienceStrategyExtensions.cs b/src/Polly.Testing/ResilienceStrategyExtensions.cs new file mode 100644 index 0000000000..bf8ca6a1c8 --- /dev/null +++ b/src/Polly.Testing/ResilienceStrategyExtensions.cs @@ -0,0 +1,68 @@ +using Polly.Utils; + +namespace Polly.Testing; + +/// +/// The test-related extensions for and . +/// +public static class ResilienceStrategyExtensions +{ + private const string TelemetryResilienceStrategy = "Polly.Extensions.Telemetry.TelemetryResilienceStrategy"; + + /// + /// Gets the inner strategies the is composed of. + /// + /// The type of result. + /// The strategy instance. + /// A list of inner strategies. + /// Thrown when is . + public static InnerStrategiesDescriptor GetInnerStrategies(this ResilienceStrategy strategy) + { + Guard.NotNull(strategy); + + return strategy.Strategy.GetInnerStrategies(); + } + + /// + /// Gets the inner strategies the is composed of. + /// + /// The strategy instance. + /// A list of inner strategies. + /// Thrown when is . + public static InnerStrategiesDescriptor GetInnerStrategies(this ResilienceStrategy strategy) + { + Guard.NotNull(strategy); + + var strategies = new List(); + strategy.ExpandStrategies(strategies); + + var innerStrategies = strategies.Select(s => new ResilienceStrategyDescriptor(s.Options, s.GetType())).ToList(); + + return new InnerStrategiesDescriptor( + innerStrategies.Where(s => !ShouldSkip(s.StrategyType)).ToList().AsReadOnly(), + HasTelemetry: innerStrategies.Exists(s => s.StrategyType.FullName == TelemetryResilienceStrategy), + IsReloadable: innerStrategies.Exists(s => s.StrategyType == typeof(ReloadableResilienceStrategy))); + } + + private static bool ShouldSkip(Type type) => type == typeof(ReloadableResilienceStrategy) || type.FullName == TelemetryResilienceStrategy; + + private static void ExpandStrategies(this ResilienceStrategy strategy, List strategies) + { + if (strategy is ResilienceStrategyPipeline pipeline) + { + foreach (var inner in pipeline.Strategies) + { + inner.ExpandStrategies(strategies); + } + } + else if (strategy is ReloadableResilienceStrategy reloadable) + { + strategies.Add(reloadable); + ExpandStrategies(reloadable.Strategy, strategies); + } + else + { + strategies.Add(strategy); + } + } +} diff --git a/test/Polly.Testing.Tests/Polly.Testing.Tests.csproj b/test/Polly.Testing.Tests/Polly.Testing.Tests.csproj new file mode 100644 index 0000000000..2469343b97 --- /dev/null +++ b/test/Polly.Testing.Tests/Polly.Testing.Tests.csproj @@ -0,0 +1,17 @@ + + + net7.0;net6.0 + $(TargetFrameworks);net481 + Test + enable + 100 + $(NoWarn);SA1600;SA1204 + [Polly.Testing]* + + + + + + + + diff --git a/test/Polly.Testing.Tests/ResilienceStrategyExtensionsTests.cs b/test/Polly.Testing.Tests/ResilienceStrategyExtensionsTests.cs new file mode 100644 index 0000000000..f4ba0477b7 --- /dev/null +++ b/test/Polly.Testing.Tests/ResilienceStrategyExtensionsTests.cs @@ -0,0 +1,102 @@ +using System; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Polly.CircuitBreaker; +using Polly.Fallback; +using Polly.Hedging; +using Polly.RateLimiting; +using Polly.Registry; +using Polly.Retry; +using Polly.Timeout; + +namespace Polly.Testing.Tests; + +public class ResilienceStrategyExtensionsTests +{ + [Fact] + public void GetInnerStrategies_Ok() + { + // arrange + var strategy = new ResilienceStrategyBuilder() + .AddFallback(new() + { + FallbackAction = _ => Outcome.FromResultAsTask("dummy"), + }) + .AddRetry(new()) + .AddAdvancedCircuitBreaker(new()) + .AddTimeout(TimeSpan.FromSeconds(1)) + .AddHedging(new()) + .AddConcurrencyLimiter(10) + .AddStrategy(new CustomStrategy()) + .ConfigureTelemetry(NullLoggerFactory.Instance) + .Build(); + + // act + var descriptor = strategy.GetInnerStrategies(); + + // assert + descriptor.HasTelemetry.Should().BeTrue(); + descriptor.IsReloadable.Should().BeFalse(); + descriptor.Strategies.Should().HaveCount(7); + descriptor.Strategies[0].Options.Should().BeOfType>(); + descriptor.Strategies[1].Options.Should().BeOfType>(); + descriptor.Strategies[2].Options.Should().BeOfType>(); + descriptor.Strategies[3].Options.Should().BeOfType(); + descriptor.Strategies[3].Options + .Should() + .BeOfType().Subject.Timeout + .Should().Be(TimeSpan.FromSeconds(1)); + + descriptor.Strategies[4].Options.Should().BeOfType>(); + descriptor.Strategies[5].Options.Should().BeOfType(); + descriptor.Strategies[6].StrategyType.Should().Be(typeof(CustomStrategy)); + } + + [Fact] + public void GetInnerStrategies_SingleStrategy_Ok() + { + // arrange + var strategy = new ResilienceStrategyBuilder() + .AddTimeout(TimeSpan.FromSeconds(1)) + .Build(); + + // act + var descriptor = strategy.GetInnerStrategies(); + + // assert + descriptor.HasTelemetry.Should().BeFalse(); + descriptor.IsReloadable.Should().BeFalse(); + descriptor.Strategies.Should().HaveCount(1); + descriptor.Strategies[0].Options.Should().BeOfType(); + } + + [Fact] + public void GetInnerStrategies_Reloadable_Ok() + { + // arrange + var strategy = new ResilienceStrategyRegistry().GetOrAddStrategy("dummy", (builder, context) => + { + context.EnableReloads(() => () => CancellationToken.None); + + builder + .AddConcurrencyLimiter(10) + .AddStrategy(new CustomStrategy()); + }); + + // act + var descriptor = strategy.GetInnerStrategies(); + + // assert + descriptor.HasTelemetry.Should().BeFalse(); + descriptor.IsReloadable.Should().BeTrue(); + descriptor.Strategies.Should().HaveCount(2); + descriptor.Strategies[0].Options.Should().BeOfType(); + descriptor.Strategies[1].StrategyType.Should().Be(typeof(CustomStrategy)); + } + + private sealed class CustomStrategy : ResilienceStrategy + { + protected override ValueTask> ExecuteCoreAsync(Func>> callback, ResilienceContext context, TState state) + => throw new NotSupportedException(); + } +}