diff --git a/docs/examples/inventory-retry-policy.md b/docs/examples/inventory-retry-policy.md new file mode 100644 index 00000000..b1b44455 --- /dev/null +++ b/docs/examples/inventory-retry-policy.md @@ -0,0 +1,21 @@ +# Inventory Retry Policy + +This example models a production inventory lookup that can receive a transient service response before stock data becomes available. It demonstrates the same retry rule through: + +- a fluent `RetryPolicy` +- a source-generated retry policy factory +- an `IServiceCollection` extension that imports the demo into a standard .NET host + +```csharp +var services = new ServiceCollection(); +services.AddInventoryRetryDemo(); + +using var provider = services.BuildServiceProvider(); +var lookup = provider.GetRequiredService(); + +var result = await lookup.CheckAsync("SKU-42"); +``` + +The registered demo uses the generated policy path and a scripted inventory client. Applications can replace `IInventoryClient` with their own implementation while keeping the same policy registration shape. + +The accompanying TinyBDD tests validate the fluent path, the generated path, and the DI integration. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index b7416457..25c8097e 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -111,3 +111,6 @@ - name: Template Method (Async) href: template-method-async-demo.md + +- name: Inventory Retry Policy + href: inventory-retry-policy.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 0a17aec3..5419543d 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -77,6 +77,12 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Reliability Pipeline**](messaging.md#generated-reliability-pipeline) | Idempotent receiver, inbox, and outbox factories | `[GenerateReliabilityPipeline]` | | [**Backplane Topology**](messaging.md#generated-backplane-topology) | Request/reply routes and publish/subscribe endpoint topology | `[GenerateBackplaneTopology]` | +### Cloud And Resilience + +| Generator | Description | Attribute | +|---|---|---| +| [**Retry**](retry.md) | Bounded retry policy factories for transient results and exceptions | `[GenerateRetryPolicy]` | + ## Quick Reference ### Creational @@ -194,6 +200,14 @@ public static partial class OrderSlip { } public static partial class OrderSaga { } ``` +### Cloud And Resilience + +```csharp +// Retry - generated bounded retry policy +[GenerateRetryPolicy(typeof(InventoryResponse), MaxAttempts = 3, BackoffFactor = 2)] +public static partial class InventoryRetryPolicy { } +``` + ## Examples See [Generator Examples](examples.md) and [Source Generator Application Suite](../examples/source-generator-application-suite.md) for: diff --git a/docs/generators/retry.md b/docs/generators/retry.md new file mode 100644 index 00000000..4ac1c67b --- /dev/null +++ b/docs/generators/retry.md @@ -0,0 +1,36 @@ +# Retry Generator + +The retry generator creates a strongly typed `RetryPolicy` factory from declarative attributes. It is useful when a team wants retry rules to live beside the operation contract while still producing the same runtime policy as the fluent API. + +```csharp +[GenerateRetryPolicy( + typeof(InventoryResponse), + FactoryMethodName = nameof(CreateGeneratedPolicy), + PolicyName = "inventory-availability", + MaxAttempts = 3, + InitialDelayMilliseconds = 25, + BackoffFactor = 2)] +public static partial class InventoryRetryPolicy +{ + [RetryResultPredicate] + private static bool ShouldRetry(InventoryResponse response) + => response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode >= 500; + + [RetryExceptionPredicate] + private static bool ShouldRetry(Exception exception) + => exception is TimeoutException; +} +``` + +The generated factory returns `PatternKit.Cloud.Retry.RetryPolicy` and applies any declared result and exception predicates. + +## Rules + +- The host type must be `partial`. +- `MaxAttempts` must be at least `1`. +- `InitialDelayMilliseconds` must be non-negative. +- `BackoffFactor` must be at least `1`. +- Result predicates must be `static bool` methods with one `TResult` parameter. +- Exception predicates must be `static bool` methods with one `Exception` parameter. + +Diagnostics use the `PKRET` prefix. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 7e28eb69..f02b4457 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -61,6 +61,9 @@ - name: Proxy href: proxy.md +- name: Retry + href: retry.md + - name: Singleton href: singleton.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index f0b2735e..03322817 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -57,6 +57,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Messaging Reliability | Outbox | `InMemoryOutbox` and dispatcher contracts | Reliability pipeline generator | | Enterprise Integration | Request-Reply | Messaging backplane facade example | Backplane topology generator | | Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Backplane topology generator | +| Cloud Architecture | Retry | `RetryPolicy` | Retry generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | diff --git a/docs/patterns/cloud/retry.md b/docs/patterns/cloud/retry.md new file mode 100644 index 00000000..6ee7228f --- /dev/null +++ b/docs/patterns/cloud/retry.md @@ -0,0 +1,31 @@ +# Retry + +Retry re-executes an operation when a transient failure is expected to clear on a later attempt. Use it for bounded, idempotent calls such as inventory lookups, remote reads, or message handoffs where a temporary `503`, `429`, timeout, or similar signal should not fail the workflow immediately. + +PatternKit provides `RetryPolicy` in `PatternKit.Cloud.Retry`. + +```csharp +var policy = RetryPolicy + .Create("inventory-availability") + .WithMaxAttempts(3) + .WithInitialDelay(TimeSpan.FromMilliseconds(25)) + .WithExponentialBackoff(2) + .HandleResult(static response => response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode >= 500) + .HandleException(static exception => exception is TimeoutException) + .Build(); + +var result = await policy.ExecuteAsync( + ct => inventoryClient.GetAvailabilityAsync("SKU-42", ct), + cancellationToken); +``` + +The policy returns `RetryResult` so callers can inspect success, attempts, final value, and the last handled exception without needing ad hoc assertion or logging code. + +## Production Notes + +- Keep `MaxAttempts` bounded and pair retries with cancellation. +- Retry only idempotent work, or work protected by an idempotency key. +- Use result predicates for service status codes and exception predicates for transient exceptions. +- Prefer zero or injected delay providers in tests; production callers can use real delays and exponential backoff. + +The inventory retry example shows both fluent and source-generated policy creation, plus `IServiceCollection` registration for importing applications. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 6c147d83..d481678a 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -311,6 +311,10 @@ href: messaging/enterprise-generators.md - name: Source-Generated Dispatcher href: messaging/dispatcher.md + - name: Cloud Architecture + items: + - name: Retry + href: cloud/retry.md - name: Type-Dispatcher href: behavioral/type-dispatcher/index.md items: diff --git a/src/PatternKit.Core/Cloud/Retry/RetryPolicy.cs b/src/PatternKit.Core/Cloud/Retry/RetryPolicy.cs new file mode 100644 index 00000000..aea01e31 --- /dev/null +++ b/src/PatternKit.Core/Cloud/Retry/RetryPolicy.cs @@ -0,0 +1,231 @@ +namespace PatternKit.Cloud.Retry; + +/// +/// Outcome returned by a retry policy execution. +/// +public sealed class RetryResult +{ + public RetryResult(TResult? value, bool succeeded, int attempts, Exception? exception) + { + Value = value; + Succeeded = succeeded; + Attempts = attempts; + Exception = exception; + } + + public TResult? Value { get; } + public bool Succeeded { get; } + public int Attempts { get; } + public Exception? Exception { get; } + + public static RetryResult Success(TResult value, int attempts) + => new(value, true, attempts, null); + + public static RetryResult Failure(TResult? value, int attempts, Exception? exception) + => new(value, false, attempts, exception); +} + +/// +/// Context passed to retry delay calculations. +/// +public sealed class RetryDelayContext +{ + public RetryDelayContext(int attempt, TimeSpan previousDelay) + { + Attempt = attempt; + PreviousDelay = previousDelay; + } + + public int Attempt { get; } + public TimeSpan PreviousDelay { get; } +} + +/// +/// Retry policy for transient-failure handling. +/// +public sealed class RetryPolicy +{ + public delegate bool ResultPredicate(TResult result); + public delegate bool ExceptionPredicate(Exception exception); + public delegate TimeSpan DelayFactory(RetryDelayContext context); + + private readonly ResultPredicate _shouldRetryResult; + private readonly ExceptionPredicate _shouldRetryException; + private readonly DelayFactory _nextDelay; + private readonly Func _delay; + + private RetryPolicy( + string name, + int maxAttempts, + TimeSpan initialDelay, + ResultPredicate shouldRetryResult, + ExceptionPredicate shouldRetryException, + DelayFactory nextDelay, + Func delay) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Retry policy name is required.", nameof(name)); + if (maxAttempts < 1) + throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "Retry policy must allow at least one attempt."); + if (initialDelay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(initialDelay), initialDelay, "Initial delay cannot be negative."); + + Name = name; + MaxAttempts = maxAttempts; + InitialDelay = initialDelay; + _shouldRetryResult = shouldRetryResult ?? throw new ArgumentNullException(nameof(shouldRetryResult)); + _shouldRetryException = shouldRetryException ?? throw new ArgumentNullException(nameof(shouldRetryException)); + _nextDelay = nextDelay ?? throw new ArgumentNullException(nameof(nextDelay)); + _delay = delay ?? throw new ArgumentNullException(nameof(delay)); + } + + public string Name { get; } + public int MaxAttempts { get; } + public TimeSpan InitialDelay { get; } + + public static Builder Create(string name = "retry") => new(name); + + public RetryResult Execute(Func operation) + { + if (operation is null) + throw new ArgumentNullException(nameof(operation)); + + TResult? lastValue = default; + Exception? lastException = null; + var delay = InitialDelay; + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + var value = operation(); + lastValue = value; + lastException = null; + + if (!_shouldRetryResult(value)) + return RetryResult.Success(value, attempt); + } + catch (Exception ex) when (_shouldRetryException(ex)) + { + lastException = ex; + } + + if (attempt == MaxAttempts) + break; + + if (delay > TimeSpan.Zero) + Thread.Sleep(delay); + + delay = _nextDelay(new RetryDelayContext(attempt, delay)); + } + + return RetryResult.Failure(lastValue, MaxAttempts, lastException); + } + + public async ValueTask> ExecuteAsync( + Func> operation, + CancellationToken cancellationToken = default) + { + if (operation is null) + throw new ArgumentNullException(nameof(operation)); + + TResult? lastValue = default; + Exception? lastException = null; + var delay = InitialDelay; + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var value = await operation(cancellationToken).ConfigureAwait(false); + lastValue = value; + lastException = null; + + if (!_shouldRetryResult(value)) + return RetryResult.Success(value, attempt); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (_shouldRetryException(ex)) + { + lastException = ex; + } + + if (attempt == MaxAttempts) + break; + + if (delay > TimeSpan.Zero) + await _delay(delay, cancellationToken).ConfigureAwait(false); + + delay = _nextDelay(new RetryDelayContext(attempt, delay)); + } + + return RetryResult.Failure(lastValue, MaxAttempts, lastException); + } + + public sealed class Builder + { + private readonly string _name; + private int _maxAttempts = 3; + private TimeSpan _initialDelay = TimeSpan.Zero; + private double _backoffFactor = 1; + private ResultPredicate _shouldRetryResult = static _ => false; + private ExceptionPredicate _shouldRetryException = static _ => true; + private Func _delay = static (delay, ct) => new(Task.Delay(delay, ct)); + + internal Builder(string name) => _name = name; + + public Builder WithMaxAttempts(int maxAttempts) + { + _maxAttempts = maxAttempts; + return this; + } + + public Builder WithInitialDelay(TimeSpan initialDelay) + { + _initialDelay = initialDelay; + return this; + } + + public Builder WithExponentialBackoff(double factor) + { + if (factor < 1) + throw new ArgumentOutOfRangeException(nameof(factor), factor, "Backoff factor must be at least 1."); + + _backoffFactor = factor; + return this; + } + + public Builder HandleResult(ResultPredicate predicate) + { + _shouldRetryResult = predicate ?? throw new ArgumentNullException(nameof(predicate)); + return this; + } + + public Builder HandleException(ExceptionPredicate predicate) + { + _shouldRetryException = predicate ?? throw new ArgumentNullException(nameof(predicate)); + return this; + } + + public Builder WithDelayProvider(Func delay) + { + _delay = delay ?? throw new ArgumentNullException(nameof(delay)); + return this; + } + + public RetryPolicy Build() + => new( + _name, + _maxAttempts, + _initialDelay, + _shouldRetryResult, + _shouldRetryException, + context => TimeSpan.FromTicks((long)Math.Min(long.MaxValue, context.PreviousDelay.Ticks * _backoffFactor)), + _delay); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 65258120..86ff3edd 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using PatternKit.Behavioral.Interpreter; using PatternKit.Behavioral.Strategy; using PatternKit.Behavioral.TypeDispatcher; +using PatternKit.Cloud.Retry; using PatternKit.Creational.AbstractFactory; using PatternKit.Creational.Prototype; using PatternKit.Creational.Singleton; @@ -26,6 +27,7 @@ using PatternKit.Examples.ProductionReadiness; using PatternKit.Examples.PrototypeDemo; using PatternKit.Examples.ProxyDemo; +using PatternKit.Examples.RetryDemo; using PatternKit.Examples.Singleton; using PatternKit.Examples.SpecificationDemo; using PatternKit.Examples.Strategies.Coercion; @@ -114,6 +116,7 @@ public sealed record ReactiveTransactionExample(ReactiveTransaction Transaction) public sealed record AsyncConnectionStateMachineExample(Func Log)>> RunAsync); public sealed record TemplateMethodSubclassingExample(DataProcessor Processor); public sealed record TemplateMethodAsyncExample(AsyncDataPipeline Pipeline); +public sealed record InventoryRetryExample(RetryPolicy Policy, InventoryLookupService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -160,7 +163,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddReactiveTransactionExample() .AddAsyncConnectionStateMachineExample() .AddTemplateMethodSubclassingExample() - .AddTemplateMethodAsyncExample(); + .AddTemplateMethodAsyncExample() + .AddInventoryRetryExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -527,6 +531,15 @@ public static IServiceCollection AddTemplateMethodAsyncExample(this IServiceColl return services.RegisterExample("Template Method Async", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddInventoryRetryExample(this IServiceCollection services) + { + services.AddInventoryRetryDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Inventory Retry Policy", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 6a0e13cb..c8e82d5f 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -391,7 +391,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/template-method-async-demo.md", ExampleIntegrationSurface.LibraryOnly, ["AsyncTemplate", "AsyncTemplateMethod"], - ["cancellation", "async storage", "error observation"]) + ["cancellation", "async storage", "error observation"]), + Descriptor( + "Inventory Retry Policy", + "src/PatternKit.Examples/RetryDemo/InventoryRetryDemo.cs", + "test/PatternKit.Examples.Tests/RetryDemo/InventoryRetryDemoTests.cs", + "docs/examples/inventory-retry-policy.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Retry"], + ["transient result retry", "source-generated policy factory", "DI composition"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 9232ec74..c172fd94 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -532,6 +532,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs", ["typed publish/subscribe", "generated backplane topology", "transport boundary example"]), + Pattern("Retry", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/retry.md", + "src/PatternKit.Core/Cloud/Retry/RetryPolicy.cs", + "test/PatternKit.Tests/Cloud/Retry/RetryPolicyTests.cs", + "docs/generators/retry.md", + "src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs", + "test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs", + null, + "docs/examples/inventory-retry-policy.md", + "src/PatternKit.Examples/RetryDemo/InventoryRetryDemo.cs", + "test/PatternKit.Examples.Tests/RetryDemo/InventoryRetryDemoTests.cs", + ["fluent retry policy", "generated retry policy", "DI-importable inventory lookup example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Examples/RetryDemo/InventoryRetryDemo.cs b/src/PatternKit.Examples/RetryDemo/InventoryRetryDemo.cs new file mode 100644 index 00000000..7e8754ef --- /dev/null +++ b/src/PatternKit.Examples/RetryDemo/InventoryRetryDemo.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.Retry; +using PatternKit.Generators.Retry; + +namespace PatternKit.Examples.RetryDemo; + +public sealed record InventoryResponse(string Sku, int Available, int StatusCode) +{ + public bool IsAvailable => StatusCode == 200 && Available > 0; +} + +public interface IInventoryClient +{ + ValueTask GetAvailabilityAsync(string sku, CancellationToken cancellationToken = default); +} + +public sealed class ScriptedInventoryClient(params InventoryResponse[] responses) : IInventoryClient +{ + private readonly Queue _responses = new(responses); + + public int Calls { get; private set; } + + public ValueTask GetAvailabilityAsync(string sku, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Calls++; + + if (_responses.Count == 0) + return new(new InventoryResponse(sku, 0, 503)); + + return new(_responses.Dequeue()); + } +} + +public sealed class InventoryLookupService( + IInventoryClient client, + RetryPolicy policy) +{ + public async ValueTask CheckAsync(string sku, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sku); + + var result = await policy.ExecuteAsync( + ct => client.GetAvailabilityAsync(sku, ct), + cancellationToken); + + return new InventoryLookupResult( + sku, + result.Succeeded && result.Value is { IsAvailable: true }, + result.Attempts, + result.Value?.Available ?? 0, + result.Value?.StatusCode ?? 0); + } +} + +public sealed record InventoryLookupResult( + string Sku, + bool Available, + int Attempts, + int AvailableQuantity, + int StatusCode); + +public static partial class InventoryRetryPolicies +{ + public static RetryPolicy CreateFluentPolicy() + => RetryPolicy + .Create("inventory-availability") + .WithMaxAttempts(3) + .WithInitialDelay(TimeSpan.Zero) + .WithExponentialBackoff(2) + .HandleResult(static response => response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode >= 500) + .HandleException(static exception => exception is TimeoutException) + .Build(); +} + +[GenerateRetryPolicy( + typeof(InventoryResponse), + FactoryMethodName = "CreateGeneratedPolicy", + PolicyName = "inventory-availability", + MaxAttempts = 3, + InitialDelayMilliseconds = 0, + BackoffFactor = 2)] +public static partial class GeneratedInventoryRetryPolicy +{ + [RetryResultPredicate] + private static bool ShouldRetry(InventoryResponse response) + => response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode >= 500; + + [RetryExceptionPredicate] + private static bool ShouldRetry(Exception exception) + => exception is TimeoutException; +} + +public static class InventoryRetryDemoServiceCollectionExtensions +{ + public static IServiceCollection AddInventoryRetryDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedInventoryRetryPolicy.CreateGeneratedPolicy()); + services.AddSingleton(static _ => new( + new InventoryResponse("SKU-42", 0, 503), + new InventoryResponse("SKU-42", 12, 200))); + services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Generators.Abstractions/Retry/RetryAttributes.cs b/src/PatternKit.Generators.Abstractions/Retry/RetryAttributes.cs new file mode 100644 index 00000000..91591bd2 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Retry/RetryAttributes.cs @@ -0,0 +1,18 @@ +namespace PatternKit.Generators.Retry; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateRetryPolicyAttribute(Type resultType) : Attribute +{ + public Type ResultType { get; } = resultType ?? throw new ArgumentNullException(nameof(resultType)); + public string FactoryMethodName { get; set; } = "Create"; + public string PolicyName { get; set; } = "retry"; + public int MaxAttempts { get; set; } = 3; + public int InitialDelayMilliseconds { get; set; } + public double BackoffFactor { get; set; } = 1; +} + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class RetryResultPredicateAttribute : Attribute; + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class RetryExceptionPredicateAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 73846591..7d406540 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -201,6 +201,10 @@ PKSPEC001 | PatternKit.Generators.Specification | Error | Specification registry PKSPEC002 | PatternKit.Generators.Specification | Error | Specification registry must declare at least one rule. PKSPEC003 | PatternKit.Generators.Specification | Error | Specification rule signature is invalid. PKSPEC004 | PatternKit.Generators.Specification | Error | Specification rule declaration is duplicated. +PKRET001 | PatternKit.Generators.Retry | Error | Retry policy host must be partial. +PKRET002 | PatternKit.Generators.Retry | Error | Retry policy configuration is invalid. +PKRET003 | PatternKit.Generators.Retry | Error | Retry predicate signature is invalid. +PKRET004 | PatternKit.Generators.Retry | Error | Retry predicate declaration is duplicated. PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be partial. PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. diff --git a/src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs b/src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs new file mode 100644 index 00000000..f71c2c3f --- /dev/null +++ b/src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs @@ -0,0 +1,234 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Retry; + +[Generator] +public sealed class RetryPolicyGenerator : IIncrementalGenerator +{ + private const string GenerateRetryPolicyAttributeName = "PatternKit.Generators.Retry.GenerateRetryPolicyAttribute"; + private const string RetryResultPredicateAttributeName = "PatternKit.Generators.Retry.RetryResultPredicateAttribute"; + private const string RetryExceptionPredicateAttributeName = "PatternKit.Generators.Retry.RetryExceptionPredicateAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKRET001", + "Retry policy host must be partial", + "Type '{0}' is marked with [GenerateRetryPolicy] but is not declared as partial", + "PatternKit.Generators.Retry", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKRET002", + "Retry policy configuration is invalid", + "Retry policy '{0}' must have MaxAttempts >= 1, InitialDelayMilliseconds >= 0, and BackoffFactor >= 1", + "PatternKit.Generators.Retry", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidPredicate = new( + "PKRET003", + "Retry predicate signature is invalid", + "Retry predicate method '{0}' must be static and return bool with a result or exception parameter", + "PatternKit.Generators.Retry", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MultiplePredicates = new( + "PKRET004", + "Retry predicate is duplicated", + "Retry policy '{0}' has multiple {1} predicates", + "PatternKit.Generators.Retry", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateRetryPolicyAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => + a.AttributeClass?.ToDisplayString() == GenerateRetryPolicyAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var resultType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (resultType is null) + return; + + var maxAttempts = GetNamedInt(attribute, "MaxAttempts") ?? 3; + var initialDelayMilliseconds = GetNamedInt(attribute, "InitialDelayMilliseconds") ?? 0; + var backoffFactor = GetNamedDouble(attribute, "BackoffFactor") ?? 1d; + if (maxAttempts < 1 || initialDelayMilliseconds < 0 || backoffFactor < 1) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + var resultPredicates = type.GetMembers().OfType() + .Where(static method => HasAttribute(method, RetryResultPredicateAttributeName)) + .ToArray(); + var exceptionPredicates = type.GetMembers().OfType() + .Where(static method => HasAttribute(method, RetryExceptionPredicateAttributeName)) + .ToArray(); + + if (resultPredicates.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create(MultiplePredicates, resultPredicates[1].Locations.FirstOrDefault(), type.Name, "result")); + return; + } + + if (exceptionPredicates.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create(MultiplePredicates, exceptionPredicates[1].Locations.FirstOrDefault(), type.Name, "exception")); + return; + } + + var resultPredicate = resultPredicates.FirstOrDefault(); + if (resultPredicate is not null && !IsResultPredicate(resultPredicate, resultType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidPredicate, resultPredicate.Locations.FirstOrDefault(), resultPredicate.Name)); + return; + } + + var exceptionPredicate = exceptionPredicates.FirstOrDefault(); + if (exceptionPredicate is not null && !IsExceptionPredicate(exceptionPredicate)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidPredicate, exceptionPredicate.Locations.FirstOrDefault(), exceptionPredicate.Name)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var policyName = GetNamedString(attribute, "PolicyName") ?? "retry"; + context.AddSource($"{type.Name}.RetryPolicy.g.cs", SourceText.From( + GenerateSource(type, resultType, factoryMethodName, policyName, maxAttempts, initialDelayMilliseconds, backoffFactor, resultPredicate, exceptionPredicate), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol resultType, + string factoryMethodName, + string policyName, + int maxAttempts, + int initialDelayMilliseconds, + double backoffFactor, + IMethodSymbol? resultPredicate, + IMethodSymbol? exceptionPredicate) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var resultTypeName = resultType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.Retry.RetryPolicy<").Append(resultTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Cloud.Retry.RetryPolicy<").Append(resultTypeName).Append(">.Create(\"").Append(Escape(policyName)).AppendLine("\")"); + sb.Append(" .WithMaxAttempts(").Append(maxAttempts).AppendLine(")"); + sb.Append(" .WithInitialDelay(global::System.TimeSpan.FromMilliseconds(").Append(initialDelayMilliseconds).AppendLine("))"); + sb.Append(" .WithExponentialBackoff(").Append(backoffFactor.ToString(System.Globalization.CultureInfo.InvariantCulture)).AppendLine(");"); + + if (resultPredicate is not null) + sb.Append(" builder.HandleResult(static result => ").Append(resultPredicate.Name).AppendLine("(result));"); + if (exceptionPredicate is not null) + sb.Append(" builder.HandleException(static exception => ").Append(exceptionPredicate.Name).AppendLine("(exception));"); + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsResultPredicate(IMethodSymbol method, ITypeSymbol resultType) + => method.IsStatic + && method.ReturnType.SpecialType == SpecialType.System_Boolean + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, resultType); + + private static bool IsExceptionPredicate(IMethodSymbol method) + => method.IsStatic + && method.ReturnType.SpecialType == SpecialType.System_Boolean + && method.Parameters.Length == 1 + && InheritsFrom(method.Parameters[0].Type, "System.Exception"); + + private static bool InheritsFrom(ITypeSymbol symbol, string metadataName) + { + for (var current = symbol as INamedTypeSymbol; current is not null; current = current.BaseType) + { + if (current.ToDisplayString() == metadataName) + return true; + } + + return false; + } + + private static bool HasAttribute(IMethodSymbol method, string metadataName) + => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == metadataName); + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static int? GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int?; + + private static double? GetNamedDouble(AttributeData attribute, string name) + { + var value = attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value; + return value is double d ? d : null; + } +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 1a678b2d..6c4fe0c5 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -88,6 +88,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var checkout = provider.GetRequiredService(); var interpreter = provider.GetRequiredService(); var specifications = provider.GetRequiredService(); + var inventoryRetry = provider.GetRequiredService(); auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary())); @@ -148,7 +149,8 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("resilient checkout succeeds", checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded), ("generated interpreter computes tier discounts", interpreter.Pricing.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.TierDiscountRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 100m, CustomerTier = "Gold" }) == 10m), ("generated interpreter evaluates VIP eligibility", interpreter.Eligibility.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.VipEligibilityRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 150m, CustomerTier = "Gold" })), - ("generated specification registry approves prime loans", specifications.Service.Evaluate(PatternKit.Examples.SpecificationDemo.LoanApprovalSpecificationDemo.CreatePrimeApplication()).Approved) + ("generated specification registry approves prime loans", specifications.Service.Evaluate(PatternKit.Examples.SpecificationDemo.LoanApprovalSpecificationDemo.CreatePrimeApplication()).Approved), + ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available) ]; } diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 63ce71c0..54889802 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -51,6 +51,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Outbox", "Request-Reply", "Publish-Subscribe", + "Retry", "CQRS", "Specification" ]; @@ -95,6 +96,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(10, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); + ScenarioExpect.Equal(1, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(2, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/RetryDemo/InventoryRetryDemoTests.cs b/test/PatternKit.Examples.Tests/RetryDemo/InventoryRetryDemoTests.cs new file mode 100644 index 00000000..91c0b3d6 --- /dev/null +++ b/test/PatternKit.Examples.Tests/RetryDemo/InventoryRetryDemoTests.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.RetryDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.RetryDemo; + +[Feature("Inventory retry demo")] +public sealed class InventoryRetryDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated retry policies recover inventory lookups")] + [Fact] + public Task Fluent_And_Generated_Retry_Policies_Recover_Inventory_Lookups() + => Given("a transient inventory outage", static () => new + { + FluentClient = new ScriptedInventoryClient( + new InventoryResponse("SKU-42", 0, 503), + new InventoryResponse("SKU-42", 8, 200)), + GeneratedClient = new ScriptedInventoryClient( + new InventoryResponse("SKU-42", 0, 503), + new InventoryResponse("SKU-42", 8, 200)) + }) + .When("checking availability through both policy paths", ctx => new + { + Fluent = new InventoryLookupService(ctx.FluentClient, InventoryRetryPolicies.CreateFluentPolicy()) + .CheckAsync("SKU-42").GetAwaiter().GetResult(), + Generated = new InventoryLookupService(ctx.GeneratedClient, GeneratedInventoryRetryPolicy.CreateGeneratedPolicy()) + .CheckAsync("SKU-42").GetAwaiter().GetResult(), + ctx.FluentClient.Calls, + GeneratedCalls = ctx.GeneratedClient.Calls + }) + .Then("both paths retry once and return the available stock", result => + { + ScenarioExpect.True(result.Fluent.Available); + ScenarioExpect.True(result.Generated.Available); + ScenarioExpect.Equal(2, result.Fluent.Attempts); + ScenarioExpect.Equal(2, result.Generated.Attempts); + ScenarioExpect.Equal(8, result.Fluent.AvailableQuantity); + ScenarioExpect.Equal(result.Fluent.AvailableQuantity, result.Generated.AvailableQuantity); + }) + .And("both clients were called by the retry policy", result => + { + ScenarioExpect.Equal(2, result.Calls); + ScenarioExpect.Equal(2, result.GeneratedCalls); + }) + .AssertPassed(); + + [Scenario("Inventory retry demo registers with IServiceCollection")] + [Fact] + public Task Inventory_Retry_Demo_Registers_With_IServiceCollection() + => Given("a standard service collection", static () => + { + var services = new ServiceCollection(); + services.AddInventoryRetryDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and using the inventory lookup service", provider => + { + using (provider) + return provider.GetRequiredService().CheckAsync("SKU-42").GetAwaiter().GetResult(); + }) + .Then("the registered service uses the generated retry policy", result => + { + ScenarioExpect.True(result.Available); + ScenarioExpect.Equal(2, result.Attempts); + ScenarioExpect.Equal(200, result.StatusCode); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 89d65681..ff7a25cf 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -14,6 +14,7 @@ using PatternKit.Generators.Observer; using PatternKit.Generators.Prototype; using PatternKit.Generators.Proxy; +using PatternKit.Generators.Retry; using PatternKit.Generators.Singleton; using PatternKit.Generators.Specification; using PatternKit.Generators.State; @@ -119,6 +120,9 @@ private enum TestTrigger { typeof(PrototypeStrategyAttribute), AttributeTargets.Property | AttributeTargets.Field, false, false }, { typeof(GenerateProxyAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, { typeof(ProxyIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, + { typeof(GenerateRetryPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(RetryResultPredicateAttribute), AttributeTargets.Method, false, false }, + { typeof(RetryExceptionPredicateAttribute), AttributeTargets.Method, false, false }, { typeof(SingletonAttribute), AttributeTargets.Class, false, false }, { typeof(SingletonFactoryAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateSpecificationRegistryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -168,6 +172,30 @@ public void Specification_Attributes_Expose_Defaults_And_Validation() ScenarioExpect.Throws(() => new SpecificationRuleAttribute("")); } + [Scenario("Retry Attributes Expose Defaults And Configuration")] + [Fact] + public void Retry_Attributes_Expose_Defaults_And_Configuration() + { + var retry = new GenerateRetryPolicyAttribute(typeof(string)) + { + FactoryMethodName = "BuildInventoryPolicy", + PolicyName = "inventory", + MaxAttempts = 5, + InitialDelayMilliseconds = 25, + BackoffFactor = 2 + }; + + ScenarioExpect.Equal(typeof(string), retry.ResultType); + ScenarioExpect.Equal("BuildInventoryPolicy", retry.FactoryMethodName); + ScenarioExpect.Equal("inventory", retry.PolicyName); + ScenarioExpect.Equal(5, retry.MaxAttempts); + ScenarioExpect.Equal(25, retry.InitialDelayMilliseconds); + ScenarioExpect.Equal(2, retry.BackoffFactor); + ScenarioExpect.Throws(() => new GenerateRetryPolicyAttribute(null!)); + ScenarioExpect.IsType(new RetryResultPredicateAttribute()); + ScenarioExpect.IsType(new RetryExceptionPredicateAttribute()); + } + [Scenario("Interpreter Attributes Expose Defaults And Validation")] [Fact] public void Interpreter_Attributes_Expose_Defaults_And_Validation() diff --git a/test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs b/test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs new file mode 100644 index 00000000..e69e94ed --- /dev/null +++ b/test/PatternKit.Generators.Tests/RetryPolicyGeneratorTests.cs @@ -0,0 +1,230 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Retry; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class RetryPolicyGeneratorTests +{ + [Scenario("Generates retry policy factory")] + [Fact] + public void GeneratesRetryPolicyFactory() + { + var source = """ + using System; + using PatternKit.Generators.Retry; + + namespace Demo; + + [GenerateRetryPolicy(typeof(string), FactoryMethodName = "Build", PolicyName = "inventory", MaxAttempts = 5, InitialDelayMilliseconds = 10, BackoffFactor = 2)] + public static partial class InventoryRetry + { + [RetryResultPredicate] + private static bool RetryResult(string result) => result == "retry"; + + [RetryExceptionPredicate] + private static bool RetryException(Exception exception) => exception is TimeoutException; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesRetryPolicyFactory)); + var gen = new RetryPolicyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Equal("InventoryRetry.RetryPolicy.g.cs", generated.HintName); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("RetryPolicy.Create(\"inventory\")", text); + ScenarioExpect.Contains(".WithMaxAttempts(5)", text); + ScenarioExpect.Contains(".WithInitialDelay(global::System.TimeSpan.FromMilliseconds(10))", text); + ScenarioExpect.Contains(".WithExponentialBackoff(2)", text); + ScenarioExpect.Contains("builder.HandleResult(static result => RetryResult(result));", text); + ScenarioExpect.Contains("builder.HandleException(static exception => RetryException(exception));", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial retry policy host")] + [Fact] + public void ReportsDiagnosticForNonPartialRetryPolicyHost() + { + var source = """ + using PatternKit.Generators.Retry; + + namespace Demo; + + [GenerateRetryPolicy(typeof(string))] + public static class RetryHost; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForNonPartialRetryPolicyHost)); + + ScenarioExpect.Equal("PKRET001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid retry configuration")] + [Fact] + public void ReportsDiagnosticForInvalidRetryConfiguration() + { + var source = """ + using PatternKit.Generators.Retry; + + namespace Demo; + + [GenerateRetryPolicy(typeof(string), MaxAttempts = 0)] + public static partial class RetryHost; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidRetryConfiguration)); + + ScenarioExpect.Equal("PKRET002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid retry predicate")] + [Fact] + public void ReportsDiagnosticForInvalidRetryPredicate() + { + var source = """ + using PatternKit.Generators.Retry; + + namespace Demo; + + [GenerateRetryPolicy(typeof(string))] + public static partial class RetryHost + { + [RetryResultPredicate] + private static string RetryResult(string result) => result; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidRetryPredicate)); + + ScenarioExpect.Equal("PKRET003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for duplicate retry predicates")] + [Fact] + public void ReportsDiagnosticForDuplicateRetryPredicates() + { + var source = """ + using PatternKit.Generators.Retry; + + namespace Demo; + + [GenerateRetryPolicy(typeof(string))] + public static partial class RetryHost + { + [RetryResultPredicate] + private static bool First(string result) => false; + + [RetryResultPredicate] + private static bool Second(string result) => true; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForDuplicateRetryPredicates)); + + ScenarioExpect.Equal("PKRET004", diagnostic.Id); + } + + [Scenario("Generates retry policy factory for global struct host without predicates")] + [Fact] + public void GeneratesRetryPolicyFactoryForGlobalStructHostWithoutPredicates() + { + var source = """ + using PatternKit.Generators.Retry; + + [GenerateRetryPolicy(typeof(int), FactoryMethodName = "CreateNumbers", PolicyName = "numbers")] + internal partial struct RetryHost; + """; + + var comp = CreateCompilation(source, nameof(GeneratesRetryPolicyFactoryForGlobalStructHostWithoutPredicates)); + var gen = new RetryPolicyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("internal partial struct RetryHost", text); + ScenarioExpect.Contains("CreateNumbers()", text); + ScenarioExpect.DoesNotContain("namespace Demo;", text); + ScenarioExpect.DoesNotContain("builder.HandleResult", text); + ScenarioExpect.DoesNotContain("builder.HandleException", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("Reports diagnostic for invalid retry exception predicate")] + [Fact] + public void ReportsDiagnosticForInvalidRetryExceptionPredicate() + { + var source = """ + using PatternKit.Generators.Retry; + + namespace Demo; + + [GenerateRetryPolicy(typeof(string))] + public static partial class RetryHost + { + [RetryExceptionPredicate] + private static bool RetryException(string exception) => true; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidRetryExceptionPredicate)); + + ScenarioExpect.Equal("PKRET003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for duplicate retry exception predicates")] + [Fact] + public void ReportsDiagnosticForDuplicateRetryExceptionPredicates() + { + var source = """ + using System; + using PatternKit.Generators.Retry; + + namespace Demo; + + [GenerateRetryPolicy(typeof(string))] + public static partial class RetryHost + { + [RetryExceptionPredicate] + private static bool First(Exception exception) => false; + + [RetryExceptionPredicate] + private static bool Second(Exception exception) => true; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForDuplicateRetryExceptionPredicates)); + + ScenarioExpect.Equal("PKRET004", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(PatternKit.Cloud.Retry.RetryPolicy<>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(RetryPolicyGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new RetryPolicyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + } +} diff --git a/test/PatternKit.Tests/Cloud/Retry/RetryPolicyTests.cs b/test/PatternKit.Tests/Cloud/Retry/RetryPolicyTests.cs new file mode 100644 index 00000000..8811b36a --- /dev/null +++ b/test/PatternKit.Tests/Cloud/Retry/RetryPolicyTests.cs @@ -0,0 +1,145 @@ +using PatternKit.Cloud.Retry; +using TinyBDD; + +namespace PatternKit.Tests.Cloud.Retry; + +public sealed class RetryPolicyTests +{ + [Scenario("Retry policy succeeds after transient exceptions")] + [Fact] + public void RetryPolicy_Succeeds_After_Transient_Exceptions() + { + var attempts = 0; + var policy = RetryPolicy.Create("inventory") + .WithMaxAttempts(3) + .HandleException(static ex => ex is TimeoutException) + .Build(); + + var result = policy.Execute(() => + { + attempts++; + if (attempts < 3) + throw new TimeoutException(); + return "available"; + }); + + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("available", result.Value); + ScenarioExpect.Equal(3, result.Attempts); + } + + [Scenario("Retry policy exhausts attempts when results remain transient")] + [Fact] + public void RetryPolicy_Exhausts_Attempts_When_Results_Remain_Transient() + { + var attempts = 0; + var policy = RetryPolicy.Create("status") + .WithMaxAttempts(4) + .HandleResult(static status => status == 503) + .Build(); + + var result = policy.Execute(() => + { + attempts++; + return 503; + }); + + ScenarioExpect.False(result.Succeeded); + ScenarioExpect.Equal(503, result.Value); + ScenarioExpect.Equal(4, result.Attempts); + ScenarioExpect.Equal(4, attempts); + } + + [Scenario("Async retry policy applies delay provider and cancellation")] + [Fact] + public async Task AsyncRetryPolicy_Applies_DelayProvider_And_Cancellation() + { + var delays = new List(); + var attempts = 0; + var policy = RetryPolicy.Create("async") + .WithMaxAttempts(3) + .WithInitialDelay(TimeSpan.FromMilliseconds(5)) + .WithExponentialBackoff(2) + .WithDelayProvider((delay, _) => + { + delays.Add(delay); + return default; + }) + .HandleResult(static result => result == "retry") + .Build(); + + var result = await policy.ExecuteAsync(_ => + { + attempts++; + return new ValueTask(attempts < 3 ? "retry" : "ok"); + }); + + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("ok", result.Value); + ScenarioExpect.Equal(3, result.Attempts); + ScenarioExpect.Equal([TimeSpan.FromMilliseconds(5), TimeSpan.FromMilliseconds(10)], delays); + } + + [Scenario("Async retry policy preserves cancellation")] + [Fact] + public async Task AsyncRetryPolicy_Preserves_Cancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var policy = RetryPolicy.Create("cancel").Build(); + + await ScenarioExpect.ThrowsAsync(() => + policy.ExecuteAsync(_ => new ValueTask("never"), cts.Token).AsTask()); + } + + [Scenario("Retry policy returns the last handled exception when exhausted")] + [Fact] + public void RetryPolicy_Returns_Last_Handled_Exception_When_Exhausted() + { + var policy = RetryPolicy.Create("timeouts") + .WithMaxAttempts(2) + .HandleException(static exception => exception is TimeoutException) + .Build(); + + var result = policy.Execute(() => throw new TimeoutException("inventory timeout")); + + ScenarioExpect.False(result.Succeeded); + ScenarioExpect.Equal(2, result.Attempts); + ScenarioExpect.IsType(result.Exception); + ScenarioExpect.Null(result.Value); + } + + [Scenario("Retry policy rethrows unhandled exceptions")] + [Fact] + public void RetryPolicy_Rethrows_Unhandled_Exceptions() + { + var policy = RetryPolicy.Create("fatal") + .HandleException(static exception => exception is TimeoutException) + .Build(); + + ScenarioExpect.Throws(() => policy.Execute(() => throw new InvalidOperationException("fatal"))); + } + + [Scenario("Retry policy rejects null operations")] + [Fact] + public async Task RetryPolicy_Rejects_Null_Operations() + { + var policy = RetryPolicy.Create("nulls").Build(); + + ScenarioExpect.Throws(() => policy.Execute(null!)); + await ScenarioExpect.ThrowsAsync(() => policy.ExecuteAsync(null!).AsTask()); + } + + [Scenario("Retry policy validates configuration")] + [Fact] + public void RetryPolicy_Validates_Configuration() + { + ScenarioExpect.Throws(() => RetryPolicy.Create("").Build()); + ScenarioExpect.Throws(() => RetryPolicy.Create().WithMaxAttempts(0).Build()); + ScenarioExpect.Throws(() => RetryPolicy.Create().WithInitialDelay(TimeSpan.FromMilliseconds(-1)).Build()); + ScenarioExpect.Throws(() => RetryPolicy.Create().WithExponentialBackoff(0.5)); + ScenarioExpect.Throws(() => RetryPolicy.Create().HandleResult(null!)); + ScenarioExpect.Throws(() => RetryPolicy.Create().HandleException(null!)); + ScenarioExpect.Throws(() => RetryPolicy.Create().WithDelayProvider(null!)); + } +}