diff --git a/.well-known/agent-skills/csharpessentials-meta/SKILL.md b/.well-known/agent-skills/csharpessentials-meta/SKILL.md index 8e016d1..3e41e3f 100644 --- a/.well-known/agent-skills/csharpessentials-meta/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-meta/SKILL.md @@ -1,6 +1,6 @@ --- name: csharpessentials-meta -description: Use when deciding which CSharpEssentials package to use — overview of all 19 packages organized by concern, the meta-package that bundles core functional modules, and a quick-reference table mapping problems to packages. +description: Use when deciding which CSharpEssentials package to use — overview of all 20 packages organized by concern, the meta-package that bundles core functional modules, and a quick-reference table mapping problems to packages. --- # CSharpEssentials — Package Index @@ -57,6 +57,12 @@ dotnet add package CSharpEssentials | `CSharpEssentials.RequestResponseLogging` | `dotnet add package CSharpEssentials.RequestResponseLogging` | `csharpessentials-logging` | | `CSharpEssentials.GcpSecretManager` | `dotnet add package CSharpEssentials.GcpSecretManager` | `csharpessentials-gcpsecretmanager` | +### Resilience + +| Package | Install | Skill | +|---------|---------|-------| +| `CSharpEssentials.Resilience` | `dotnet add package CSharpEssentials.Resilience` | `csharpessentials-resilience` | + ### Utilities | Package | Install | Skill | @@ -83,6 +89,7 @@ dotnet add package CSharpEssentials | JSON serialization with string enums + polymorphism | `CSharpEssentials.Json` | | Log request/response bodies | `CSharpEssentials.RequestResponseLogging` | | Load secrets from GCP Secret Manager | `CSharpEssentials.GcpSecretManager` | +| Transient fault handling (retry, timeout, circuit breaker, fallback) | `CSharpEssentials.Resilience` | | Testable time / freeze clock in tests | `CSharpEssentials.Time` | | Deep-copy entity collections | `CSharpEssentials.Clone` | | Fast enum-to-string (NativeAOT-safe) | `CSharpEssentials.Enums` | @@ -107,6 +114,7 @@ using CSharpEssentials.Entity.Interfaces; // IDomainEvent using CSharpEssentials.EntityFrameworkCore; // interceptors, pagination using CSharpEssentials.AspNetCore; // GlobalExceptionHandler, ResultEndpointFilter using CSharpEssentials.Http; // HttpClientResultExtensions, HttpRequestBuilder +using CSharpEssentials.Resilience; // ResiliencePolicy, ResiliencePolicy using CSharpEssentials.Json; // JsonOptions, converters using CSharpEssentials.RequestResponseLogging; // LoggingOptions, SkipLoggingAttributes using CSharpEssentials.GcpSecretManager; // AddGcpSecretManager() diff --git a/.well-known/agent-skills/csharpessentials-resilience/SKILL.md b/.well-known/agent-skills/csharpessentials-resilience/SKILL.md new file mode 100644 index 0000000..7ffc341 --- /dev/null +++ b/.well-known/agent-skills/csharpessentials-resilience/SKILL.md @@ -0,0 +1,123 @@ +--- +name: csharpessentials-resilience +description: Use when adding transient fault handling around Result-based operations — ResiliencePolicy/ResiliencePolicy for retry, timeout, circuit breaker, and fallback composition backed by Polly v8 with Result-aware retry filtering. +--- + +# CSharpEssentials.Resilience + +HTTP-agnostic resilience patterns for `Result` and `Result`. Compose retry, timeout, circuit breaker, and fallback without leaking Polly types into application code. + +## Installation + +```bash +dotnet add package CSharpEssentials.Resilience +``` + +## Namespace + +```csharp +using CSharpEssentials.Resilience; +``` + +--- + +## When to Use / When NOT to Use + +| Scenario | Use this package? | +|----------|-------------------| +| Transient fault handling (retry, timeout, circuit breaker) | ✅ Yes | +| Composing resilience policies around `Result` pipelines | ✅ Yes | +| Fallback values after retries are exhausted | ✅ Yes | +| HTTP-specific resilience (redirects, status code mapping) | ❌ No — use `CSharpEssentials.Http` | +| Non-Result exception-only retry logic | ⚠️ Consider Polly directly for simpler scenarios | + +--- + +## Key Types + +| Type | Description | +|------|-------------| +| `ResiliencePolicy` | Non-generic policy for `Result` (unit) operations | +| `ResiliencePolicy` | Generic policy for `Result` operations; supports fallback | +| `ResiliencePolicyOptions` | Options record combining Retry, Timeout, CircuitBreaker | +| `RetryOptions` | MaxAttempts, Delay, ExponentialBackoff | +| `TimeoutOptions` | Timeout duration | +| `CircuitBreakerOptions` | MinimumThroughput, SamplingDuration, BreakDuration, FailureRatio | + +--- + +## Builder Pattern + +```csharp +Result user = await ResiliencePolicy + .Create() + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromSeconds(1)) + .WithTimeout(TimeSpan.FromSeconds(5)) + .ExecuteAsync(ct => _db.GetUser(id, ct)); + +ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 3) + .WithCircuitBreaker(minimumThroughput: 10); + +Result result = await policy.ExecuteAsync(ct => _api.GetValue(ct)); +``` + +--- + +## Delegate Extensions + +```csharp +// Direct execution — wraps any Func> in a Result +Result user = await (() => _db.GetUser(id)).ExecuteAsync(); + +// RetryIfFailed — retries on transient Result failures +Func>> operation = ct => _db.GetUser(id, ct); +Result retried = await operation.RetryIfFailed(maxAttempts: 3); +``` + +--- + +## Result-Aware Retry Filtering + +`ResiliencePolicy` retries exceptions and failed `Result` values, but skips these non-transient error types: + +- `ErrorType.Unauthorized` +- `ErrorType.Forbidden` +- `ErrorType.NotFound` +- `ErrorType.Validation` + +`Conflict` and unexpected failures are retried. + +--- + +## Error Codes + +| Code | When | +|---|---| +| `Resilience.Timeout` | Operation exceeded the configured timeout | +| `Resilience.CircuitBroken` | Circuit breaker is open | + +Timeout and circuit-breaker failures are returned as failed `Result` values rather than rethrown. + +--- + +## Fallback + +Fallback is available on `ResiliencePolicy` and runs after prior strategies in the pipeline have been exhausted. + +```csharp +Result product = await ResiliencePolicy + .Create() + .WithRetry(maxAttempts: 3) + .WithFallback(ct => _cache.GetAsync(id, ct)) + .ExecuteAsync(ct => _catalog.GetProduct(id, ct)); +``` + +--- + +## Best Practices + +- Use `ResiliencePolicy` for operations that already return `Result`; do not wrap `Result` again. +- Always pass the `CancellationToken` through the callback so Polly timeout and cancellation behave correctly. +- Keep Polly namespaces internal to the package boundary; expose `ResiliencePolicy` from application and library code. +- Prefer the HTTP package only for HTTP-specific helpers; general retry/timeout logic belongs in `CSharpEssentials.Resilience`. diff --git a/.well-known/agent-skills/index.json b/.well-known/agent-skills/index.json index f878877..3a8d851 100644 --- a/.well-known/agent-skills/index.json +++ b/.well-known/agent-skills/index.json @@ -80,6 +80,11 @@ "description": "Use when handling operation outcomes without exceptions — Result and Result for success/failure, railway-oriented chaining with Then/ThenAsync/Ensure, Match for consumption, and Result.And/Or for combining multiple results.", "files": ["SKILL.md"] }, + { + "name": "csharpessentials-resilience", + "description": "Use when adding transient fault handling around Result-based operations — ResiliencePolicy/ResiliencePolicy for retry, timeout, circuit breaker, and fallback composition backed by Polly v8 with Result-aware retry filtering.", + "files": ["SKILL.md"] + }, { "name": "csharpessentials-rules", "description": "Use when composing business validation logic — define rules as classes, Func fields, or inline lambdas; combine with .And()/.Or()/.Linear()/.Next(); evaluate with RuleEngine.Evaluate(); branch with RuleEngine.If().", diff --git a/AGENTS.md b/AGENTS.md index 9bae92d..7b1ae8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## What -CSharpEssentials is a modular .NET NuGet ecosystem (19 packages) that bridges OOP and Functional Programming in C#. Core patterns: Result/Maybe monads, Discriminated Unions (Any), composable Rules engine, DDD base classes (EntityBase), EF Core interceptors/pagination, and ASP.NET Core utilities. Multi-targets: .NET 9/8, netstandard2.1/2.0. Current version: 3.0.0. +CSharpEssentials is a modular .NET NuGet ecosystem (20 packages) that bridges OOP and Functional Programming in C#. Core patterns: Result/Maybe monads, Discriminated Unions (Any), composable Rules engine, DDD base classes (EntityBase), EF Core interceptors/pagination, and ASP.NET Core utilities. Multi-targets: .NET 9/8, netstandard2.1/2.0. Current version: 3.0.0. ## Why diff --git a/CSharpEssentials.Http/CSharpEssentials.Http.csproj b/CSharpEssentials.Http/CSharpEssentials.Http.csproj index 7ace6bd..a939348 100644 --- a/CSharpEssentials.Http/CSharpEssentials.Http.csproj +++ b/CSharpEssentials.Http/CSharpEssentials.Http.csproj @@ -33,7 +33,8 @@ - + + diff --git a/CSharpEssentials.Http/HttpClientResilienceExtensions.cs b/CSharpEssentials.Http/HttpClientResilienceExtensions.cs index 83cf46f..e467bf0 100644 --- a/CSharpEssentials.Http/HttpClientResilienceExtensions.cs +++ b/CSharpEssentials.Http/HttpClientResilienceExtensions.cs @@ -1,156 +1,56 @@ -using CSharpEssentials.Errors; +using CSharpEssentials.Resilience; using CSharpEssentials.ResultPattern; using Polly; -using Polly.CircuitBreaker; -using Polly.Retry; -using Polly.Timeout; namespace CSharpEssentials.Http; public static class HttpClientResilienceExtensions { + // ────────────────────────────────────────────────────── + // Legacy API — returns Polly ResiliencePipeline (preserved for backward compatibility) + // ────────────────────────────────────────────────────── + public static ResiliencePipeline CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) - { - TimeSpan baseDelay = delay ?? TimeSpan.FromSeconds(1); - - return new ResiliencePipelineBuilder() - .AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = maxRetryAttempts, - Delay = baseDelay, - BackoffType = DelayBackoffType.Exponential, - ShouldHandle = new PredicateBuilder() - .Handle() - .Handle() - .Handle() - .Handle(ex => !ex.CancellationToken.IsCancellationRequested) - }) - .Build(); - } + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay).ToPipeline(); public static ResiliencePipeline> CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) - { - TimeSpan baseDelay = delay ?? TimeSpan.FromSeconds(1); - - return new ResiliencePipelineBuilder>() - .AddRetry(new RetryStrategyOptions> - { - MaxRetryAttempts = maxRetryAttempts, - Delay = baseDelay, - BackoffType = DelayBackoffType.Exponential, - ShouldHandle = new PredicateBuilder>() - .HandleResult(IsRetryable) - .Handle() - .Handle() - .Handle() - .Handle(ex => !ex.CancellationToken.IsCancellationRequested) - }) - .Build(); - } + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay).ToPipeline(); public static ResiliencePipeline CreateTimeoutPipeline(TimeSpan timeout) - { - return new ResiliencePipelineBuilder() - .AddTimeout(new TimeoutStrategyOptions - { - Timeout = timeout - }) - .Build(); - } + => ResiliencePolicy.Create().WithTimeout(timeout).ToPipeline(); public static ResiliencePipeline CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) - { - return new ResiliencePipelineBuilder() - .AddCircuitBreaker(new CircuitBreakerStrategyOptions - { - FailureRatio = 0.5, - MinimumThroughput = minimumThroughput, - SamplingDuration = samplingDuration ?? TimeSpan.FromMinutes(1), - BreakDuration = breakDuration ?? TimeSpan.FromSeconds(30), - ShouldHandle = new PredicateBuilder() - .Handle() - .Handle() - .Handle() - .Handle(ex => !ex.CancellationToken.IsCancellationRequested) - }) - .Build(); - } + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration).ToPipeline(); public static ResiliencePipeline> CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) - { - return new ResiliencePipelineBuilder>() - .AddCircuitBreaker(new CircuitBreakerStrategyOptions> - { - FailureRatio = 0.5, - MinimumThroughput = minimumThroughput, - SamplingDuration = samplingDuration ?? TimeSpan.FromMinutes(1), - BreakDuration = breakDuration ?? TimeSpan.FromSeconds(30), - ShouldHandle = new PredicateBuilder>() - .HandleResult(IsRetryable) - .Handle() - .Handle() - .Handle() - .Handle(ex => !ex.CancellationToken.IsCancellationRequested) - }) - .Build(); - } + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration).ToPipeline(); public static ResiliencePipeline CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) { TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); - TimeSpan effectiveDelay = retryDelay ?? TimeSpan.FromSeconds(1); - - return new ResiliencePipelineBuilder() - .AddTimeout(new TimeoutStrategyOptions - { - Timeout = effectiveTimeout - }) - .AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = maxRetryAttempts, - Delay = effectiveDelay, - BackoffType = DelayBackoffType.Exponential, - ShouldHandle = new PredicateBuilder() - .Handle() - .Handle() - .Handle() - .Handle(ex => !ex.CancellationToken.IsCancellationRequested) - }) - .Build(); + + return ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay) + .WithTimeout(effectiveTimeout) + .ToPipeline(); } public static ResiliencePipeline> CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) { TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); - TimeSpan effectiveDelay = retryDelay ?? TimeSpan.FromSeconds(1); - - return new ResiliencePipelineBuilder>() - .AddTimeout(new TimeoutStrategyOptions - { - Timeout = effectiveTimeout - }) - .AddRetry(new RetryStrategyOptions> - { - MaxRetryAttempts = maxRetryAttempts, - Delay = effectiveDelay, - BackoffType = DelayBackoffType.Exponential, - ShouldHandle = new PredicateBuilder>() - .HandleResult(r => IsRetryable(r)) - .Handle() - .Handle() - .Handle() - .Handle(ex => !ex.CancellationToken.IsCancellationRequested) - }) - .Build(); + + return ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay) + .WithTimeout(effectiveTimeout) + .ToPipeline(); } - public static async Task ExecuteAsResultAsync( + public static Task ExecuteAsResultAsync( this ResiliencePipeline pipeline, Func> callback, CancellationToken cancellationToken = default) { - return await ExecuteResilienceAsync(async () => - await pipeline.ExecuteAsync(async token => await callback(token), cancellationToken)); + return ResiliencePolicy.FromPipeline(pipeline).ExecuteAsync(callback, cancellationToken); } public static Task> ExecuteAsResultAsync( @@ -158,10 +58,7 @@ public static Task> ExecuteAsResultAsync( Func>> callback, CancellationToken cancellationToken = default) { - return Result.TryAsync( - () => pipeline.ExecuteAsync(async token => await callback(token), cancellationToken).AsTask(), - HandleException, - cancellationToken); + return ResiliencePolicy.FromPipeline(pipeline).ExecuteAsync(callback, cancellationToken); } public static Task> ExecuteAsResultAsync( @@ -169,48 +66,43 @@ public static Task> ExecuteAsResultAsync( Func>> callback, CancellationToken cancellationToken = default) { - return Result.TryAsync( - () => pipeline.ExecuteAsync(async token => await callback(token), cancellationToken).AsTask(), - HandleException, - cancellationToken); + return ResiliencePolicy.FromPipeline(pipeline).ExecuteAsync(callback, cancellationToken); } - private static bool IsRetryable(Result result) - { - if (result.IsSuccess) - return false; - - ErrorType type = result.FirstError.Type; - return type is not ErrorType.Unauthorized - and not ErrorType.Forbidden - and not ErrorType.NotFound - and not ErrorType.Validation; - } + // ────────────────────────────────────────────────────── + // New API — returns ResiliencePolicy / ResiliencePolicy + // ────────────────────────────────────────────────────── - private static Error HandleException(Exception ex) - { - if (ex is OperationCanceledException oce && oce.CancellationToken.IsCancellationRequested) - throw new OperationCanceledException(oce.Message, oce, oce.CancellationToken); + public static ResiliencePolicy CreateRetryPolicy(int maxRetryAttempts = 3, TimeSpan? delay = null) + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay); + + public static ResiliencePolicy CreateRetryPolicy(int maxRetryAttempts = 3, TimeSpan? delay = null) + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay); + + public static ResiliencePolicy CreateTimeoutPolicy(TimeSpan timeout) + => ResiliencePolicy.Create().WithTimeout(timeout); - if (ex is BrokenCircuitException) - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex).Throw(); + public static ResiliencePolicy CreateCircuitBreakerPolicy(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration); - return Error.Exception(ex, ErrorType.Unexpected); + public static ResiliencePolicy CreateCircuitBreakerPolicy(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration); + + public static ResiliencePolicy CreateResiliencePolicy(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) + { + TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); + + return ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay) + .WithTimeout(effectiveTimeout); } - private static Task ExecuteResilienceAsync(Func> action) + public static ResiliencePolicy CreateResiliencePolicy(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) { - return Result.TryAsync( - action, - ex => - { - if (ex is OperationCanceledException oce && oce.CancellationToken.IsCancellationRequested) - throw new OperationCanceledException(oce.Message, oce, oce.CancellationToken); - - if (ex is BrokenCircuitException) - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex).Throw(); - - return Error.Exception(ex, ErrorType.Unexpected); - }); + TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); + + return ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay) + .WithTimeout(effectiveTimeout); } } diff --git a/CSharpEssentials.Resilience/CSharpEssentials.Resilience.csproj b/CSharpEssentials.Resilience/CSharpEssentials.Resilience.csproj new file mode 100644 index 0000000..590314e --- /dev/null +++ b/CSharpEssentials.Resilience/CSharpEssentials.Resilience.csproj @@ -0,0 +1,32 @@ + + + + net11.0;net10.0;net9.0;netstandard2.1 + CSharpEssentials.Resilience + CSharp Essentials Resilience + CSharp Essentials Resilience + HTTP-agnostic resilience patterns (Retry, Timeout, Circuit Breaker, Fallback) with Result<T> integration. + Composable ResiliencePolicy builder backed by Polly v8 for transient fault handling without exceptions. + Resilience,Retry,Timeout,CircuitBreaker,Fallback,Polly,Result,FunctionalProgramming,CSharpEssentials + icon.png + Readme.MD + true + true + portable + + + + + + + + + + + + + + + + + diff --git a/CSharpEssentials.Resilience/Extensions/ResilienceFuncExtensions.cs b/CSharpEssentials.Resilience/Extensions/ResilienceFuncExtensions.cs new file mode 100644 index 0000000..4c9b281 --- /dev/null +++ b/CSharpEssentials.Resilience/Extensions/ResilienceFuncExtensions.cs @@ -0,0 +1,54 @@ +using CSharpEssentials.ResultPattern; + +namespace CSharpEssentials.Resilience; + +public static class ResilienceFuncExtensions +{ + public static async Task ExecuteAsync( + this Func func, + CancellationToken cancellationToken = default) + { + return await ResiliencePolicy.Create() + .ExecuteAsync(async _ => await func(), cancellationToken); + } + + public static async Task> ExecuteAsync( + this Func> func, + CancellationToken cancellationToken = default) + { + return await ResiliencePolicy.Create() + .ExecuteAsync(async _ => await func(), cancellationToken); + } + + public static async Task> ExecuteAsync( + this Func>> func, + CancellationToken cancellationToken = default) + { + return await ResiliencePolicy.Create() + .ExecuteAsync(async _ => await func(), cancellationToken); + } + + public static async Task ExecuteAsync( + this Func func, + CancellationToken cancellationToken = default) + { + return await ResiliencePolicy.Create() + .ExecuteAsync(func, cancellationToken); + } + + public static async Task> ExecuteAsync( + this Func> func, + CancellationToken cancellationToken = default) + { + return await ResiliencePolicy.Create() + .ExecuteAsync(func, cancellationToken); + } + + public static async Task> ExecuteAsync( + this Func>> func, + CancellationToken cancellationToken = default) + { + return await ResiliencePolicy.Create() + .ExecuteAsync(func, cancellationToken); + } +} diff --git a/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs b/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs new file mode 100644 index 0000000..b343c9e --- /dev/null +++ b/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs @@ -0,0 +1,103 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using Polly; +using Polly.Retry; + +namespace CSharpEssentials.Resilience; + +public static class ResilienceResultExtensions +{ + public static async ValueTask> RetryIfFailed( + this Func>> operation, + int maxAttempts = 3, + TimeSpan? delay = null, + bool exponentialBackoff = true, + CancellationToken cancellationToken = default) + { + TimeSpan effectiveDelay = delay ?? TimeSpan.FromSeconds(1); + + ResiliencePipeline> pipeline = new ResiliencePipelineBuilder>() + .AddRetry(new RetryStrategyOptions> + { + MaxRetryAttempts = maxAttempts, + Delay = effectiveDelay, + BackoffType = exponentialBackoff ? DelayBackoffType.Exponential : DelayBackoffType.Constant, + ShouldHandle = new PredicateBuilder>() + .HandleResult(r => IsRetryable(r)) + .Handle() + }) + .Build(); + + try + { + return await pipeline.ExecuteAsync( + async token => await operation(token), + cancellationToken); + } + catch (Exception ex) + { + return Error.Exception(ex, ErrorType.Unexpected); + } + } + + public static async ValueTask RetryIfFailed( + this Func> operation, + int maxAttempts = 3, + TimeSpan? delay = null, + bool exponentialBackoff = true, + CancellationToken cancellationToken = default) + { + TimeSpan effectiveDelay = delay ?? TimeSpan.FromSeconds(1); + + ResiliencePipeline pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = maxAttempts, + Delay = effectiveDelay, + BackoffType = exponentialBackoff ? DelayBackoffType.Exponential : DelayBackoffType.Constant, + ShouldHandle = new PredicateBuilder() + .HandleResult(r => IsRetryable(r)) + .Handle() + }) + .Build(); + + try + { + return await pipeline.ExecuteAsync( + async token => await operation(token), + cancellationToken); + } + catch (Exception ex) + { + return Error.Exception(ex, ErrorType.Unexpected); + } + } + + private static bool IsRetryable(Result result) + { + if (result.IsSuccess) + { + return false; + } + + ErrorType type = result.FirstError.Type; + return type is not ErrorType.Unauthorized + and not ErrorType.Forbidden + and not ErrorType.NotFound + and not ErrorType.Validation; + } + + private static bool IsRetryable(Result result) + { + if (result.IsSuccess) + { + return false; + } + + ErrorType type = result.FirstError.Type; + return type is not ErrorType.Unauthorized + and not ErrorType.Forbidden + and not ErrorType.NotFound + and not ErrorType.Validation; + } +} diff --git a/CSharpEssentials.Resilience/Modules/ResiliencePolicy.CircuitBreaker.cs b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.CircuitBreaker.cs new file mode 100644 index 0000000..10aca89 --- /dev/null +++ b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.CircuitBreaker.cs @@ -0,0 +1,85 @@ +using CSharpEssentials.ResultPattern; +using Polly; +using Polly.CircuitBreaker; + +namespace CSharpEssentials.Resilience; + +public readonly partial struct ResiliencePolicy +{ + public ResiliencePolicy WithCircuitBreaker( + int minimumThroughput = 10, + TimeSpan? samplingDuration = null, + TimeSpan? breakDuration = null, + double failureRatio = 0.5) + { + ResiliencePipeline pipeline = new ResiliencePipelineBuilder() + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatio = failureRatio, + MinimumThroughput = minimumThroughput, + SamplingDuration = samplingDuration ?? TimeSpan.FromMinutes(1), + BreakDuration = breakDuration ?? TimeSpan.FromSeconds(30), + ShouldHandle = new PredicateBuilder().Handle() + }) + .Build(); + + return Merge(pipeline); + } + + public ResiliencePolicy WithCircuitBreaker(CircuitBreakerOptions options) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + throw new ArgumentNullException(nameof(options)); +#endif + + return WithCircuitBreaker( + options.MinimumThroughput, + options.SamplingDuration, + options.BreakDuration, + options.FailureRatio); + } +} + +public readonly partial struct ResiliencePolicy +{ + public ResiliencePolicy WithCircuitBreaker( + int minimumThroughput = 10, + TimeSpan? samplingDuration = null, + TimeSpan? breakDuration = null, + double failureRatio = 0.5) + { + ResiliencePipeline> pipeline = new ResiliencePipelineBuilder>() + .AddCircuitBreaker(new CircuitBreakerStrategyOptions> + { + FailureRatio = failureRatio, + MinimumThroughput = minimumThroughput, + SamplingDuration = samplingDuration ?? TimeSpan.FromMinutes(1), + BreakDuration = breakDuration ?? TimeSpan.FromSeconds(30), + ShouldHandle = new PredicateBuilder>() + .HandleResult(IsRetryable) + .Handle() + }) + .Build(); + + return Merge(pipeline); + } + + public ResiliencePolicy WithCircuitBreaker(CircuitBreakerOptions options) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + throw new ArgumentNullException(nameof(options)); +#endif + + return WithCircuitBreaker( + options.MinimumThroughput, + options.SamplingDuration, + options.BreakDuration, + options.FailureRatio); + } +} diff --git a/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs new file mode 100644 index 0000000..2cf0a1b --- /dev/null +++ b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs @@ -0,0 +1,85 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using Polly; +using Polly.Fallback; + +namespace CSharpEssentials.Resilience; + +public readonly partial struct ResiliencePolicy +{ + public ResiliencePolicy WithFallback(Func> fallbackAsync) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(fallbackAsync); +#else + if (fallbackAsync is null) + throw new ArgumentNullException(nameof(fallbackAsync)); +#endif + + ResiliencePipeline> pipeline = new ResiliencePipelineBuilder>() + .AddFallback(new FallbackStrategyOptions> + { + ShouldHandle = new PredicateBuilder>() + .HandleResult(static result => result.IsFailure) + .Handle(), + FallbackAction = async args => + { + try + { + T fallbackValue = await fallbackAsync(args.Context.CancellationToken); + return Outcome.FromResult(Result.Success(fallbackValue)); + } + catch (Exception ex) + { + return Outcome.FromResult(Result.Failure(Error.Exception(ex))); + } + } + }) + .Build(); + + ResiliencePipeline> merged = new ResiliencePipelineBuilder>() + .AddPipeline(pipeline) + .AddPipeline(_pipeline) + .Build(); + + return new(merged); + } + + public ResiliencePolicy WithFallback(Func>> fallbackAsync) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(fallbackAsync); +#else + if (fallbackAsync is null) + throw new ArgumentNullException(nameof(fallbackAsync)); +#endif + + ResiliencePipeline> pipeline = new ResiliencePipelineBuilder>() + .AddFallback(new FallbackStrategyOptions> + { + ShouldHandle = new PredicateBuilder>() + .HandleResult(static result => result.IsFailure) + .Handle(), + FallbackAction = async args => + { + try + { + Result fallbackResult = await fallbackAsync(args.Context.CancellationToken); + return Outcome.FromResult(fallbackResult); + } + catch (Exception ex) + { + return Outcome.FromResult(Result.Failure(Error.Exception(ex))); + } + } + }) + .Build(); + + ResiliencePipeline> merged = new ResiliencePipelineBuilder>() + .AddPipeline(pipeline) + .AddPipeline(_pipeline) + .Build(); + + return new(merged); + } +} diff --git a/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Retry.cs b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Retry.cs new file mode 100644 index 0000000..2671bea --- /dev/null +++ b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Retry.cs @@ -0,0 +1,71 @@ +using CSharpEssentials.ResultPattern; +using Polly; +using Polly.Retry; + +namespace CSharpEssentials.Resilience; + +public readonly partial struct ResiliencePolicy +{ + public ResiliencePolicy WithRetry(int maxAttempts = 3, TimeSpan? delay = null, bool exponentialBackoff = true) + { + TimeSpan effectiveDelay = delay ?? TimeSpan.FromSeconds(1); + + ResiliencePipeline pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = maxAttempts, + Delay = effectiveDelay, + BackoffType = exponentialBackoff ? DelayBackoffType.Exponential : DelayBackoffType.Constant, + ShouldHandle = new PredicateBuilder().Handle() + }) + .Build(); + + return Merge(pipeline); + } + + public ResiliencePolicy WithRetry(RetryOptions options) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + throw new ArgumentNullException(nameof(options)); +#endif + + return WithRetry(options.MaxAttempts, options.Delay, options.ExponentialBackoff); + } +} + +public readonly partial struct ResiliencePolicy +{ + public ResiliencePolicy WithRetry(int maxAttempts = 3, TimeSpan? delay = null, bool exponentialBackoff = true) + { + TimeSpan effectiveDelay = delay ?? TimeSpan.FromSeconds(1); + + ResiliencePipeline> pipeline = new ResiliencePipelineBuilder>() + .AddRetry(new RetryStrategyOptions> + { + MaxRetryAttempts = maxAttempts, + Delay = effectiveDelay, + BackoffType = exponentialBackoff ? DelayBackoffType.Exponential : DelayBackoffType.Constant, + ShouldHandle = new PredicateBuilder>() + .HandleResult(IsRetryable) + .Handle() + }) + .Build(); + + return Merge(pipeline); + } + + public ResiliencePolicy WithRetry(RetryOptions options) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + throw new ArgumentNullException(nameof(options)); +#endif + + return WithRetry(options.MaxAttempts, options.Delay, options.ExponentialBackoff); + } +} diff --git a/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Timeout.cs b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Timeout.cs new file mode 100644 index 0000000..4e633dd --- /dev/null +++ b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Timeout.cs @@ -0,0 +1,59 @@ +using CSharpEssentials.ResultPattern; +using Polly; +using Polly.Timeout; + +namespace CSharpEssentials.Resilience; + +public readonly partial struct ResiliencePolicy +{ + public ResiliencePolicy WithTimeout(TimeSpan timeout) + { + ResiliencePipeline pipeline = new ResiliencePipelineBuilder() + .AddTimeout(new TimeoutStrategyOptions + { + Timeout = timeout + }) + .Build(); + + return Merge(pipeline); + } + + public ResiliencePolicy WithTimeout(TimeoutOptions options) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + throw new ArgumentNullException(nameof(options)); +#endif + + return WithTimeout(options.Timeout); + } +} + +public readonly partial struct ResiliencePolicy +{ + public ResiliencePolicy WithTimeout(TimeSpan timeout) + { + ResiliencePipeline> pipeline = new ResiliencePipelineBuilder>() + .AddTimeout(new TimeoutStrategyOptions + { + Timeout = timeout + }) + .Build(); + + return Merge(pipeline); + } + + public ResiliencePolicy WithTimeout(TimeoutOptions options) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) + throw new ArgumentNullException(nameof(options)); +#endif + + return WithTimeout(options.Timeout); + } +} diff --git a/CSharpEssentials.Resilience/Readme.MD b/CSharpEssentials.Resilience/Readme.MD new file mode 100644 index 0000000..f9277af --- /dev/null +++ b/CSharpEssentials.Resilience/Readme.MD @@ -0,0 +1,118 @@ +# CSharpEssentials.Resilience + +HTTP-agnostic resilience patterns (Retry, Timeout, Circuit Breaker, Fallback) with `Result` integration. Composable `ResiliencePolicy` builder backed by Polly v8 for transient fault handling without exceptions. + +## Installation + +```bash +dotnet add package CSharpEssentials.Resilience +``` + +## Quick Start + +```csharp +using CSharpEssentials.Resilience; + +// Simple retry +Result user = await ResiliencePolicy + .Create() + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromSeconds(1)) + .ExecuteAsync(_ => _db.GetUser(id)); + +// Retry + Timeout +Result order = await ResiliencePolicy + .Create() + .WithRetry(3) + .WithTimeout(TimeSpan.FromSeconds(5)) + .ExecuteAsync(_ => _orderService.GetOrder(id)); + +// Circuit Breaker + Fallback +Result product = await ResiliencePolicy + .Create() + .WithCircuitBreaker(minimumThroughput: 10, failureRatio: 0.5) + .WithFallback(ct => _cache.GetAsync(id, ct)) + .ExecuteAsync(_ => _productService.GetProduct(id)); +``` + +## Delegate Extensions + +```csharp +using CSharpEssentials.Resilience; + +// Direct execution — wraps any Func> in a Result +Result user = await (() => _db.GetUser(id)).ExecuteAsync(); + +// With CancellationToken +Result user = await ((ct) => _db.GetUser(id, ct)).ExecuteAsync(cancellationToken); +``` + +## Result Retry + +```csharp +// Retry a failed Result directly +Result result = await _db.GetUser(id) + .RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromSeconds(1)); +``` + +## Configuration Options + +```csharp +using CSharpEssentials.Resilience; + +// Use options records +var options = new ResiliencePolicyOptions +{ + Retry = new RetryOptions { MaxAttempts = 3, Delay = TimeSpan.FromSeconds(1) }, + Timeout = new TimeoutOptions { Timeout = TimeSpan.FromSeconds(5) }, + CircuitBreaker = new CircuitBreakerOptions + { + MinimumThroughput = 10, + FailureRatio = 0.5, + BreakDuration = TimeSpan.FromSeconds(30) + } +}; + +Result user = await ResiliencePolicy + .Create(options) + .ExecuteAsync(_ => _db.GetUser(id)); +``` + +## Error Handling + +Resilience-specific errors are returned as `Error` values: + +- `Error.Failure("Resilience.Timeout", "Operation timed out.")` +- `Error.Failure("Resilience.CircuitBroken", "Circuit breaker is open.")` + +When retries exhaust, the last exception is returned as `ErrorType.Unexpected`. + +## Pipeline Composition + +```csharp +// Stack multiple policies +Result result = await ResiliencePolicy + .Create() + .WithRetry(3) + .WithTimeout(TimeSpan.FromSeconds(5)) + .WithCircuitBreaker(minimumThroughput: 10) + .ExecuteAsync(_ => _api.GetData()); +``` + +## Polly Integration + +Under the hood, uses Polly v8 `ResiliencePipeline`: + +```csharp +// Advanced: Direct Polly configuration +Result result = await ResiliencePolicy + .Create(builder => + { + builder.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential + }); + }) + .ExecuteAsync(_ => _api.GetData()); +``` diff --git a/CSharpEssentials.Resilience/ResilienceOptions.cs b/CSharpEssentials.Resilience/ResilienceOptions.cs new file mode 100644 index 0000000..4866b94 --- /dev/null +++ b/CSharpEssentials.Resilience/ResilienceOptions.cs @@ -0,0 +1,28 @@ +namespace CSharpEssentials.Resilience; + +public sealed record RetryOptions +{ + public int MaxAttempts { get; init; } = 3; + public TimeSpan Delay { get; init; } = TimeSpan.FromSeconds(1); + public bool ExponentialBackoff { get; init; } = true; +} + +public sealed record TimeoutOptions +{ + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30); +} + +public sealed record CircuitBreakerOptions +{ + public int MinimumThroughput { get; init; } = 10; + public TimeSpan SamplingDuration { get; init; } = TimeSpan.FromMinutes(1); + public TimeSpan BreakDuration { get; init; } = TimeSpan.FromSeconds(30); + public double FailureRatio { get; init; } = 0.5; +} + +public sealed record ResiliencePolicyOptions +{ + public RetryOptions? Retry { get; init; } + public TimeoutOptions? Timeout { get; init; } + public CircuitBreakerOptions? CircuitBreaker { get; init; } +} diff --git a/CSharpEssentials.Resilience/ResiliencePolicy.cs b/CSharpEssentials.Resilience/ResiliencePolicy.cs new file mode 100644 index 0000000..dce28b7 --- /dev/null +++ b/CSharpEssentials.Resilience/ResiliencePolicy.cs @@ -0,0 +1,180 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using Polly; +using Polly.CircuitBreaker; +using Polly.Timeout; + +namespace CSharpEssentials.Resilience; + +public readonly partial struct ResiliencePolicy +{ + private readonly ResiliencePipeline _pipeline; + + private ResiliencePolicy(ResiliencePipeline pipeline) => + _pipeline = pipeline; + + public static ResiliencePolicy Create() => + new(ResiliencePipeline.Empty); + + public static ResiliencePolicy FromPipeline(ResiliencePipeline pipeline) => + new(pipeline); + + public static ResiliencePolicy Create(ResiliencePolicyOptions options) + { + ResiliencePolicy policy = Create(); + + if (options.Retry is not null) + { + policy = policy.WithRetry(options.Retry.MaxAttempts, options.Retry.Delay, options.Retry.ExponentialBackoff); + } + + if (options.CircuitBreaker is not null) + { + policy = policy.WithCircuitBreaker( + options.CircuitBreaker.MinimumThroughput, + options.CircuitBreaker.SamplingDuration, + options.CircuitBreaker.BreakDuration, + options.CircuitBreaker.FailureRatio); + } + + if (options.Timeout is not null) + { + policy = policy.WithTimeout(options.Timeout.Timeout); + } + + return policy; + } + + public static ResiliencePolicy Create(Action configure) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(configure); +#else + if (configure is null) + throw new ArgumentNullException(nameof(configure)); +#endif + + ResiliencePipelineBuilder builder = new(); + configure(builder); + return new(builder.Build()); + } + + public ResiliencePipeline ToPipeline() => + _pipeline; + + public async Task ExecuteAsync( + Func action, + CancellationToken cancellationToken = default) + { + ResiliencePipeline pipeline = _pipeline ?? ResiliencePipeline.Empty; + try + { + await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); + return Result.Success(); + } + catch (Exception ex) + { + return HandleException(ex); + } + } + + public async Task> ExecuteAsync( + Func> action, + CancellationToken cancellationToken = default) + { + ResiliencePipeline pipeline = _pipeline ?? ResiliencePipeline.Empty; + try + { + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); + } + catch (Exception ex) + { + return HandleException(ex); + } + } + + public async Task> ExecuteAsync( + Func>> action, + CancellationToken cancellationToken = default) + { + ResiliencePipeline pipeline = _pipeline ?? ResiliencePipeline.Empty; + try + { + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); + } + catch (Exception ex) + { + return HandleException(ex); + } + } + + public async Task ExecuteAsync( + Func> action, + CancellationToken cancellationToken = default) + { + ResiliencePipeline pipeline = _pipeline ?? ResiliencePipeline.Empty; + try + { + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); + } + catch (Exception ex) + { + return HandleException(ex); + } + } + + private static Result HandleException(Exception ex) + { + if (ex is OperationCanceledException oce && oce.CancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(oce.Message, oce, oce.CancellationToken); + } + + if (ex is BrokenCircuitException) + { + return Error.Failure("Resilience.CircuitBroken", "Circuit breaker is open."); + } + + if (ex is TimeoutRejectedException) + { + return Error.Failure("Resilience.Timeout", "Operation timed out."); + } + + return Error.Exception(ex, ErrorType.Unexpected); + } + + private static Result HandleException(Exception ex) + { + if (ex is OperationCanceledException oce && oce.CancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(oce.Message, oce, oce.CancellationToken); + } + + if (ex is BrokenCircuitException) + { + return Error.Failure("Resilience.CircuitBroken", "Circuit breaker is open."); + } + + if (ex is TimeoutRejectedException) + { + return Error.Failure("Resilience.Timeout", "Operation timed out."); + } + + return Error.Exception(ex, ErrorType.Unexpected); + } + + private ResiliencePolicy Merge(ResiliencePipeline additionalPipeline) + { + if (_pipeline == ResiliencePipeline.Empty) + { + return new(additionalPipeline); + } + + ResiliencePipeline merged = new ResiliencePipelineBuilder() + .AddPipeline(_pipeline) + .AddPipeline(additionalPipeline) + .Build(); + + return new(merged); + } +} diff --git a/CSharpEssentials.Resilience/ResiliencePolicyT.cs b/CSharpEssentials.Resilience/ResiliencePolicyT.cs new file mode 100644 index 0000000..cdcb544 --- /dev/null +++ b/CSharpEssentials.Resilience/ResiliencePolicyT.cs @@ -0,0 +1,118 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using Polly; +using Polly.CircuitBreaker; +using Polly.Timeout; + +namespace CSharpEssentials.Resilience; + +public readonly partial struct ResiliencePolicy +{ + private readonly ResiliencePipeline> _pipeline; + + private ResiliencePolicy(ResiliencePipeline> pipeline) => + _pipeline = pipeline; + + public static ResiliencePolicy Create() => + new(new ResiliencePipelineBuilder>().Build()); + + public static ResiliencePolicy FromPipeline(ResiliencePipeline> pipeline) => + new(pipeline); + + public static ResiliencePolicy Create(Action>> configure) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(configure); +#else + if (configure is null) + throw new ArgumentNullException(nameof(configure)); +#endif + + ResiliencePipelineBuilder> builder = new(); + configure(builder); + return new(builder.Build()); + } + + public ResiliencePipeline> ToPipeline() => + _pipeline; + + public async Task> ExecuteAsync( + Func> action, + CancellationToken cancellationToken = default) + { + ResiliencePipeline> pipeline = _pipeline ?? new ResiliencePipelineBuilder>().Build(); + try + { + return await pipeline.ExecuteAsync( + async token => + { + T value = await action(token); + return Result.Success(value); + }, + cancellationToken); + } + catch (Exception ex) + { + return HandleException(ex); + } + } + + public async Task> ExecuteAsync( + Func>> action, + CancellationToken cancellationToken = default) + { + ResiliencePipeline> pipeline = _pipeline ?? new ResiliencePipelineBuilder>().Build(); + try + { + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); + } + catch (Exception ex) + { + return HandleException(ex); + } + } + + private static Result HandleException(Exception ex) + { + if (ex is OperationCanceledException oce && oce.CancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(oce.Message, oce, oce.CancellationToken); + } + + if (ex is BrokenCircuitException) + { + return Error.Failure("Resilience.CircuitBroken", "Circuit breaker is open."); + } + + if (ex is TimeoutRejectedException) + { + return Error.Failure("Resilience.Timeout", "Operation timed out."); + } + + return Error.Exception(ex, ErrorType.Unexpected); + } + + private ResiliencePolicy Merge(ResiliencePipeline> additionalPipeline) + { + ResiliencePipeline> merged = new ResiliencePipelineBuilder>() + .AddPipeline(_pipeline) + .AddPipeline(additionalPipeline) + .Build(); + + return new(merged); + } + + private static bool IsRetryable(Result result) + { + if (result.IsSuccess) + { + return false; + } + + ErrorType type = result.FirstError.Type; + return type is not ErrorType.Unauthorized + and not ErrorType.Forbidden + and not ErrorType.NotFound + and not ErrorType.Validation; + } +} diff --git a/CSharpEssentials.Tests/CSharpEssentials.Tests.csproj b/CSharpEssentials.Tests/CSharpEssentials.Tests.csproj index 0df3c6c..4e6c225 100644 --- a/CSharpEssentials.Tests/CSharpEssentials.Tests.csproj +++ b/CSharpEssentials.Tests/CSharpEssentials.Tests.csproj @@ -39,6 +39,7 @@ Include="..\CSharpEssentials.GcpSecretManager\CSharpEssentials.GcpSecretManager.csproj" /> + diff --git a/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs b/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs index 4f235f9..eaf5320 100644 --- a/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs +++ b/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs @@ -1,42 +1,41 @@ using CSharpEssentials.Errors; using CSharpEssentials.Http; +using CSharpEssentials.Resilience; using CSharpEssentials.ResultPattern; using FluentAssertions; -using Polly; -using Polly.CircuitBreaker; namespace CSharpEssentials.Tests.Http; public class HttpClientResilienceExtensionsTests { [Fact] - public async Task ExecuteAsResultAsync_Should_Return_Success_When_No_Failure() + public async Task CreateRetryPolicy_Should_Return_Success() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 1); - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success())); + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); result.IsSuccess.Should().BeTrue(); } [Fact] - public async Task ExecuteAsResultAsync_Generic_Should_Return_Value() + public async Task CreateRetryPolicy_Generic_Should_Return_Value() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 1); - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(42.ToResult())); + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success(42))); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(42); } [Fact] - public async Task ExecuteAsResultAsync_Should_Retry_On_HttpRequestException() + public async Task CreateRetryPolicy_Should_Retry_On_Exception() { int attempts = 0; - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; if (attempts < 2) @@ -49,38 +48,28 @@ public async Task ExecuteAsResultAsync_Should_Retry_On_HttpRequestException() } [Fact] - public async Task ExecuteAsResultAsync_Should_Return_Failure_On_Persistent_Exception() + public async Task CreateRetryPolicy_Should_Return_Failure_On_Persistent_Exception() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); - Result result = await pipeline.ExecuteAsResultAsync(_ => throw new HttpRequestException("Persistent failure")); + Result result = await policy.ExecuteAsync(_ => throw new HttpRequestException("Persistent failure")); result.IsFailure.Should().BeTrue(); result.Errors[0].Type.Should().Be(ErrorType.Unexpected); } [Fact] - public async Task CreateTimeoutPipeline_Should_Throw_On_Timeout() - { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateTimeoutPipeline(TimeSpan.FromSeconds(1)); - - Func act = async () => await pipeline.ExecuteAsync(async token => await Task.Delay(TimeSpan.FromSeconds(5), token)); - - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task ExecuteAsResultAsync_With_GenericPipeline_Should_Return_Value() + public async Task CreateRetryPolicy_Generic_Should_Retry_On_Failure() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; if (attempts < 2) return Task.FromResult(Result.Failure(Error.Unexpected())); - return Task.FromResult(99.ToResult()); + return Task.FromResult(Result.Success(99)); }); result.IsSuccess.Should().BeTrue(); @@ -89,15 +78,15 @@ public async Task ExecuteAsResultAsync_With_GenericPipeline_Should_Return_Value( } [Fact] - public async Task CreateRetryPipeline_Generic_Should_Not_Retry_On_Success() + public async Task CreateRetryPolicy_Generic_Should_Not_Retry_On_Success() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; - return Task.FromResult(42.ToResult()); + return Task.FromResult(Result.Success(42)); }); result.IsSuccess.Should().BeTrue(); @@ -106,14 +95,31 @@ public async Task CreateRetryPipeline_Generic_Should_Not_Retry_On_Success() } [Fact] - public async Task CreateResiliencePipeline_Generic_Should_Retry_And_Timeout() + public async Task CreateResiliencePolicy_Should_Apply_Default_Timeout() + { + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateResiliencePolicy( + maxRetryAttempts: 1, + timeout: TimeSpan.FromSeconds(1)); + + Result result = await policy.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return Result.Success(); + }); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.Timeout"); + } + + [Fact] + public async Task CreateResiliencePolicy_Generic_Should_Retry_And_Timeout() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateResiliencePipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateResiliencePolicy( maxRetryAttempts: 1, timeout: TimeSpan.FromSeconds(1), retryDelay: TimeSpan.FromMilliseconds(10)); - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(7.ToResult())); + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success(7))); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(7); @@ -124,14 +130,14 @@ public async Task CreateResiliencePipeline_Generic_Should_Retry_And_Timeout() [InlineData(ErrorType.Forbidden)] [InlineData(ErrorType.NotFound)] [InlineData(ErrorType.Validation)] - public async Task CreateRetryPipeline_Generic_Should_Not_Retry_NonRetryable_Errors(ErrorType errorType) + public async Task CreateRetryPolicy_Generic_Should_Not_Retry_NonRetryable_Errors(ErrorType errorType) { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy( maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; return Task.FromResult(Result.Failure(CreateError(errorType))); @@ -142,19 +148,19 @@ public async Task CreateRetryPipeline_Generic_Should_Not_Retry_NonRetryable_Erro } [Fact] - public async Task CreateRetryPipeline_Generic_Should_Retry_On_Conflict() + public async Task CreateRetryPolicy_Generic_Should_Retry_On_Conflict() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy( maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; if (attempts < 2) return Task.FromResult(Result.Failure(Error.Conflict())); - return Task.FromResult(42.ToResult()); + return Task.FromResult(Result.Success(42)); }); result.IsSuccess.Should().BeTrue(); @@ -163,47 +169,40 @@ public async Task CreateRetryPipeline_Generic_Should_Retry_On_Conflict() } [Fact] - public void CreateCircuitBreakerPipeline_Should_Have_Sensible_Defaults() + public void CreateCircuitBreakerPolicy_Should_Have_Sensible_Defaults() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline(minimumThroughput: 5); + ResiliencePolicy pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPolicy(minimumThroughput: 5); pipeline.Should().NotBeNull(); } + // ── Legacy API tests ── + [Fact] - public async Task CreateCircuitBreakerPipeline_NonGeneric_Should_Open_On_Exceptions() + public async Task Legacy_CreateRetryPipeline_Should_Return_Success() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline( - minimumThroughput: 3, - samplingDuration: TimeSpan.FromSeconds(1), - breakDuration: TimeSpan.FromSeconds(1)); + Polly.ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); - for (int i = 0; i < 3; i++) - { - Result result = await pipeline.ExecuteAsResultAsync(_ => throw new HttpRequestException("failure")); - result.IsFailure.Should().BeTrue(); - } + Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success())); - Func act = async () => await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success())); - await act.Should().ThrowAsync(); + result.IsSuccess.Should().BeTrue(); } [Fact] - public async Task CreateCircuitBreakerPipeline_Generic_Should_Open_On_Failures() + public async Task Legacy_CreateResiliencePipeline_Should_Apply_Default_Timeout() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline( - minimumThroughput: 3, - samplingDuration: TimeSpan.FromSeconds(1), - breakDuration: TimeSpan.FromSeconds(1)); + Polly.ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateResiliencePipeline( + maxRetryAttempts: 1, + timeout: TimeSpan.FromSeconds(1)); - for (int i = 0; i < 3; i++) + Result result = await pipeline.ExecuteAsResultAsync(async ct => { - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Failure(Error.Unexpected()))); - result.IsFailure.Should().BeTrue(); - } + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return Result.Success(); + }); - Func act = async () => await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(1.ToResult())); - await act.Should().ThrowAsync(); + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.Timeout"); } private static Error CreateError(ErrorType type) => type switch @@ -214,6 +213,4 @@ public async Task CreateCircuitBreakerPipeline_Generic_Should_Open_On_Failures() ErrorType.Validation => Error.Validation(), _ => Error.Failure() }; - - } diff --git a/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs b/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs new file mode 100644 index 0000000..79a6c28 --- /dev/null +++ b/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs @@ -0,0 +1,142 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.Resilience; +using CSharpEssentials.ResultPattern; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Resilience; + +public class ResilienceFuncExtensionsTests +{ + [Fact] + public async Task ExecuteAsync_Should_Return_Success() + { + Func func = () => Task.CompletedTask; + Result result = await func.ExecuteAsync(); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Generic_Should_Return_Value() + { + Func> func = () => Task.FromResult(42); + Result result = await func.ExecuteAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + [Fact] + public async Task ExecuteAsync_With_CancellationToken_Should_Work() + { + Func func = _ => Task.CompletedTask; + Result result = await func.ExecuteAsync(); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Generic_With_CancellationToken_Should_Work() + { + Func> func = _ => Task.FromResult(42); + Result result = await func.ExecuteAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + [Fact] + public async Task ExecuteAsync_Result_Generic_With_CancellationToken_Should_Work() + { + Func>> func = _ => Task.FromResult(Result.Success(42)); + Result result = await func.ExecuteAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_Exception_In_Func_Task() + { + Func func = () => throw new InvalidOperationException("boom"); + Result result = await func.ExecuteAsync(); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_Exception_In_Func_Task_T() + { + Func> func = () => throw new InvalidOperationException("boom"); + Result result = await func.ExecuteAsync(); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_Exception_In_Func_Task_Result_T() + { + Func>> func = () => throw new InvalidOperationException("boom"); + Result result = await func.ExecuteAsync(); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_Exception_In_Func_CT_Task() + { + Func func = _ => throw new InvalidOperationException("boom"); + Result result = await func.ExecuteAsync(); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_Exception_In_Func_CT_Task_T() + { + Func> func = _ => throw new InvalidOperationException("boom"); + Result result = await func.ExecuteAsync(); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_Exception_In_Func_CT_Task_Result_T() + { + Func>> func = _ => throw new InvalidOperationException("boom"); + Result result = await func.ExecuteAsync(); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_OperationCanceledException() + { + using CancellationTokenSource cts = new(); + + Func func = async ct => + { + await Task.Delay(100, ct); + }; + + Result result = await func.ExecuteAsync(cts.Token); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Generic_Should_Handle_OperationCanceledException() + { + using CancellationTokenSource cts = new(); + + Func> func = async ct => + { + await Task.Delay(100, ct); + return 42; + }; + + Result result = await func.ExecuteAsync(cts.Token); + + result.IsSuccess.Should().BeTrue(); + } +} diff --git a/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs b/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs new file mode 100644 index 0000000..06abcff --- /dev/null +++ b/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs @@ -0,0 +1,660 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.Resilience; +using CSharpEssentials.ResultPattern; +using FluentAssertions; +using Polly; +using Polly.Retry; + +namespace CSharpEssentials.Tests.Resilience; + +public class ResiliencePolicyTests +{ + [Fact] + public async Task Create_Should_Return_Success_When_No_Failure() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Create_Generic_Should_Return_Value() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(42)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + [Fact] + public async Task WithRetry_Should_Retry_On_Exception() + { + int attempts = 0; + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + if (attempts < 2) + throw new InvalidOperationException("Transient failure"); + return Task.FromResult(Result.Success()); + }); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(2); + } + + [Fact] + public async Task WithRetry_Should_Return_Failure_On_Persistent_Exception() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(_ => throw new InvalidOperationException("Persistent failure")); + + result.IsFailure.Should().BeTrue(); + result.Errors[0].Type.Should().Be(ErrorType.Unexpected); + } + + [Fact] + public async Task WithTimeout_Should_Return_Timeout_Error() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithTimeout(TimeSpan.FromSeconds(1)); + + Result result = await policy.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return Result.Success(); + }); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.Timeout"); + } + + [Fact] + public async Task WithCircuitBreaker_Should_Open_On_Failures() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithCircuitBreaker( + minimumThroughput: 3, + samplingDuration: TimeSpan.FromSeconds(1), + breakDuration: TimeSpan.FromSeconds(1)); + + for (int i = 0; i < 3; i++) + { + Result result = await policy.ExecuteAsync(_ => throw new InvalidOperationException("failure")); + result.IsFailure.Should().BeTrue(); + } + + Result resultAfterOpen = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); + resultAfterOpen.IsFailure.Should().BeTrue(); + resultAfterOpen.FirstError.Code.Should().Be("Resilience.CircuitBroken"); + } + + [Fact] + public async Task Generic_WithRetry_Should_Retry_On_Failure() + { + int attempts = 0; + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success(42)); + }); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(2); + } + + [Fact] + public async Task Generic_WithRetry_Should_Not_Retry_On_Success() + { + int attempts = 0; + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + return Task.FromResult(Result.Success(42)); + }); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(1); + } + + [Theory] + [InlineData(ErrorType.Unauthorized)] + [InlineData(ErrorType.Forbidden)] + [InlineData(ErrorType.NotFound)] + [InlineData(ErrorType.Validation)] + public async Task Generic_WithRetry_Should_Not_Retry_NonRetryable_Errors(ErrorType errorType) + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + int attempts = 0; + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + return Task.FromResult(Result.Failure(CreateError(errorType))); + }); + + result.IsFailure.Should().BeTrue(); + attempts.Should().Be(1); + } + + [Fact] + public async Task Generic_WithRetry_Should_Retry_On_Conflict() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + int attempts = 0; + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Conflict())); + return Task.FromResult(Result.Success(42)); + }); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(2); + } + + [Fact] + public async Task Combined_Pipeline_Should_Retry_And_Timeout() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 1, delay: TimeSpan.FromMilliseconds(10)) + .WithTimeout(TimeSpan.FromSeconds(1)); + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Generic_WithFallback_Should_Return_Fallback_After_Retries_Are_Exhausted() + { + int attempts = 0; + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)) + .WithFallback(_ => Task.FromResult(99)); + + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + return Task.FromResult(Result.Failure(Error.Unexpected())); + }); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(99); + attempts.Should().Be(3); + } + + [Fact] + public void Create_WithResiliencePolicyOptions_Should_Apply_Retry() + { + ResiliencePolicyOptions options = new() + { + Retry = new RetryOptions { MaxAttempts = 2, Delay = TimeSpan.FromMilliseconds(10), ExponentialBackoff = false } + }; + + ResiliencePolicy policy = ResiliencePolicy.Create(options); + + policy.ToPipeline().Should().NotBeNull(); + } + + [Fact] + public void Create_WithResiliencePolicyOptions_Should_Apply_CircuitBreaker() + { + ResiliencePolicyOptions options = new() + { + CircuitBreaker = new CircuitBreakerOptions + { + MinimumThroughput = 5, + SamplingDuration = TimeSpan.FromSeconds(1), + BreakDuration = TimeSpan.FromSeconds(1), + FailureRatio = 0.5 + } + }; + + ResiliencePolicy policy = ResiliencePolicy.Create(options); + + policy.ToPipeline().Should().NotBeNull(); + } + + [Fact] + public void Create_WithResiliencePolicyOptions_Should_Apply_Timeout() + { + ResiliencePolicyOptions options = new() + { + Timeout = new TimeoutOptions { Timeout = TimeSpan.FromSeconds(5) } + }; + + ResiliencePolicy policy = ResiliencePolicy.Create(options); + + policy.ToPipeline().Should().NotBeNull(); + } + + [Fact] + public void Create_WithBuilder_Should_Allow_Custom_Configuration() + { + ResiliencePolicy policy = ResiliencePolicy.Create(builder => + { + builder.AddTimeout(new Polly.Timeout.TimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(5) + }); + }); + + policy.ToPipeline().Should().NotBeNull(); + } + + [Fact] + public void Create_WithBuilder_Null_Should_Throw() + { + Action act = () => ResiliencePolicy.Create((Action)null!); + + act.Should().Throw(); + } + + [Fact] + public void ToPipeline_Should_Return_NonNull_Pipeline() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + var pipeline = policy.ToPipeline(); + + pipeline.Should().NotBeNull(); + } + + [Fact] + public async Task ExecuteAsync_NonGeneric_Result_Should_Return_Success() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_NonGeneric_Result_Should_Return_Failure_On_Exception() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(_ => throw new InvalidOperationException("test")); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_BrokenCircuitException_For_NonGeneric() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithCircuitBreaker( + minimumThroughput: 2, + samplingDuration: TimeSpan.FromSeconds(1), + breakDuration: TimeSpan.FromSeconds(10)); + + for (int i = 0; i < 2; i++) + { + await policy.ExecuteAsync(_ => throw new InvalidOperationException("fail")); + } + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.CircuitBroken"); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_TimeoutRejectedException_For_NonGeneric() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithTimeout(TimeSpan.FromSeconds(1)); + + Result result = await policy.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return Result.Success(); + }); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.Timeout"); + } + + [Fact] + public async Task ExecuteAsync_Generic_T_Should_Handle_BrokenCircuitException() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithCircuitBreaker( + minimumThroughput: 2, + samplingDuration: TimeSpan.FromSeconds(1), + breakDuration: TimeSpan.FromSeconds(10)); + + for (int i = 0; i < 2; i++) + { + await policy.ExecuteAsync(_ => throw new InvalidOperationException("fail")); + } + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success(42))); + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.CircuitBroken"); + } + + [Fact] + public async Task ExecuteAsync_Generic_T_Should_Handle_TimeoutRejectedException() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithTimeout(TimeSpan.FromSeconds(1)); + + Result result = await policy.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return 42; + }); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.Timeout"); + } + + [Fact] + public async Task ExecuteAsync_Generic_T_Should_Handle_Unexpected_Exception() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(_ => throw new InvalidOperationException("boom")); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Unexpected); + } + + [Fact] + public async Task ExecuteAsync_Should_Handle_OperationCanceledException() + { + using CancellationTokenSource cts = new(); + + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(async ct => + { + await Task.Delay(100, ct); + return Result.Success(); + }, cts.Token); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_Generic_Should_Handle_OperationCanceledException() + { + using CancellationTokenSource cts = new(); + + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + + Result result = await policy.ExecuteAsync(async ct => + { + await Task.Delay(100, ct); + return 42; + }, cts.Token); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Generic_WithRetry_Options_Should_Apply_Retry() + { + int attempts = 0; + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(new RetryOptions { MaxAttempts = 2, Delay = TimeSpan.FromMilliseconds(10), ExponentialBackoff = false }); + + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success(42)); + }); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(2); + } + + [Fact] + public void Generic_WithRetry_Options_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithRetry((RetryOptions)null!); + + act.Should().Throw(); + } + + [Fact] + public async Task Generic_WithTimeout_Options_Should_Apply_Timeout() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithTimeout(new TimeoutOptions { Timeout = TimeSpan.FromSeconds(1) }); + + Result result = await policy.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return 42; + }); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.Timeout"); + } + + [Fact] + public void Generic_WithTimeout_Options_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithTimeout((TimeoutOptions)null!); + + act.Should().Throw(); + } + + [Fact] + public async Task Generic_WithCircuitBreaker_Options_Should_Open_On_Failures() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithCircuitBreaker(new CircuitBreakerOptions + { + MinimumThroughput = 2, + SamplingDuration = TimeSpan.FromSeconds(1), + BreakDuration = TimeSpan.FromSeconds(10), + FailureRatio = 0.5 + }); + + for (int i = 0; i < 2; i++) + { + await policy.ExecuteAsync(_ => throw new InvalidOperationException("fail")); + } + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success(42))); + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.CircuitBroken"); + } + + [Fact] + public void Generic_WithCircuitBreaker_Options_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithCircuitBreaker((CircuitBreakerOptions)null!); + + act.Should().Throw(); + } + + [Fact] + public void NonGeneric_WithRetry_Options_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithRetry((RetryOptions)null!); + + act.Should().Throw(); + } + + [Fact] + public void NonGeneric_WithTimeout_Options_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithTimeout((TimeoutOptions)null!); + + act.Should().Throw(); + } + + [Fact] + public void NonGeneric_WithCircuitBreaker_Options_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithCircuitBreaker((CircuitBreakerOptions)null!); + + act.Should().Throw(); + } + + [Fact] + public void Generic_WithFallback_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithFallback((Func>)null!); + + act.Should().Throw(); + } + + [Fact] + public void Generic_WithFallback_Result_Null_Should_Throw() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + Action act = () => policy.WithFallback((Func>>)null!); + + act.Should().Throw(); + } + + [Fact] + public async Task Generic_WithFallback_Result_Should_Return_Fallback_After_Failure() + { + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithFallback(_ => Task.FromResult(Result.Success(99))); + + Result result = await policy.ExecuteAsync(_ => + Task.FromResult(Result.Failure(Error.Unexpected()))); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(99); + } + + [Fact] + public void Generic_Create_Null_Should_Throw() + { + Action act = () => ResiliencePolicy.Create(null!); + + act.Should().Throw(); + } + + [Fact] + public void Generic_ToPipeline_Should_Return_NonNull() + { + ResiliencePolicy policy = ResiliencePolicy.Create(); + + var pipeline = policy.ToPipeline(); + + pipeline.Should().NotBeNull(); + } + + [Fact] + public async Task Generic_WithRetry_Constant_Backoff_Should_Work() + { + int attempts = 0; + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10), exponentialBackoff: false); + + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success(42)); + }); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(2); + } + + [Fact] + public async Task NonGeneric_WithRetry_Constant_Backoff_Should_Work() + { + int attempts = 0; + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10), exponentialBackoff: false); + + Result result = await policy.ExecuteAsync(_ => + { + attempts++; + if (attempts < 2) + throw new InvalidOperationException("fail"); + return Task.FromResult(Result.Success()); + }); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(2); + } + + [Fact] + public async Task Create_WithAllOptions_Should_Apply_All_Policies() + { + ResiliencePolicyOptions options = new() + { + Retry = new RetryOptions { MaxAttempts = 2, Delay = TimeSpan.FromMilliseconds(10) }, + CircuitBreaker = new CircuitBreakerOptions + { + MinimumThroughput = 10, + SamplingDuration = TimeSpan.FromSeconds(1), + BreakDuration = TimeSpan.FromSeconds(1), + FailureRatio = 0.5 + }, + Timeout = new TimeoutOptions { Timeout = TimeSpan.FromSeconds(5) } + }; + + ResiliencePolicy policy = ResiliencePolicy.Create(options); + + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); + result.IsSuccess.Should().BeTrue(); + } + + private static Error CreateError(ErrorType type) => type switch + { + ErrorType.Unauthorized => Error.Unauthorized(), + ErrorType.Forbidden => Error.Forbidden(), + ErrorType.NotFound => Error.NotFound(), + ErrorType.Validation => Error.Validation(), + _ => Error.Failure() + }; +} diff --git a/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs b/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs new file mode 100644 index 0000000..5df92dc --- /dev/null +++ b/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs @@ -0,0 +1,286 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.Resilience; +using CSharpEssentials.ResultPattern; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Resilience; + +public class ResilienceResultExtensionsTests +{ + [Fact] + public async Task RetryIfFailed_Should_Return_Success() + { + Func>> operation = _ => Task.FromResult(Result.Success(42)); + + Result result = await operation.RetryIfFailed(maxAttempts: 3); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + } + + [Fact] + public async Task RetryIfFailed_Should_Retry_On_Transient_Failure() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + if (attempts < 3) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success(42)); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(3); + } + + [Fact] + public async Task RetryIfFailed_Should_Return_Failure_When_All_Retries_Failed() + { + Func>> operation = _ => + Task.FromResult(Result.Failure(Error.Unexpected())); + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task RetryIfFailed_NonGeneric_Should_Return_Success() + { + Func> operation = _ => Task.FromResult(Result.Success()); + + Result result = await operation.RetryIfFailed(maxAttempts: 3); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task RetryIfFailed_NonGeneric_Should_Retry_On_Transient_Failure() + { + int attempts = 0; + Func> operation = _ => + { + attempts++; + if (attempts < 3) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success()); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(3); + } + + [Fact] + public async Task RetryIfFailed_NonGeneric_Should_Return_Failure_When_All_Retries_Failed() + { + Func> operation = _ => + Task.FromResult(Result.Failure(Error.Unexpected())); + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task RetryIfFailed_Should_Not_Retry_On_Unauthorized() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + return Task.FromResult(Result.Failure(Error.Unauthorized())); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Unauthorized); + attempts.Should().Be(1); + } + + [Fact] + public async Task RetryIfFailed_Should_Not_Retry_On_Forbidden() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + return Task.FromResult(Result.Failure(Error.Forbidden())); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Forbidden); + attempts.Should().Be(1); + } + + [Fact] + public async Task RetryIfFailed_Should_Not_Retry_On_NotFound() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + return Task.FromResult(Result.Failure(Error.NotFound())); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.NotFound); + attempts.Should().Be(1); + } + + [Fact] + public async Task RetryIfFailed_Should_Not_Retry_On_Validation() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + return Task.FromResult(Result.Failure(Error.Validation())); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Validation); + attempts.Should().Be(1); + } + + [Fact] + public async Task RetryIfFailed_Should_Retry_On_Conflict() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Conflict())); + return Task.FromResult(Result.Success(42)); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(2); + } + + [Fact] + public async Task RetryIfFailed_Should_Use_Constant_Backoff() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success(42)); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10), exponentialBackoff: false); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(2); + } + + [Fact] + public async Task RetryIfFailed_NonGeneric_Should_Use_Constant_Backoff() + { + int attempts = 0; + Func> operation = _ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success()); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10), exponentialBackoff: false); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(2); + } + + [Fact] + public async Task RetryIfFailed_NonGeneric_Should_Retry_On_Conflict() + { + int attempts = 0; + Func> operation = _ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Conflict())); + return Task.FromResult(Result.Success()); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(2); + } + + [Fact] + public async Task RetryIfFailed_NonGeneric_Should_Not_Retry_On_Unauthorized() + { + int attempts = 0; + Func> operation = _ => + { + attempts++; + return Task.FromResult(Result.Failure(Error.Unauthorized())); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Unauthorized); + attempts.Should().Be(1); + } + + [Fact] + public async Task RetryIfFailed_Should_Retry_On_Failure_Type() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Failure())); + return Task.FromResult(Result.Success(42)); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(2); + } + + [Fact] + public async Task RetryIfFailed_Should_Retry_On_Unexpected_Type() + { + int attempts = 0; + Func>> operation = _ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Unexpected())); + return Task.FromResult(Result.Success(42)); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(42); + attempts.Should().Be(2); + } +} diff --git a/CSharpEssentials.slnx b/CSharpEssentials.slnx index d3fdeef..6863606 100644 --- a/CSharpEssentials.slnx +++ b/CSharpEssentials.slnx @@ -14,6 +14,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index c289893..90606aa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -59,6 +59,7 @@ + diff --git a/README.MD b/README.MD index ee86c98..c5cdf21 100644 --- a/README.MD +++ b/README.MD @@ -6,12 +6,12 @@ [![Build](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml/badge.svg)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) -[![Tests](https://img.shields.io/badge/tests-2787%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) +[![Tests](https://img.shields.io/badge/tests-2811%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![Downloads](https://img.shields.io/nuget/dt/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/senrecep/CSharpEssentials/blob/main/LICENCE) [![.NET](https://img.shields.io/badge/.NET-8%20%7C%209%20%7C%2010%20%7C%2011%20%7C%20ns2.0%20%7C%20ns2.1-512BD4)](https://dotnet.microsoft.com) -[![Packages](https://img.shields.io/badge/packages-19-blue)](https://www.nuget.org/profiles/senrecep) +[![Packages](https://img.shields.io/badge/packages-20-blue)](https://www.nuget.org/profiles/senrecep) Modular .NET NuGet ecosystem that bridges OOP and Functional Programming in C#. @@ -32,6 +32,7 @@ Modular .NET NuGet ecosystem that bridges OOP and Functional Programming in C#. | `CSharpEssentials.EntityFrameworkCore` | EF Core interceptors (audit, events, slow queries) + pagination | [📖](examples/Examples.EntityFrameworkCore/README.md) | [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.EntityFrameworkCore.svg)](https://www.nuget.org/packages/CSharpEssentials.EntityFrameworkCore) | | `CSharpEssentials.AspNetCore` | `GlobalExceptionHandler`, `ResultEndpointFilter`, Swagger versioning | [📖](examples/Examples.AspNetCore/README.md) | [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.AspNetCore.svg)](https://www.nuget.org/packages/CSharpEssentials.AspNetCore) | | `CSharpEssentials.Http` | `HttpClient` extensions returning `Result` | [📖](CSharpEssentials.Http/Readme.MD) | [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.Http.svg)](https://www.nuget.org/packages/CSharpEssentials.Http) | +| `CSharpEssentials.Resilience` | Transient fault handling: Retry, Timeout, Circuit Breaker, Fallback | [📖](CSharpEssentials.Resilience/Readme.MD) | [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.Resilience.svg)](https://www.nuget.org/packages/CSharpEssentials.Resilience) | | `CSharpEssentials.Json` | Pre-configured `System.Text.Json` options and converters | [📖](examples/Examples.Json/README.md) | [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.Json.svg)](https://www.nuget.org/packages/CSharpEssentials.Json) | | `CSharpEssentials.RequestResponseLogging` | Request/response body logging middleware | [📖](examples/Examples.RequestResponseLogging/README.md) | [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.RequestResponseLogging.svg)](https://www.nuget.org/packages/CSharpEssentials.RequestResponseLogging) | | `CSharpEssentials.GcpSecretManager` | GCP Secret Manager → `IConfiguration` provider | [📖](examples/Examples.GcpSecretManager/README.md) | [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.GcpSecretManager.svg)](https://www.nuget.org/packages/CSharpEssentials.GcpSecretManager) | @@ -41,7 +42,7 @@ Modular .NET NuGet ecosystem that bridges OOP and Functional Programming in C#. ## Documentation -**[API Reference](docs/API_REFERENCE.md)** — Complete guide to every package, method, and pattern in the ecosystem. Covers all 19 packages with method tables, philosophy, code examples, and cross-cutting FP patterns. +**[API Reference](docs/API_REFERENCE.md)** — Complete guide to every package, method, and pattern in the ecosystem. Covers all 20 packages with method tables, philosophy, code examples, and cross-cutting FP patterns. ## Installation diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 960b9bd..bef8eb1 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -17,16 +17,17 @@ A comprehensive guide to every package, method, and pattern in the CSharpEssenti - [Rules — Composable Business Rules](#6-csharpessentialsrules--composable-business-rules) - [Entity — DDD Building Blocks](#7-csharpessentialsentity--ddd-building-blocks) - [Http — Result-Returning HTTP Client](#8-csharpessentialshttp--result-returning-http-client) -- [EntityFrameworkCore — EF Core Integration](#9-csharpessentialsentityframeworkcore--ef-core-integration) -- [Json — Serialization Defaults](#10-csharpessentialsjson--serialization-defaults) -- [AspNetCore — API Layer](#11-csharpessentialsaspnetcore--api-layer) -- [Mediator — Pipeline Behaviors](#12-csharpessentialsmediator--pipeline-behaviors) -- [Enums — Source-Generated String Enums](#13-csharpessentialsenums--source-generated-string-enums) -- [Time — Testable Clock](#14-csharpessentialstime--testable-clock) -- [Clone — Deep Copy](#15-csharpessentialsclone--deep-copy) -- [RequestResponseLogging — HTTP Logging Middleware](#16-csharpessentialsrequestresponselogging--http-logging-middleware) -- [GcpSecretManager — Secret Configuration](#17-csharpessentialsgcpsecretmanager--secret-configuration) -- [Validation — Model-First Validation](#18-csharpessentialsvalidation--model-first-validation) +- [Resilience — Transient Fault Handling](#9-csharpessentialsresilience--transient-fault-handling) +- [EntityFrameworkCore — EF Core Integration](#10-csharpessentialsentityframeworkcore--ef-core-integration) +- [Json — Serialization Defaults](#11-csharpessentialsjson--serialization-defaults) +- [AspNetCore — API Layer](#12-csharpessentialsaspnetcore--api-layer) +- [Mediator — Pipeline Behaviors](#13-csharpessentialsmediator--pipeline-behaviors) +- [Enums — Source-Generated String Enums](#14-csharpessentialsenums--source-generated-string-enums) +- [Time — Testable Clock](#15-csharpessentialstime--testable-clock) +- [Clone — Deep Copy](#16-csharpessentialsclone--deep-copy) +- [RequestResponseLogging — HTTP Logging Middleware](#17-csharpessentialsrequestresponselogging--http-logging-middleware) +- [GcpSecretManager — Secret Configuration](#18-csharpessentialsgcpsecretmanager--secret-configuration) +- [Validation — Model-First Validation](#19-csharpessentialsvalidation--model-first-validation) - [Ecosystem Design Patterns](#ecosystem-design-patterns) --- @@ -696,6 +697,8 @@ HTTP status codes are automatically mapped to `ErrorType`: ### Resilience (Polly Integration) +> **Moved:** These methods have been replaced by the dedicated `CSharpEssentials.Resilience` package (Section 9). Prefer `ResiliencePolicy` / `ResiliencePolicy` for new code. + | Method | What It Does | |--------|-------------| | `CreateRetryPipeline` | Polly retry policy | @@ -706,7 +709,111 @@ HTTP status codes are automatically mapped to `ErrorType`: --- -## 9. CSharpEssentials.EntityFrameworkCore — EF Core Integration +## 9. CSharpEssentials.Resilience — Transient Fault Handling + +**What it is:** HTTP-agnostic resilience patterns (Retry, Timeout, Circuit Breaker, Fallback) with `Result` integration. Composable `ResiliencePolicy` builder backed by Polly v8. + +**Why it exists:** Transient faults are inevitable in distributed systems. This package provides a clean, composable API for handling retries, timeouts, circuit breakers, and fallbacks without coupling to any specific transport (HTTP, database, message queue, etc.). + +### Quick Start + +```csharp +using CSharpEssentials.Resilience; + +// Simple retry +Result user = await ResiliencePolicy + .Create() + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromSeconds(1)) + .ExecuteAsync(_ => _db.GetUser(id)); + +// Retry + Timeout +Result order = await ResiliencePolicy + .Create() + .WithRetry(3) + .WithTimeout(TimeSpan.FromSeconds(5)) + .ExecuteAsync(_ => _orderService.GetOrder(id)); + +// Circuit Breaker + Fallback +Result product = await ResiliencePolicy + .Create() + .WithCircuitBreaker(minimumThroughput: 10, failureRatio: 0.5) + .WithFallback(ct => _cache.GetAsync(id, ct)) + .ExecuteAsync(_ => _productService.GetProduct(id)); +``` + +### ResiliencePolicy + +| Method | What It Does | +|--------|-------------| +| `ResiliencePolicy.Create()` | Creates an empty policy | +| `.WithRetry(maxAttempts, delay, exponentialBackoff)` | Adds retry strategy | +| `.WithTimeout(timeout)` | Adds timeout strategy | +| `.WithCircuitBreaker(minThroughput, samplingDuration, breakDuration, failureRatio)` | Adds circuit breaker | +| `.ExecuteAsync(action)` | Executes action through the pipeline, returns `Result` | +| `.ExecuteAsync(action)` | Executes typed action, returns `Result` | + +### ResiliencePolicy\ (Result-Aware) + +The generic variant automatically filters retryable errors — `Unauthorized`, `Forbidden`, `NotFound`, and `Validation` errors are **not** retried. + +| Method | What It Does | +|--------|-------------| +| `ResiliencePolicy.Create()` | Creates an empty typed policy | +| `.WithRetry(...)` | Adds retry with Result error filtering | +| `.WithTimeout(...)` | Adds timeout | +| `.WithCircuitBreaker(...)` | Adds circuit breaker with Result error filtering | +| `.WithFallback(fallbackAsync)` | Adds fallback that returns `T` or `Result` | +| `.ExecuteAsync(action)` | Executes through pipeline, returns `Result` | + +### Delegate Extensions + +```csharp +// Direct execution — wraps any Func> in a Result +Result user = await (() => _db.GetUser(id)).ExecuteAsync(); + +// With CancellationToken +Result user = await ((ct) => _db.GetUser(id, ct)).ExecuteAsync(cancellationToken); +``` + +### Retry Extensions + +```csharp +Func>> getUser = ct => _db.GetUser(id, ct); +Result result = await getUser.RetryIfFailed(maxAttempts: 3); +``` + +### Error Handling + +| Error Code | When | +|-----------|------| +| `Resilience.Timeout` | Operation exceeded timeout | +| `Resilience.CircuitBroken` | Circuit breaker is open | + +When retries exhaust, the last exception is returned as `ErrorType.Unexpected`. + +### Configuration Options + +```csharp +var options = new ResiliencePolicyOptions +{ + Retry = new RetryOptions { MaxAttempts = 3, Delay = TimeSpan.FromSeconds(1) }, + Timeout = new TimeoutOptions { Timeout = TimeSpan.FromSeconds(5) }, + CircuitBreaker = new CircuitBreakerOptions + { + MinimumThroughput = 10, + FailureRatio = 0.5, + BreakDuration = TimeSpan.FromSeconds(30) + } +}; + +Result user = await ResiliencePolicy + .Create(options) + .ExecuteAsync(_ => _db.GetUser(id)); +``` + +--- + +## 10. CSharpEssentials.EntityFrameworkCore — EF Core Integration **What it is:** EF Core extensions that bring the Result pattern to database operations, plus pagination, audit interceptors, and CQRS context separation. @@ -759,7 +866,7 @@ HTTP status codes are automatically mapped to `ErrorType`: --- -## 10. CSharpEssentials.Json — Serialization Defaults +## 11. CSharpEssentials.Json — Serialization Defaults **What it is:** Pre-configured `System.Text.Json` options and custom converters. @@ -779,7 +886,7 @@ HTTP status codes are automatically mapped to `ErrorType`: --- -## 11. CSharpEssentials.AspNetCore — API Layer +## 12. CSharpEssentials.AspNetCore — API Layer **What it is:** ASP.NET Core integration that automatically maps `Result`/`Error` types to proper HTTP responses using ProblemDetails. @@ -814,7 +921,7 @@ HTTP status codes are automatically mapped to `ErrorType`: --- -## 12. CSharpEssentials.Mediator — Pipeline Behaviors +## 13. CSharpEssentials.Mediator — Pipeline Behaviors **What it is:** MediatR pipeline behaviors for cross-cutting concerns: validation, logging, caching, and transactions. @@ -841,7 +948,7 @@ HTTP status codes are automatically mapped to `ErrorType`: --- -## 13. CSharpEssentials.Enums — Source-Generated String Enums +## 14. CSharpEssentials.Enums — Source-Generated String Enums **What it is:** A Roslyn source generator that produces fast, AOT-safe enum-to-string and string-to-enum methods. @@ -866,7 +973,7 @@ IReadOnlyList all = OrderStatusExtensions.GetValues(); --- -## 14. CSharpEssentials.Time — Testable Clock +## 15. CSharpEssentials.Time — Testable Clock **What it is:** An `IDateTimeProvider` interface that wraps the system clock for testability. @@ -881,7 +988,7 @@ IReadOnlyList all = OrderStatusExtensions.GetValues(); --- -## 15. CSharpEssentials.Clone — Deep Copy +## 16. CSharpEssentials.Clone — Deep Copy **What it is:** Deep cloning via JSON serialization. @@ -893,7 +1000,7 @@ IReadOnlyList all = OrderStatusExtensions.GetValues(); --- -## 16. CSharpEssentials.RequestResponseLogging — HTTP Logging Middleware +## 17. CSharpEssentials.RequestResponseLogging — HTTP Logging Middleware **What it is:** ASP.NET Core middleware that logs HTTP request and response bodies. @@ -906,7 +1013,7 @@ IReadOnlyList all = OrderStatusExtensions.GetValues(); --- -## 17. CSharpEssentials.GcpSecretManager — Secret Configuration +## 18. CSharpEssentials.GcpSecretManager — Secret Configuration **What it is:** Plugs Google Cloud Secret Manager into the .NET `IConfiguration` system. @@ -918,7 +1025,7 @@ IReadOnlyList all = OrderStatusExtensions.GetValues(); --- -## 18. CSharpEssentials.Validation — Model-First Validation +## 19. CSharpEssentials.Validation — Model-First Validation **What it is:** A high-performance, model-first validation library that returns `Result` natively. diff --git a/examples/Examples.Resilience/Examples.Resilience.csproj b/examples/Examples.Resilience/Examples.Resilience.csproj new file mode 100644 index 0000000..f690b54 --- /dev/null +++ b/examples/Examples.Resilience/Examples.Resilience.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + enable + enable + false + None + false + false + false + false + false + + + + + + + + + diff --git a/examples/Examples.Resilience/Program.cs b/examples/Examples.Resilience/Program.cs new file mode 100644 index 0000000..3798cff --- /dev/null +++ b/examples/Examples.Resilience/Program.cs @@ -0,0 +1,227 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.Resilience; +using CSharpEssentials.ResultPattern; + +Console.WriteLine("========================================"); +Console.WriteLine("CSharpEssentials.Resilience Example"); +Console.WriteLine("========================================\n"); + +// ============================================================================ +// 1. BASIC POLICY — EMPTY PIPELINE +// ============================================================================ +Console.WriteLine("--- 1. Basic Policy (Empty Pipeline) ---"); + +ResiliencePolicy policy = ResiliencePolicy.Create(); + +Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success())); +Console.WriteLine($"Empty pipeline: IsSuccess={result.IsSuccess}"); + +Result valueResult = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success(42))); +Console.WriteLine($"Empty pipeline (generic): Value={valueResult.Value}"); +Console.WriteLine(); + +// ============================================================================ +// 2. RETRY POLICY +// ============================================================================ +Console.WriteLine("--- 2. Retry Policy ---"); + +int retryAttempts = 0; +ResiliencePolicy retryPolicy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(100)); + +Result retryResult = await retryPolicy.ExecuteAsync(_ => +{ + retryAttempts++; + if (retryAttempts < 3) + throw new InvalidOperationException($"Attempt {retryAttempts} failed"); + return Task.FromResult(Result.Success()); +}); + +Console.WriteLine($"Retry result: IsSuccess={retryResult.IsSuccess}, Total attempts={retryAttempts}"); +Console.WriteLine(); + +// ============================================================================ +// 3. RETRY WITH EXPONENTIAL BACKOFF +// ============================================================================ +Console.WriteLine("--- 3. Retry with Exponential Backoff ---"); + +ResiliencePolicy exponentialPolicy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(50), exponentialBackoff: true); + +ResiliencePolicy constantPolicy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(50), exponentialBackoff: false); + +Console.WriteLine("Exponential backoff: delays grow exponentially (50ms, 100ms, 200ms)"); +Console.WriteLine("Constant backoff: delays stay constant (50ms, 50ms, 50ms)"); +Console.WriteLine(); + +// ============================================================================ +// 4. TIMEOUT POLICY +// ============================================================================ +Console.WriteLine("--- 4. Timeout Policy ---"); + +ResiliencePolicy timeoutPolicy = ResiliencePolicy.Create() + .WithTimeout(TimeSpan.FromSeconds(1)); + +Result timeoutResult = await timeoutPolicy.ExecuteAsync(async ct => +{ + await Task.Delay(TimeSpan.FromSeconds(5), ct); + return Result.Success(); +}); + +Console.WriteLine($"Timeout result: IsSuccess={timeoutResult.IsSuccess}, Error={timeoutResult.FirstError.Code}"); +Console.WriteLine(); + +// ============================================================================ +// 5. CIRCUIT BREAKER POLICY +// ============================================================================ +Console.WriteLine("--- 5. Circuit Breaker Policy ---"); + +ResiliencePolicy cbPolicy = ResiliencePolicy.Create() + .WithCircuitBreaker( + minimumThroughput: 3, + samplingDuration: TimeSpan.FromSeconds(1), + breakDuration: TimeSpan.FromSeconds(5), + failureRatio: 0.5); + +int cbAttempts = 0; +for (int i = 0; i < 5; i++) +{ + Result cbResult = await cbPolicy.ExecuteAsync(_ => + { + cbAttempts++; + throw new InvalidOperationException("Service unavailable"); + }); + Console.WriteLine($" Attempt {i + 1}: IsFailure={cbResult.IsFailure}, Code={cbResult.FirstError.Code}"); +} + +Console.WriteLine($"Circuit opened after threshold failures"); +Console.WriteLine(); + +// ============================================================================ +// 6. COMBINED POLICIES (RETRY + TIMEOUT + CIRCUIT BREAKER) +// ============================================================================ +Console.WriteLine("--- 6. Combined Policies ---"); + +ResiliencePolicy combinedPolicy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 2, delay: TimeSpan.FromMilliseconds(50)) + .WithTimeout(TimeSpan.FromSeconds(1)) + .WithCircuitBreaker(minimumThroughput: 10, samplingDuration: TimeSpan.FromSeconds(1)); + +Result combinedResult = await combinedPolicy.ExecuteAsync(_ => Task.FromResult(Result.Success())); +Console.WriteLine($"Combined pipeline: IsSuccess={combinedResult.IsSuccess}"); +Console.WriteLine(); + +// ============================================================================ +// 7. GENERIC RESILIENCE POLICY +// ============================================================================ +Console.WriteLine("--- 7. Generic ResiliencePolicy ---"); + +int genAttempts = 0; +ResiliencePolicy genericPolicy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(50)); + +Result genResult = await genericPolicy.ExecuteAsync(_ => +{ + genAttempts++; + if (genAttempts < 2) + return Task.FromResult(Result.Failure(Error.Unexpected("Transient", "Temporary failure"))); + return Task.FromResult(Result.Success(100)); +}); + +Console.WriteLine($"Generic result: Value={genResult.Value}, Attempts={genAttempts}"); +Console.WriteLine(); + +// ============================================================================ +// 8. FALLBACK POLICY +// ============================================================================ +Console.WriteLine("--- 8. Fallback Policy ---"); + +ResiliencePolicy fallbackPolicy = ResiliencePolicy.Create() + .WithFallback(_ => Task.FromResult(Result.Success("fallback value"))); + +Result fallbackResult = await fallbackPolicy.ExecuteAsync(_ => + Task.FromResult(Result.Failure(Error.Unexpected()))); + +Console.WriteLine($"Fallback result: Value={fallbackResult.Value}"); +Console.WriteLine(); + +// ============================================================================ +// 9. CREATE FROM OPTIONS RECORD +// ============================================================================ +Console.WriteLine("--- 9. Create from ResiliencePolicyOptions ---"); + +ResiliencePolicyOptions options = new() +{ + Retry = new RetryOptions { MaxAttempts = 2, Delay = TimeSpan.FromMilliseconds(50), ExponentialBackoff = false }, + Timeout = new TimeoutOptions { Timeout = TimeSpan.FromSeconds(2) }, + CircuitBreaker = new CircuitBreakerOptions + { + MinimumThroughput = 10, + SamplingDuration = TimeSpan.FromSeconds(1), + BreakDuration = TimeSpan.FromSeconds(1), + FailureRatio = 0.5 + } +}; + +ResiliencePolicy optionsPolicy = ResiliencePolicy.Create(options); +Result optionsResult = await optionsPolicy.ExecuteAsync(_ => Task.FromResult(Result.Success())); +Console.WriteLine($"Options-based pipeline: IsSuccess={optionsResult.IsSuccess}"); +Console.WriteLine(); + +// ============================================================================ +// 10. FUNC EXTENSIONS — INLINE EXECUTION +// ============================================================================ +Console.WriteLine("--- 10. Func Extensions (Inline Execution) ---"); + +Func> computeValue = () => Task.FromResult(42); +Result funcResult = await computeValue.ExecuteAsync(); +Console.WriteLine($"Func>: Value={funcResult.Value}"); + +Func>> fetchString = ct => Task.FromResult(Result.Success("hello")); +Result funcResult2 = await fetchString.ExecuteAsync(); +Console.WriteLine($"Func>>: Value={funcResult2.Value}"); +Console.WriteLine(); + +// ============================================================================ +// 11. RETRY IF FAILED — EXTENSION ON RESULT-RETURNING FUNCTIONS +// ============================================================================ +Console.WriteLine("--- 11. RetryIfFailed Extension ---"); + +int rifAttempts = 0; +Func>> unreliableOperation = ct => +{ + rifAttempts++; + if (rifAttempts < 3) + return Task.FromResult(Result.Failure(Error.Conflict("RateLimited", "Too many requests"))); + return Task.FromResult(Result.Success(999)); +}; + +Result rifResult = await unreliableOperation.RetryIfFailed( + maxAttempts: 5, + delay: TimeSpan.FromMilliseconds(50)); + +Console.WriteLine($"RetryIfFailed: Value={rifResult.Value}, Attempts={rifAttempts}"); +Console.WriteLine(); + +// ============================================================================ +// 12. HANDLE DIFFERENT ERROR TYPES +// ============================================================================ +Console.WriteLine("--- 12. Error Type Handling ---"); + +ResiliencePolicy errorPolicy = ResiliencePolicy.Create() + .WithRetry(maxAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + +Result validationError = await errorPolicy.ExecuteAsync(_ => + Task.FromResult(Result.Failure(Error.Validation("InvalidInput", "Name is required")))); + +Result notFoundError = await errorPolicy.ExecuteAsync(_ => + Task.FromResult(Result.Failure(Error.NotFound("User not found")))); + +Console.WriteLine($"Validation error: Code={validationError.FirstError.Code}, Type={validationError.FirstError.Type}"); +Console.WriteLine($"NotFound error: Code={notFoundError.FirstError.Code}, Type={notFoundError.FirstError.Type}"); +Console.WriteLine(); + +Console.WriteLine("========================================"); +Console.WriteLine("Demo complete."); +Console.WriteLine("========================================"); diff --git a/examples/Examples.Resilience/README.md b/examples/Examples.Resilience/README.md new file mode 100644 index 0000000..106e7d5 --- /dev/null +++ b/examples/Examples.Resilience/README.md @@ -0,0 +1,23 @@ +# Examples.Resilience + +Demonstrates the `CSharpEssentials.Resilience` package — modular resilience policies for transient fault handling. + +## Run + +```bash +dotnet run +``` + +## What's Covered + +- **Empty pipeline** — `ResiliencePolicy.Create()` +- **Retry** — exponential and constant backoff +- **Timeout** — cancelling long-running operations +- **Circuit Breaker** — opening after failure threshold +- **Combined policies** — chaining retry + timeout + circuit breaker +- **Generic `ResiliencePolicy`** — typed result pipelines +- **Fallback** — providing default values on failure +- **Options record** — building from `ResiliencePolicyOptions` +- **Func extensions** — inline `Func>.ExecuteAsync()` +- **RetryIfFailed** — extension on `Func>>` +- **Error type handling** — validation, not-found, conflict errors diff --git a/index.html b/index.html index 53aa428..641fa6b 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + @@ -762,7 +762,7 @@

CSharpEssentials

Modular .NET library ecosystem bridging OOP and Functional Programming — Result monads, discriminated unions, rules engine, and more.

@@ -805,7 +805,7 @@

CSharpEssentials

- 19 + 20 Packages
@@ -885,7 +885,7 @@

Modular by Default

-

19 focused packages

+

20 focused packages

Targeting .NET 9, .NET 8, netstandard2.1, and netstandard2.0. Central package management keeps versions consistent.

@@ -1052,6 +1052,15 @@

19 focused packages

Validator<T> · RuleChain
+
+
+ Resilience + Infra +
+

Modular resilience policies — Retry, Timeout, CircuitBreaker, Fallback — composable via fluent API.

+ ResiliencePolicy · Polly +
+
Meta @@ -1072,7 +1081,7 @@

19 focused packages

AI skills for every agent

-

19 per-package skills for Claude Code, Cursor, Codex, and 50+ compatible AI agents. Install once and every agent in your workflow gets structured, accurate knowledge of each package's API.

+

20 per-package skills for Claude Code, Cursor, Codex, and 50+ compatible AI agents. Install once and every agent in your workflow gets structured, accurate knowledge of each package's API.

Claude Code Cursor @@ -1231,6 +1240,7 @@

AI skills for every agent

'Time': { nuget: 'CSharpEssentials.Time', docs: 'examples/Examples.Time' }, 'Clone': { nuget: 'CSharpEssentials.Clone', docs: 'examples/Examples.Clone' }, 'Validation': { nuget: 'CSharpEssentials.Validation', docs: 'examples/Examples.Validation' }, + 'Resilience': { nuget: 'CSharpEssentials.Resilience', docs: 'examples/Examples.Resilience' }, 'Meta': { nuget: 'CSharpEssentials', docs: 'examples/Examples.Main' } };