From 62bf2f450f38f0269067c3eafb35edcae95f3b0c Mon Sep 17 00:00:00 2001 From: Erdem Date: Thu, 28 May 2026 18:19:03 +0300 Subject: [PATCH 01/11] feat: add CSharpEssentials.Resilience package --- .../CSharpEssentials.Http.csproj | 3 +- .../HttpClientResilienceExtensions.cs | 214 +++-------------- .../CSharpEssentials.Resilience.csproj | 32 +++ .../Extensions/ResilienceFuncExtensions.cs | 54 +++++ .../Extensions/ResilienceResultExtensions.cs | 74 ++++++ .../ResiliencePolicy.CircuitBreaker.cs | 85 +++++++ .../Modules/ResiliencePolicy.Fallback.cs | 70 ++++++ .../Modules/ResiliencePolicy.Retry.cs | 71 ++++++ .../Modules/ResiliencePolicy.Timeout.cs | 59 +++++ CSharpEssentials.Resilience/Readme.MD | 120 ++++++++++ .../ResilienceOptions.cs | 28 +++ .../ResiliencePolicy.cs | 173 ++++++++++++++ .../ResiliencePolicyT.cs | 113 +++++++++ .../CSharpEssentials.Tests.csproj | 1 + .../HttpClientResilienceExtensionsTests.cs | 97 ++------ .../ResilienceFuncExtensionsTests.cs | 57 +++++ .../Resilience/ResiliencePolicyTests.cs | 216 ++++++++++++++++++ .../ResilienceResultExtensionsTests.cs | 176 ++++++++++++++ CSharpEssentials.slnx | 1 + Directory.Packages.props | 1 + README.MD | 7 +- docs/API_REFERENCE.md | 143 ++++++++++-- 22 files changed, 1519 insertions(+), 276 deletions(-) create mode 100644 CSharpEssentials.Resilience/CSharpEssentials.Resilience.csproj create mode 100644 CSharpEssentials.Resilience/Extensions/ResilienceFuncExtensions.cs create mode 100644 CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs create mode 100644 CSharpEssentials.Resilience/Modules/ResiliencePolicy.CircuitBreaker.cs create mode 100644 CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs create mode 100644 CSharpEssentials.Resilience/Modules/ResiliencePolicy.Retry.cs create mode 100644 CSharpEssentials.Resilience/Modules/ResiliencePolicy.Timeout.cs create mode 100644 CSharpEssentials.Resilience/Readme.MD create mode 100644 CSharpEssentials.Resilience/ResilienceOptions.cs create mode 100644 CSharpEssentials.Resilience/ResiliencePolicy.cs create mode 100644 CSharpEssentials.Resilience/ResiliencePolicyT.cs create mode 100644 CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs create mode 100644 CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs create mode 100644 CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs 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..11993ff 100644 --- a/CSharpEssentials.Http/HttpClientResilienceExtensions.cs +++ b/CSharpEssentials.Http/HttpClientResilienceExtensions.cs @@ -1,216 +1,72 @@ -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 { - public static ResiliencePipeline CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) - { - TimeSpan baseDelay = delay ?? TimeSpan.FromSeconds(1); + public static ResiliencePolicy CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay); - 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(); - } + public static ResiliencePolicy CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay); - public static ResiliencePipeline> CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) - { - TimeSpan baseDelay = delay ?? TimeSpan.FromSeconds(1); + public static ResiliencePolicy CreateTimeoutPipeline(TimeSpan timeout) + => ResiliencePolicy.Create().WithTimeout(timeout); - 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(); - } + public static ResiliencePolicy CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration); - public static ResiliencePipeline CreateTimeoutPipeline(TimeSpan timeout) - { - return new ResiliencePipelineBuilder() - .AddTimeout(new TimeoutStrategyOptions - { - Timeout = timeout - }) - .Build(); - } + public static ResiliencePolicy CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration); - public static ResiliencePipeline CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + public static ResiliencePolicy CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = 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 policy = ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay); - 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(); - } - - 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); + if (timeout.HasValue) + { + policy = policy.WithTimeout(timeout.Value); + } - 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 policy; } - public static ResiliencePipeline> CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) + public static ResiliencePolicy CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) { - TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); - TimeSpan effectiveDelay = retryDelay ?? TimeSpan.FromSeconds(1); + ResiliencePolicy policy = ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay); + + if (timeout.HasValue) + { + policy = policy.WithTimeout(timeout.Value); + } - 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 policy; } - public static async Task ExecuteAsResultAsync( - this ResiliencePipeline pipeline, + public static Task ExecuteAsResultAsync( + this ResiliencePolicy policy, Func> callback, CancellationToken cancellationToken = default) { - return await ExecuteResilienceAsync(async () => - await pipeline.ExecuteAsync(async token => await callback(token), cancellationToken)); + return policy.ExecuteAsync(callback, cancellationToken); } public static Task> ExecuteAsResultAsync( - this ResiliencePipeline pipeline, + this ResiliencePolicy policy, Func>> callback, CancellationToken cancellationToken = default) { - return Result.TryAsync( - () => pipeline.ExecuteAsync(async token => await callback(token), cancellationToken).AsTask(), - HandleException, - cancellationToken); + return policy.ExecuteAsync(callback, cancellationToken); } public static Task> ExecuteAsResultAsync( - this ResiliencePipeline> pipeline, + this ResiliencePolicy policy, Func>> callback, CancellationToken cancellationToken = default) { - return Result.TryAsync( - () => pipeline.ExecuteAsync(async token => await callback(token), cancellationToken).AsTask(), - HandleException, - 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; - } - - private static Error HandleException(Exception 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); - } - - private static Task ExecuteResilienceAsync(Func> action) - { - 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); - }); + return policy.ExecuteAsync(callback, cancellationToken); } } 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..ecd40f0 --- /dev/null +++ b/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs @@ -0,0 +1,74 @@ +#pragma warning disable IDE0390 +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)) + }) + .Build(); + + return await pipeline.ExecuteAsync( + async token => await operation(token), + cancellationToken); + } + + 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 => r.IsFailure) + }) + .Build(); + + return await pipeline.ExecuteAsync( + async token => await operation(token), + 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; + } +} 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..7c685ed --- /dev/null +++ b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs @@ -0,0 +1,70 @@ +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 => + { + T fallbackValue = await fallbackAsync(args.Context.CancellationToken); + return Outcome.FromResult(Result.Success(fallbackValue)); + } + }) + .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 => + { + Result fallbackResult = await fallbackAsync(args.Context.CancellationToken); + return Outcome.FromResult(fallbackResult); + } + }) + .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..552c2df --- /dev/null +++ b/CSharpEssentials.Resilience/Readme.MD @@ -0,0 +1,120 @@ +# 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(samplingWindow: 10, failureRatio: 0.5) + .WithFallback(ct => _cache.GetAsync(id, ct)) + .ExecuteAsync(() => _productService.GetProduct(id)); +``` + +## Delegate Extensions + +```csharp +using CSharpEssentials.Resilience; + +// Fluent builder on delegates +Result user = await (() => _db.GetUser(id)) + .WithRetry(3) + .WithTimeout(TimeSpan.FromSeconds(5)) + .ExecuteAsync(); + +// Direct execution +Result user = await (() => _db.GetUser(id)).ExecuteAsync(); +``` + +## 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.RetryExhausted", "All retry attempts exhausted.")` +- `Error.Failure("Resilience.Timeout", "Operation timed out.")` +- `Error.Failure("Resilience.CircuitBroken", "Circuit breaker is open.")` + +## Pipeline Composition + +```csharp +// Stack multiple policies +Result result = await ResiliencePolicy + .Create() + .WithRetry(3) + .WithTimeout(TimeSpan.FromSeconds(5)) + .WithCircuitBreaker(samplingWindow: 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..37704e8 --- /dev/null +++ b/CSharpEssentials.Resilience/ResiliencePolicy.cs @@ -0,0 +1,173 @@ +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 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) + { + 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) + { + 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) + { + 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) + { + 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..439ca91 --- /dev/null +++ b/CSharpEssentials.Resilience/ResiliencePolicyT.cs @@ -0,0 +1,113 @@ +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 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) + { + 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) + { + 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..1d2185c 100644 --- a/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs +++ b/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs @@ -1,9 +1,8 @@ using CSharpEssentials.Errors; using CSharpEssentials.Http; +using CSharpEssentials.Resilience; using CSharpEssentials.ResultPattern; using FluentAssertions; -using Polly; -using Polly.CircuitBreaker; namespace CSharpEssentials.Tests.Http; @@ -12,9 +11,9 @@ public class HttpClientResilienceExtensionsTests [Fact] public async Task ExecuteAsResultAsync_Should_Return_Success_When_No_Failure() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success())); + Result result = await policy.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success())); result.IsSuccess.Should().BeTrue(); } @@ -22,21 +21,21 @@ public async Task ExecuteAsResultAsync_Should_Return_Success_When_No_Failure() [Fact] public async Task ExecuteAsResultAsync_Generic_Should_Return_Value() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(42.ToResult())); + Result result = await policy.ExecuteAsResultAsync(_ => 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 ExecuteAsResultAsync_Should_Retry_On_Exception() { int attempts = 0; - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsResultAsync(_ => { attempts++; if (attempts < 2) @@ -51,36 +50,26 @@ public async Task ExecuteAsResultAsync_Should_Retry_On_HttpRequestException() [Fact] public async Task ExecuteAsResultAsync_Should_Return_Failure_On_Persistent_Exception() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); - Result result = await pipeline.ExecuteAsResultAsync(_ => throw new HttpRequestException("Persistent failure")); + Result result = await policy.ExecuteAsResultAsync(_ => 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() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsResultAsync(_ => { 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(); @@ -91,13 +80,13 @@ public async Task ExecuteAsResultAsync_With_GenericPipeline_Should_Return_Value( [Fact] public async Task CreateRetryPipeline_Generic_Should_Not_Retry_On_Success() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsResultAsync(_ => { attempts++; - return Task.FromResult(42.ToResult()); + return Task.FromResult(Result.Success(42)); }); result.IsSuccess.Should().BeTrue(); @@ -108,12 +97,12 @@ public async Task CreateRetryPipeline_Generic_Should_Not_Retry_On_Success() [Fact] public async Task CreateResiliencePipeline_Generic_Should_Retry_And_Timeout() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateResiliencePipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateResiliencePipeline( maxRetryAttempts: 1, timeout: TimeSpan.FromSeconds(1), retryDelay: TimeSpan.FromMilliseconds(10)); - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(7.ToResult())); + Result result = await policy.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success(7))); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(7); @@ -126,12 +115,12 @@ public async Task CreateResiliencePipeline_Generic_Should_Retry_And_Timeout() [InlineData(ErrorType.Validation)] public async Task CreateRetryPipeline_Generic_Should_Not_Retry_NonRetryable_Errors(ErrorType errorType) { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline( maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsResultAsync(_ => { attempts++; return Task.FromResult(Result.Failure(CreateError(errorType))); @@ -144,17 +133,17 @@ public async Task CreateRetryPipeline_Generic_Should_Not_Retry_NonRetryable_Erro [Fact] public async Task CreateRetryPipeline_Generic_Should_Retry_On_Conflict() { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateRetryPipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline( maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await pipeline.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsResultAsync(_ => { 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(); @@ -165,47 +154,11 @@ public async Task CreateRetryPipeline_Generic_Should_Retry_On_Conflict() [Fact] public void CreateCircuitBreakerPipeline_Should_Have_Sensible_Defaults() { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline(minimumThroughput: 5); + ResiliencePolicy pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline(minimumThroughput: 5); pipeline.Should().NotBeNull(); } - [Fact] - public async Task CreateCircuitBreakerPipeline_NonGeneric_Should_Open_On_Exceptions() - { - ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline( - minimumThroughput: 3, - samplingDuration: TimeSpan.FromSeconds(1), - breakDuration: TimeSpan.FromSeconds(1)); - - for (int i = 0; i < 3; i++) - { - Result result = await pipeline.ExecuteAsResultAsync(_ => throw new HttpRequestException("failure")); - result.IsFailure.Should().BeTrue(); - } - - Func act = async () => await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success())); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task CreateCircuitBreakerPipeline_Generic_Should_Open_On_Failures() - { - ResiliencePipeline> pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline( - minimumThroughput: 3, - samplingDuration: TimeSpan.FromSeconds(1), - breakDuration: TimeSpan.FromSeconds(1)); - - for (int i = 0; i < 3; i++) - { - Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Failure(Error.Unexpected()))); - result.IsFailure.Should().BeTrue(); - } - - Func act = async () => await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(1.ToResult())); - await act.Should().ThrowAsync(); - } - private static Error CreateError(ErrorType type) => type switch { ErrorType.Unauthorized => Error.Unauthorized(), @@ -214,6 +167,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..8896665 --- /dev/null +++ b/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs @@ -0,0 +1,57 @@ +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); + } +} diff --git a/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs b/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs new file mode 100644 index 0000000..2d167c0 --- /dev/null +++ b/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs @@ -0,0 +1,216 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.Resilience; +using CSharpEssentials.ResultPattern; +using FluentAssertions; + +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); + } + + 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..d3c51bc --- /dev/null +++ b/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs @@ -0,0 +1,176 @@ +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); + } +} 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..ca7cac8 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) --- @@ -706,7 +707,109 @@ 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 +Result user = await (() => _db.GetUser(id)) + .WithRetry(3) + .WithTimeout(TimeSpan.FromSeconds(5)) + .ExecuteAsync(); +``` + +### 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.RetryExhausted` | All retry attempts failed | +| `Resilience.CircuitBroken` | Circuit breaker is open | + +### 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 +862,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 +882,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 +917,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 +944,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 +969,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 +984,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 +996,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 +1009,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 +1021,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. From c0a18333ffaadc3fbbdbb3a1ff7d50d817865933 Mon Sep 17 00:00:00 2001 From: Erdem Date: Thu, 28 May 2026 18:19:07 +0300 Subject: [PATCH 02/11] docs: add resilience-package skill for AI agents --- .../csharpessentials-resilience/SKILL.md | 99 +++++++++++++++++++ .well-known/agent-skills/index.json | 5 + 2 files changed, 104 insertions(+) create mode 100644 .well-known/agent-skills/csharpessentials-resilience/SKILL.md 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..9a35414 --- /dev/null +++ b/.well-known/agent-skills/csharpessentials-resilience/SKILL.md @@ -0,0 +1,99 @@ +--- +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; +``` + +--- + +## 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 +Result user = await (() => _db.GetUser(id)) + .WithRetry(3) + .WithTimeout(TimeSpan.FromSeconds(5)) + .ExecuteAsync(); + +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().", From 3b7520bb1561b931710341b9a523d90f384337d7 Mon Sep 17 00:00:00 2001 From: Erdem Date: Thu, 28 May 2026 19:16:42 +0300 Subject: [PATCH 03/11] test(resilience): increase coverage to 91% line / 80% branch --- .../ResilienceFuncExtensionsTests.cs | 85 ++++ .../Resilience/ResiliencePolicyTests.cs | 444 ++++++++++++++++++ .../ResilienceResultExtensionsTests.cs | 111 +++++ 3 files changed, 640 insertions(+) diff --git a/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs b/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs index 8896665..79a6c28 100644 --- a/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs +++ b/CSharpEssentials.Tests/Resilience/ResilienceFuncExtensionsTests.cs @@ -54,4 +54,89 @@ public async Task ExecuteAsync_Result_Generic_With_CancellationToken_Should_Work 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 index 2d167c0..06abcff 100644 --- a/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs +++ b/CSharpEssentials.Tests/Resilience/ResiliencePolicyTests.cs @@ -2,6 +2,8 @@ using CSharpEssentials.Resilience; using CSharpEssentials.ResultPattern; using FluentAssertions; +using Polly; +using Polly.Retry; namespace CSharpEssentials.Tests.Resilience; @@ -205,6 +207,448 @@ public async Task Generic_WithFallback_Should_Return_Fallback_After_Retries_Are_ 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(), diff --git a/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs b/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs index d3c51bc..3659350 100644 --- a/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs +++ b/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs @@ -173,4 +173,115 @@ public async Task RetryIfFailed_Should_Retry_On_Conflict() 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_Retry_On_Unauthorized() + { + int attempts = 0; + Func> operation = _ => + { + attempts++; + if (attempts < 2) + return Task.FromResult(Result.Failure(Error.Unauthorized())); + return Task.FromResult(Result.Success()); + }; + + Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(2); + } + + [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); + } } From 30e5ead0fc67594d967bb8e44636b77caf74d766 Mon Sep 17 00:00:00 2001 From: Erdem Date: Thu, 28 May 2026 19:19:28 +0300 Subject: [PATCH 04/11] docs: add Resilience to package index, meta SKILL, and AGENTS --- .../agent-skills/csharpessentials-meta/SKILL.md | 10 +++++++++- AGENTS.md | 2 +- index.html | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) 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/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/index.html b/index.html index 53aa428..132f01c 100644 --- a/index.html +++ b/index.html @@ -1231,6 +1231,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: 'CSharpEssentials.Resilience/Readme.MD' }, 'Meta': { nuget: 'CSharpEssentials', docs: 'examples/Examples.Main' } }; From f03e0f1cf4575205d4b1af489091f8feed562d47 Mon Sep 17 00:00:00 2001 From: Erdem Date: Thu, 28 May 2026 19:22:42 +0300 Subject: [PATCH 05/11] docs: add Examples.Resilience project with runnable demos --- .../Examples.Resilience.csproj | 23 ++ examples/Examples.Resilience/Program.cs | 227 ++++++++++++++++++ examples/Examples.Resilience/README.md | 23 ++ index.html | 2 +- 4 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 examples/Examples.Resilience/Examples.Resilience.csproj create mode 100644 examples/Examples.Resilience/Program.cs create mode 100644 examples/Examples.Resilience/README.md 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 132f01c..8fe2e67 100644 --- a/index.html +++ b/index.html @@ -1231,7 +1231,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: 'CSharpEssentials.Resilience/Readme.MD' }, + 'Resilience': { nuget: 'CSharpEssentials.Resilience', docs: 'examples/Examples.Resilience' }, 'Meta': { nuget: 'CSharpEssentials', docs: 'examples/Examples.Main' } }; From 3cde5101da39893f36660f9c86278b112fecc9db Mon Sep 17 00:00:00 2001 From: Erdem Date: Thu, 28 May 2026 19:24:59 +0300 Subject: [PATCH 06/11] docs: add Resilience card to packages grid, update counts to 20 --- index.html | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 8fe2e67..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 From 2fe66fcefce5c86ec6edaf92a95105fc9af47ce8 Mon Sep 17 00:00:00 2001 From: Erdem Date: Sat, 30 May 2026 20:56:20 +0300 Subject: [PATCH 07/11] =?UTF-8?q?fix(resilience):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20RetryIfFailed=20filter,=20pragma,=20docs,=20SKILL.m?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../csharpessentials-resilience/SKILL.md | 25 +++++++++++++++++++ .../Extensions/ResilienceResultExtensions.cs | 17 +++++++++++-- CSharpEssentials.Resilience/Readme.MD | 4 +-- .../ResilienceResultExtensionsTests.cs | 11 ++++---- docs/API_REFERENCE.md | 2 ++ 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/.well-known/agent-skills/csharpessentials-resilience/SKILL.md b/.well-known/agent-skills/csharpessentials-resilience/SKILL.md index 9a35414..436a0a8 100644 --- a/.well-known/agent-skills/csharpessentials-resilience/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-resilience/SKILL.md @@ -21,6 +21,31 @@ 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 diff --git a/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs b/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs index ecd40f0..bd3b9d5 100644 --- a/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs +++ b/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs @@ -1,4 +1,3 @@ -#pragma warning disable IDE0390 using CSharpEssentials.Errors; using CSharpEssentials.ResultPattern; using Polly; @@ -49,7 +48,7 @@ public static async ValueTask RetryIfFailed( Delay = effectiveDelay, BackoffType = exponentialBackoff ? DelayBackoffType.Exponential : DelayBackoffType.Constant, ShouldHandle = new PredicateBuilder() - .HandleResult(r => r.IsFailure) + .HandleResult(r => IsRetryable(r)) }) .Build(); @@ -58,6 +57,20 @@ public static async ValueTask RetryIfFailed( 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; + } + private static bool IsRetryable(Result result) { if (result.IsSuccess) diff --git a/CSharpEssentials.Resilience/Readme.MD b/CSharpEssentials.Resilience/Readme.MD index 552c2df..1aa9771 100644 --- a/CSharpEssentials.Resilience/Readme.MD +++ b/CSharpEssentials.Resilience/Readme.MD @@ -29,7 +29,7 @@ Result order = await ResiliencePolicy // Circuit Breaker + Fallback Result product = await ResiliencePolicy .Create() - .WithCircuitBreaker(samplingWindow: 10, failureRatio: 0.5) + .WithCircuitBreaker(samplingDuration: 10, failureRatio: 0.5) .WithFallback(ct => _cache.GetAsync(id, ct)) .ExecuteAsync(() => _productService.GetProduct(id)); ``` @@ -96,7 +96,7 @@ Result result = await ResiliencePolicy .Create() .WithRetry(3) .WithTimeout(TimeSpan.FromSeconds(5)) - .WithCircuitBreaker(samplingWindow: 10) + .WithCircuitBreaker(samplingDuration: 10) .ExecuteAsync(() => _api.GetData()); ``` diff --git a/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs b/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs index 3659350..5df92dc 100644 --- a/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs +++ b/CSharpEssentials.Tests/Resilience/ResilienceResultExtensionsTests.cs @@ -230,21 +230,20 @@ public async Task RetryIfFailed_NonGeneric_Should_Retry_On_Conflict() } [Fact] - public async Task RetryIfFailed_NonGeneric_Should_Retry_On_Unauthorized() + public async Task RetryIfFailed_NonGeneric_Should_Not_Retry_On_Unauthorized() { int attempts = 0; Func> operation = _ => { attempts++; - if (attempts < 2) - return Task.FromResult(Result.Failure(Error.Unauthorized())); - return Task.FromResult(Result.Success()); + return Task.FromResult(Result.Failure(Error.Unauthorized())); }; Result result = await operation.RetryIfFailed(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(10)); - result.IsSuccess.Should().BeTrue(); - attempts.Should().Be(2); + result.IsFailure.Should().BeTrue(); + result.FirstError.Type.Should().Be(ErrorType.Unauthorized); + attempts.Should().Be(1); } [Fact] diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index ca7cac8..b7ba570 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -697,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 | From f89de5943ee584ab31abc94788b39530cd554b44 Mon Sep 17 00:00:00 2001 From: Erdem Date: Sat, 30 May 2026 23:01:58 +0300 Subject: [PATCH 08/11] fix(http): restore default 30s timeout, preserve backward-compatible return types --- .../HttpClientResilienceExtensions.cs | 104 ++++++++++++------ .../ResiliencePolicy.cs | 3 + .../ResiliencePolicyT.cs | 3 + .../HttpClientResilienceExtensionsTests.cs | 104 +++++++++++++----- 4 files changed, 151 insertions(+), 63 deletions(-) diff --git a/CSharpEssentials.Http/HttpClientResilienceExtensions.cs b/CSharpEssentials.Http/HttpClientResilienceExtensions.cs index 11993ff..e467bf0 100644 --- a/CSharpEssentials.Http/HttpClientResilienceExtensions.cs +++ b/CSharpEssentials.Http/HttpClientResilienceExtensions.cs @@ -1,72 +1,108 @@ using CSharpEssentials.Resilience; using CSharpEssentials.ResultPattern; +using Polly; namespace CSharpEssentials.Http; public static class HttpClientResilienceExtensions { - public static ResiliencePolicy CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) - => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay); + // ────────────────────────────────────────────────────── + // Legacy API — returns Polly ResiliencePipeline (preserved for backward compatibility) + // ────────────────────────────────────────────────────── - public static ResiliencePolicy CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) - => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay); + public static ResiliencePipeline CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay).ToPipeline(); - public static ResiliencePolicy CreateTimeoutPipeline(TimeSpan timeout) - => ResiliencePolicy.Create().WithTimeout(timeout); + public static ResiliencePipeline> CreateRetryPipeline(int maxRetryAttempts = 3, TimeSpan? delay = null) + => ResiliencePolicy.Create().WithRetry(maxRetryAttempts, delay).ToPipeline(); - public static ResiliencePolicy CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) - => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration); + public static ResiliencePipeline CreateTimeoutPipeline(TimeSpan timeout) + => ResiliencePolicy.Create().WithTimeout(timeout).ToPipeline(); - public static ResiliencePolicy CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) - => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration); + public static ResiliencePipeline CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration).ToPipeline(); - public static ResiliencePolicy CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) - { - ResiliencePolicy policy = ResiliencePolicy.Create() - .WithRetry(maxRetryAttempts, retryDelay); + public static ResiliencePipeline> CreateCircuitBreakerPipeline(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration).ToPipeline(); - if (timeout.HasValue) - { - policy = policy.WithTimeout(timeout.Value); - } + public static ResiliencePipeline CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) + { + TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); - return policy; + return ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay) + .WithTimeout(effectiveTimeout) + .ToPipeline(); } - public static ResiliencePolicy CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) + public static ResiliencePipeline> CreateResiliencePipeline(int maxRetryAttempts = 3, TimeSpan? timeout = null, TimeSpan? retryDelay = null) { - ResiliencePolicy policy = ResiliencePolicy.Create() - .WithRetry(maxRetryAttempts, retryDelay); + TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); - if (timeout.HasValue) - { - policy = policy.WithTimeout(timeout.Value); - } - - return policy; + return ResiliencePolicy.Create() + .WithRetry(maxRetryAttempts, retryDelay) + .WithTimeout(effectiveTimeout) + .ToPipeline(); } public static Task ExecuteAsResultAsync( - this ResiliencePolicy policy, + this ResiliencePipeline pipeline, Func> callback, CancellationToken cancellationToken = default) { - return policy.ExecuteAsync(callback, cancellationToken); + return ResiliencePolicy.FromPipeline(pipeline).ExecuteAsync(callback, cancellationToken); } public static Task> ExecuteAsResultAsync( - this ResiliencePolicy policy, + this ResiliencePipeline pipeline, Func>> callback, CancellationToken cancellationToken = default) { - return policy.ExecuteAsync(callback, cancellationToken); + return ResiliencePolicy.FromPipeline(pipeline).ExecuteAsync(callback, cancellationToken); } public static Task> ExecuteAsResultAsync( - this ResiliencePolicy policy, + this ResiliencePipeline> pipeline, Func>> callback, CancellationToken cancellationToken = default) { - return policy.ExecuteAsync(callback, cancellationToken); + return ResiliencePolicy.FromPipeline(pipeline).ExecuteAsync(callback, cancellationToken); + } + + // ────────────────────────────────────────────────────── + // New API — returns ResiliencePolicy / ResiliencePolicy + // ────────────────────────────────────────────────────── + + 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); + + public static ResiliencePolicy CreateCircuitBreakerPolicy(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null) + => ResiliencePolicy.Create().WithCircuitBreaker(minimumThroughput, samplingDuration, breakDuration); + + 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); + } + + 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); } } diff --git a/CSharpEssentials.Resilience/ResiliencePolicy.cs b/CSharpEssentials.Resilience/ResiliencePolicy.cs index 37704e8..2436787 100644 --- a/CSharpEssentials.Resilience/ResiliencePolicy.cs +++ b/CSharpEssentials.Resilience/ResiliencePolicy.cs @@ -16,6 +16,9 @@ private ResiliencePolicy(ResiliencePipeline 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(); diff --git a/CSharpEssentials.Resilience/ResiliencePolicyT.cs b/CSharpEssentials.Resilience/ResiliencePolicyT.cs index 439ca91..5195cc4 100644 --- a/CSharpEssentials.Resilience/ResiliencePolicyT.cs +++ b/CSharpEssentials.Resilience/ResiliencePolicyT.cs @@ -16,6 +16,9 @@ private ResiliencePolicy(ResiliencePipeline> 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 diff --git a/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs b/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs index 1d2185c..eaf5320 100644 --- a/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs +++ b/CSharpEssentials.Tests/Http/HttpClientResilienceExtensionsTests.cs @@ -9,33 +9,33 @@ 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() { - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 1); - Result result = await policy.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() { - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 1); - Result result = await policy.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success(42))); + 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_Exception() + public async Task CreateRetryPolicy_Should_Retry_On_Exception() { int attempts = 0; - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); - Result result = await policy.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; if (attempts < 2) @@ -48,23 +48,23 @@ public async Task ExecuteAsResultAsync_Should_Retry_On_Exception() } [Fact] - public async Task ExecuteAsResultAsync_Should_Return_Failure_On_Persistent_Exception() + public async Task CreateRetryPolicy_Should_Return_Failure_On_Persistent_Exception() { - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 1, delay: TimeSpan.FromMilliseconds(10)); - Result result = await policy.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 ExecuteAsResultAsync_With_GenericPipeline_Should_Return_Value() + public async Task CreateRetryPolicy_Generic_Should_Retry_On_Failure() { - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await policy.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; if (attempts < 2) @@ -78,12 +78,12 @@ 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() { - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy(maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await policy.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; return Task.FromResult(Result.Success(42)); @@ -95,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.CreateResiliencePipeline( + 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() + { + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateResiliencePolicy( maxRetryAttempts: 1, timeout: TimeSpan.FromSeconds(1), retryDelay: TimeSpan.FromMilliseconds(10)); - Result result = await policy.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success(7))); + Result result = await policy.ExecuteAsync(_ => Task.FromResult(Result.Success(7))); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(7); @@ -113,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) { - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy( maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await policy.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; return Task.FromResult(Result.Failure(CreateError(errorType))); @@ -131,14 +148,14 @@ 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() { - ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPipeline( + ResiliencePolicy policy = HttpClientResilienceExtensions.CreateRetryPolicy( maxRetryAttempts: 2, delay: TimeSpan.FromMilliseconds(10)); int attempts = 0; - Result result = await policy.ExecuteAsResultAsync(_ => + Result result = await policy.ExecuteAsync(_ => { attempts++; if (attempts < 2) @@ -152,13 +169,42 @@ public async Task CreateRetryPipeline_Generic_Should_Retry_On_Conflict() } [Fact] - public void CreateCircuitBreakerPipeline_Should_Have_Sensible_Defaults() + public void CreateCircuitBreakerPolicy_Should_Have_Sensible_Defaults() { - ResiliencePolicy pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPipeline(minimumThroughput: 5); + ResiliencePolicy pipeline = HttpClientResilienceExtensions.CreateCircuitBreakerPolicy(minimumThroughput: 5); pipeline.Should().NotBeNull(); } + // ── Legacy API tests ── + + [Fact] + public async Task Legacy_CreateRetryPipeline_Should_Return_Success() + { + Polly.ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateRetryPipeline(maxRetryAttempts: 1); + + Result result = await pipeline.ExecuteAsResultAsync(_ => Task.FromResult(Result.Success())); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Legacy_CreateResiliencePipeline_Should_Apply_Default_Timeout() + { + Polly.ResiliencePipeline pipeline = HttpClientResilienceExtensions.CreateResiliencePipeline( + maxRetryAttempts: 1, + timeout: TimeSpan.FromSeconds(1)); + + Result result = await pipeline.ExecuteAsResultAsync(async ct => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return Result.Success(); + }); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("Resilience.Timeout"); + } + private static Error CreateError(ErrorType type) => type switch { ErrorType.Unauthorized => Error.Unauthorized(), From 209e576f71cae8b27f6fd4e472dd9350272b8f2f Mon Sep 17 00:00:00 2001 From: Erdem Date: Sat, 30 May 2026 23:54:27 +0300 Subject: [PATCH 09/11] fix(resilience): FallbackAction exception handling, RetryIfFailed exception support, fix docs --- .../csharpessentials-resilience/SKILL.md | 7 ++--- .../Extensions/ResilienceResultExtensions.cs | 28 +++++++++++++++---- .../Modules/ResiliencePolicy.Fallback.cs | 23 ++++++++++++--- CSharpEssentials.Resilience/Readme.MD | 14 ++++------ docs/API_REFERENCE.md | 12 ++++---- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/.well-known/agent-skills/csharpessentials-resilience/SKILL.md b/.well-known/agent-skills/csharpessentials-resilience/SKILL.md index 436a0a8..7ffc341 100644 --- a/.well-known/agent-skills/csharpessentials-resilience/SKILL.md +++ b/.well-known/agent-skills/csharpessentials-resilience/SKILL.md @@ -67,11 +67,10 @@ Result result = await policy.ExecuteAsync(ct => _api.GetValue(ct)); ## Delegate Extensions ```csharp -Result user = await (() => _db.GetUser(id)) - .WithRetry(3) - .WithTimeout(TimeSpan.FromSeconds(5)) - .ExecuteAsync(); +// 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); ``` diff --git a/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs b/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs index bd3b9d5..b343c9e 100644 --- a/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs +++ b/CSharpEssentials.Resilience/Extensions/ResilienceResultExtensions.cs @@ -24,12 +24,20 @@ public static async ValueTask> RetryIfFailed( BackoffType = exponentialBackoff ? DelayBackoffType.Exponential : DelayBackoffType.Constant, ShouldHandle = new PredicateBuilder>() .HandleResult(r => IsRetryable(r)) + .Handle() }) .Build(); - return await pipeline.ExecuteAsync( - async token => await operation(token), - cancellationToken); + 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( @@ -49,12 +57,20 @@ public static async ValueTask RetryIfFailed( BackoffType = exponentialBackoff ? DelayBackoffType.Exponential : DelayBackoffType.Constant, ShouldHandle = new PredicateBuilder() .HandleResult(r => IsRetryable(r)) + .Handle() }) .Build(); - return await pipeline.ExecuteAsync( - async token => await operation(token), - cancellationToken); + 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) diff --git a/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs index 7c685ed..2cf0a1b 100644 --- a/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs +++ b/CSharpEssentials.Resilience/Modules/ResiliencePolicy.Fallback.cs @@ -1,3 +1,4 @@ +using CSharpEssentials.Errors; using CSharpEssentials.ResultPattern; using Polly; using Polly.Fallback; @@ -23,8 +24,15 @@ public ResiliencePolicy WithFallback(Func> fallbac .Handle(), FallbackAction = async args => { - T fallbackValue = await fallbackAsync(args.Context.CancellationToken); - return Outcome.FromResult(Result.Success(fallbackValue)); + 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(); @@ -54,8 +62,15 @@ public ResiliencePolicy WithFallback(Func>> .Handle(), FallbackAction = async args => { - Result fallbackResult = await fallbackAsync(args.Context.CancellationToken); - return Outcome.FromResult(fallbackResult); + try + { + Result fallbackResult = await fallbackAsync(args.Context.CancellationToken); + return Outcome.FromResult(fallbackResult); + } + catch (Exception ex) + { + return Outcome.FromResult(Result.Failure(Error.Exception(ex))); + } } }) .Build(); diff --git a/CSharpEssentials.Resilience/Readme.MD b/CSharpEssentials.Resilience/Readme.MD index 1aa9771..6da4c92 100644 --- a/CSharpEssentials.Resilience/Readme.MD +++ b/CSharpEssentials.Resilience/Readme.MD @@ -39,14 +39,11 @@ Result product = await ResiliencePolicy ```csharp using CSharpEssentials.Resilience; -// Fluent builder on delegates -Result user = await (() => _db.GetUser(id)) - .WithRetry(3) - .WithTimeout(TimeSpan.FromSeconds(5)) - .ExecuteAsync(); - -// Direct execution +// 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 @@ -84,10 +81,11 @@ Result user = await ResiliencePolicy Resilience-specific errors are returned as `Error` values: -- `Error.Failure("Resilience.RetryExhausted", "All retry attempts exhausted.")` - `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 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b7ba570..e55d0ea 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -768,10 +768,11 @@ The generic variant automatically filters retryable errors — `Unauthorized`, ` ### Delegate Extensions ```csharp -Result user = await (() => _db.GetUser(id)) - .WithRetry(3) - .WithTimeout(TimeSpan.FromSeconds(5)) - .ExecuteAsync(); +// 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 @@ -786,9 +787,10 @@ Result result = await getUser.RetryIfFailed(maxAttempts: 3); | Error Code | When | |-----------|------| | `Resilience.Timeout` | Operation exceeded timeout | -| `Resilience.RetryExhausted` | All retry attempts failed | | `Resilience.CircuitBroken` | Circuit breaker is open | +When retries exhaust, the last exception is returned as `ErrorType.Unexpected`. + ### Configuration Options ```csharp From 40f7d8f7afa1b67e1c48ddf0d6f111a7917cdee0 Mon Sep 17 00:00:00 2001 From: Erdem Date: Sun, 31 May 2026 00:16:12 +0300 Subject: [PATCH 10/11] fix(resilience): null guard for default struct, fix Quick Start examples --- CSharpEssentials.Resilience/Readme.MD | 8 ++++---- CSharpEssentials.Resilience/ResiliencePolicy.cs | 12 ++++++++---- CSharpEssentials.Resilience/ResiliencePolicyT.cs | 6 ++++-- docs/API_REFERENCE.md | 8 ++++---- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/CSharpEssentials.Resilience/Readme.MD b/CSharpEssentials.Resilience/Readme.MD index 6da4c92..0f2d1f5 100644 --- a/CSharpEssentials.Resilience/Readme.MD +++ b/CSharpEssentials.Resilience/Readme.MD @@ -17,21 +17,21 @@ using CSharpEssentials.Resilience; Result user = await ResiliencePolicy .Create() .WithRetry(maxAttempts: 3, delay: TimeSpan.FromSeconds(1)) - .ExecuteAsync(() => _db.GetUser(id)); + .ExecuteAsync(_ => _db.GetUser(id)); // Retry + Timeout Result order = await ResiliencePolicy .Create() .WithRetry(3) .WithTimeout(TimeSpan.FromSeconds(5)) - .ExecuteAsync(() => _orderService.GetOrder(id)); + .ExecuteAsync(_ => _orderService.GetOrder(id)); // Circuit Breaker + Fallback Result product = await ResiliencePolicy .Create() - .WithCircuitBreaker(samplingDuration: 10, failureRatio: 0.5) + .WithCircuitBreaker(minimumThroughput: 10, failureRatio: 0.5) .WithFallback(ct => _cache.GetAsync(id, ct)) - .ExecuteAsync(() => _productService.GetProduct(id)); + .ExecuteAsync(_ => _productService.GetProduct(id)); ``` ## Delegate Extensions diff --git a/CSharpEssentials.Resilience/ResiliencePolicy.cs b/CSharpEssentials.Resilience/ResiliencePolicy.cs index 2436787..dce28b7 100644 --- a/CSharpEssentials.Resilience/ResiliencePolicy.cs +++ b/CSharpEssentials.Resilience/ResiliencePolicy.cs @@ -66,9 +66,10 @@ public async Task ExecuteAsync( Func action, CancellationToken cancellationToken = default) { + ResiliencePipeline pipeline = _pipeline ?? ResiliencePipeline.Empty; try { - await _pipeline.ExecuteAsync(async token => await action(token), cancellationToken); + await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); return Result.Success(); } catch (Exception ex) @@ -81,9 +82,10 @@ 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); + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); } catch (Exception ex) { @@ -95,9 +97,10 @@ 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); + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); } catch (Exception ex) { @@ -109,9 +112,10 @@ 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); + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); } catch (Exception ex) { diff --git a/CSharpEssentials.Resilience/ResiliencePolicyT.cs b/CSharpEssentials.Resilience/ResiliencePolicyT.cs index 5195cc4..cdcb544 100644 --- a/CSharpEssentials.Resilience/ResiliencePolicyT.cs +++ b/CSharpEssentials.Resilience/ResiliencePolicyT.cs @@ -40,9 +40,10 @@ public async Task> ExecuteAsync( Func> action, CancellationToken cancellationToken = default) { + ResiliencePipeline> pipeline = _pipeline ?? new ResiliencePipelineBuilder>().Build(); try { - return await _pipeline.ExecuteAsync( + return await pipeline.ExecuteAsync( async token => { T value = await action(token); @@ -60,9 +61,10 @@ 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); + return await pipeline.ExecuteAsync(async token => await action(token), cancellationToken); } catch (Exception ex) { diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e55d0ea..bef8eb1 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -724,21 +724,21 @@ using CSharpEssentials.Resilience; Result user = await ResiliencePolicy .Create() .WithRetry(maxAttempts: 3, delay: TimeSpan.FromSeconds(1)) - .ExecuteAsync(() => _db.GetUser(id)); + .ExecuteAsync(_ => _db.GetUser(id)); // Retry + Timeout Result order = await ResiliencePolicy .Create() .WithRetry(3) .WithTimeout(TimeSpan.FromSeconds(5)) - .ExecuteAsync(() => _orderService.GetOrder(id)); + .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)); + .ExecuteAsync(_ => _productService.GetProduct(id)); ``` ### ResiliencePolicy @@ -808,7 +808,7 @@ var options = new ResiliencePolicyOptions Result user = await ResiliencePolicy .Create(options) - .ExecuteAsync(() => _db.GetUser(id)); + .ExecuteAsync(_ => _db.GetUser(id)); ``` --- From a2b4fb1247ec83061f7d2881c30a3c1342a0ef3a Mon Sep 17 00:00:00 2001 From: Erdem Date: Sun, 31 May 2026 00:26:44 +0300 Subject: [PATCH 11/11] fix(resilience): fix remaining broken examples in Readme.MD --- CSharpEssentials.Resilience/Readme.MD | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CSharpEssentials.Resilience/Readme.MD b/CSharpEssentials.Resilience/Readme.MD index 0f2d1f5..f9277af 100644 --- a/CSharpEssentials.Resilience/Readme.MD +++ b/CSharpEssentials.Resilience/Readme.MD @@ -74,7 +74,7 @@ var options = new ResiliencePolicyOptions Result user = await ResiliencePolicy .Create(options) - .ExecuteAsync(() => _db.GetUser(id)); + .ExecuteAsync(_ => _db.GetUser(id)); ``` ## Error Handling @@ -94,8 +94,8 @@ Result result = await ResiliencePolicy .Create() .WithRetry(3) .WithTimeout(TimeSpan.FromSeconds(5)) - .WithCircuitBreaker(samplingDuration: 10) - .ExecuteAsync(() => _api.GetData()); + .WithCircuitBreaker(minimumThroughput: 10) + .ExecuteAsync(_ => _api.GetData()); ``` ## Polly Integration @@ -114,5 +114,5 @@ Result result = await ResiliencePolicy BackoffType = DelayBackoffType.Exponential }); }) - .ExecuteAsync(() => _api.GetData()); + .ExecuteAsync(_ => _api.GetData()); ```