diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..05a74bb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,103 @@ +# EditorConfig — https://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{csproj,props,targets,xml,config,nuspec}] +indent_size = 2 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# Namespace +csharp_style_namespace_declarations = file_scoped:suggestion + +# var preferences +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_indexers = when_on_single_line:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:suggestion +csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion +csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null checking +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = always:suggestion + +# this. qualification +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Language keywords vs BCL types +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Parentheses +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion + +# Newline preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true + +# Indentation +csharp_indent_case_contents = true +csharp_indent_switch_labels = true + +# Spacing +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false + +# Wrapping +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true + +# Using directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Naming conventions +dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_style.camel_case_underscore.required_prefix = _ +dotnet_naming_style.camel_case_underscore.capitalization = camel_case diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1d3af10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Report a bug in Resilion +title: '' +labels: bug +assignees: '' + +--- + +**Resilion version** + + +**.NET SDK version** + + +**Target framework** + + +**Describe the bug** +A clear description of what the bug is. + +**Minimal reproduction** + + +```csharp +var pipeline = Pipeline.Create(builder => builder + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddTimeout(TimeSpan.FromSeconds(10))); + +await pipeline.ExecuteAsync(async (state, ct) => +{ + // Your operation here + return null; +}, (object?)null, CancellationToken.None); +``` + +**Expected behavior** +What you expected to happen. + +**Actual behavior** +What actually happened. Include any exceptions or stack traces. + +**Additional context** +Any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..41d9423 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest a feature for Resilion +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem?** +A clear description of what the problem is. + +**Describe the solution you'd like** +A clear description of what you want to happen. + +**Describe alternatives you've considered** +Any alternative solutions or features you've considered. + +**Additional context** +Any other context, code examples, or references about the feature request. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..004596d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + DOTNET_VERSION: '9.0.x' + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release --no-restore diff --git a/.github/workflows/release-nuget.yml b/.github/workflows/release-nuget.yml new file mode 100644 index 0000000..dc2d725 --- /dev/null +++ b/.github/workflows/release-nuget.yml @@ -0,0 +1,77 @@ +name: Create a release on NuGet + +on: + push: + tags: + - "v*.*.*" + - "v*.*.*-pre" + +env: + DOTNET_VERSION: '9.0.x' + +jobs: + release-nuget: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Verify commit exists in origin/master + run: | + git fetch --no-tags --prune origin +refs/heads/*:refs/remotes/origin/* + git merge-base --is-ancestor origin/master HEAD || { echo "Tag is not on origin/master. Aborting release."; exit 1; } + + - name: Get version information from tag + id: get_version + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Validate SemVer format (basic check for X.Y.Z or X.Y.Z-prerelease) + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then + echo "Invalid version format: $VERSION" + exit 1 + fi + echo "version_without_v=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build + env: + VERSION: ${{ steps.get_version.outputs.version_without_v }} + run: dotnet build -c Release -p:Version="${VERSION}" + + - name: Test + run: dotnet test -c Release --no-build + + - name: Pack NuGet packages + env: + VERSION: ${{ steps.get_version.outputs.version_without_v }} + run: | + dotnet pack src/Resilion/Resilion.csproj -c Release --no-build -p:Version="${VERSION}" + dotnet pack src/Resilion.Extensions/Resilion.Extensions.csproj -c Release --no-build -p:Version="${VERSION}" + dotnet pack src/Resilion.RateLimiting/Resilion.RateLimiting.csproj -c Release --no-build -p:Version="${VERSION}" + + - name: NuGet login (OIDC - trusted publishing) + id: login + uses: NuGet/login@v1 + with: + user: FreakyAli + + - name: Push NuGet packages + run: | + VERSION="${{ steps.get_version.outputs.version_without_v }}" + NUPKGS=$(find . -name "*.nupkg" -path "*${VERSION}*" ! -name "*.symbols.nupkg") + COUNT=$(echo "$NUPKGS" | wc -l) + if [ "$COUNT" -ne 3 ]; then + echo "Expected exactly 3 matching .nupkg files, found $COUNT. Aborting." + exit 1 + fi + echo "$NUPKGS" | while read -r nupkg; do + echo "Pushing: $nupkg" + dotnet nuget push "$nupkg" -s https://api.nuget.org/v3/index.json -k ${{ steps.login.outputs.NUGET_API_KEY }} + done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d98c6ec --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +name: test +run-name: '[Test] ${{ github.event.head_commit.message || github.event.pull_request.title || github.ref_name }}' + +on: + pull_request: + branches: + - "*" + push: + branches: + - "master" + +permissions: + contents: read + checks: write + pull-requests: write + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build Solution + run: dotnet build --configuration Release --no-restore + + - name: Run Unit Tests + run: dotnet test --configuration Release --no-build -- --report-trx --results-directory ${{ github.workspace }}/TestResults + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-${{ github.run_id }} + path: TestResults/*.trx + retention-days: 7 + + - name: Publish Test Results + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Unit Test Results + path: TestResults/*.trx + reporter: dotnet-trx + fail-on-error: false + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index d5a18de..1e5b4f1 100644 --- a/.gitignore +++ b/.gitignore @@ -269,6 +269,7 @@ ClientBin/ *.pfx *.publishsettings orleans.codegen.cs +.DS_Store # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) @@ -427,3 +428,35 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# AI coding agents +.claude/ +.cursor/ +.cursorignore +.cursorindexingignore +.cursorrules +.codex/ +AGENTS.md +.copilot/ +.windsurf/ +.windsurfrules +.codeium/ +.cody/ +.continue/ +.aider* +.aiderignore +.cline/ +.clinerules +.roo/ +.roorules +.devin/ +.junie/ +.augment/ +.amazonq/ +.gemini/ +.tabnine/ +.tabby/ +.bolt/ +.v0/ +.idx/ +.sourcery/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..28ed043 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial project structure and build configuration +- Core abstractions: `Outcome`, `ResilienceContext`, `Pipeline`, `Strategy` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..08c93ca --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing to Resilion + +Thank you for your interest in contributing to Resilion! + +## Getting Started + +### Prerequisites + +- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) or later + +### Local Setup + +```text +git clone https://github.com/FreakyAli/Resilion.git +cd Resilion +dotnet restore +dotnet build +``` + +## Project Structure + +```text +src/ + Resilion/ Core library (zero dependencies) + Resilion.Extensions/ DI, logging, telemetry integration + Resilion.RateLimiting/ Rate limiting strategy + +tests/ + Resilion.Tests/ Core strategy and pipeline tests + Resilion.Extensions.Tests/ DI and telemetry tests + +benchmarks/ + Resilion.Benchmarks/ Performance benchmarks (not in .sln) + +samples/ + Resilion.Samples/ Usage examples +``` + +## Development Workflow + +1. Fork the repository +2. Create a feature branch from `master` +3. Make your changes +4. Write or update tests +5. Ensure all tests pass: `dotnet test` +6. Submit a pull request + +### Running Tests + +```bash +# Run all tests +dotnet test + +# Run specific test project +dotnet test tests/Resilion.Tests + +# Run with filter +dotnet test --filter "FullyQualifiedName~RetryTests" +``` + +### Running Benchmarks + +Benchmarks are not part of the solution file. Run them directly: + +```bash +cd benchmarks/Resilion.Benchmarks +dotnet run -c Release +``` + +## Code Style + +This project uses an `.editorconfig` file for consistent formatting: + +- File-scoped namespaces +- Allman bracing (new line before all braces) +- `var` everywhere +- Private fields prefixed with `_` +- Nullable reference types enabled + +## Submitting Changes + +- Rebase onto `master` before submitting +- Write clear commit messages +- Include tests for new functionality +- Update documentation where applicable +- Ensure CI passes + +## Reporting Issues + +- Use the [bug report](https://github.com/FreakyAli/Resilion/issues/new?template=bug_report.md) template for bugs +- Use the [feature request](https://github.com/FreakyAli/Resilion/issues/new?template=feature_request.md) template for enhancements diff --git a/LICENSE b/LICENSE index 261eeb9..5d12bb9 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 FreakyAli Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 4ca72d7..9e8a7ca 100644 --- a/README.md +++ b/README.md @@ -1 +1,413 @@ -# Resilion \ No newline at end of file +# Resilion + +**A modern resilience library for .NET.** Retry, circuit breaker, timeout, fallback, rate limiting, hedging, and pipeline composition — with zero external dependencies in the core package. + +[![CI](https://github.com/FreakyAli/Resilion/actions/workflows/ci.yml/badge.svg)](https://github.com/FreakyAli/Resilion/actions/workflows/ci.yml) +[![Tests](https://github.com/FreakyAli/Resilion/actions/workflows/test.yml/badge.svg)](https://github.com/FreakyAli/Resilion/actions/workflows/test.yml) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +[![NuGet](https://img.shields.io/nuget/v/Resilion.svg)](https://www.nuget.org/packages/Resilion) + +## Quick Start + +```csharp +using Resilion; + +// Create a resilience pipeline with multiple strategies +var pipeline = Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(30)) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 10, + }) + .AddTimeout(TimeSpan.FromSeconds(5))); + +// Execute with the pipeline +var result = await pipeline.ExecuteAsync( + static (httpClient, ct) => httpClient.GetStringAsync("https://api.example.com/data", ct), + new HttpClient()); +``` + +## Why Resilion? + +Resilion is designed for experienced .NET developers who want powerful resilience patterns without complexity: + +### Zero Dependencies in Core +The `Resilion` package has **zero external dependencies**. Everything you need for production resilience is built-in. + +### Simple, Fluent API +One `Pipeline.Create()` call. Chain strategies. Build. That's it. No framework to learn, no builder patterns, no magic. + +```csharp +var pipeline = Pipeline.Create(b => b + .AddRetry(options) + .AddCircuitBreaker(options) + .AddTimeout(duration)); +``` + +### Sync and Async +Both `Execute` and `ExecuteAsync` with **true sync implementations** — not sync-over-async. No blocking calls, no artificial overhead. + +```csharp +// Synchronous execution +var result = pipeline.Execute(state => DoWork(state), httpClient); + +// Asynchronous execution +var result = await pipeline.ExecuteAsync(async (state, ct) => + await DoWorkAsync(state, ct), httpClient); +``` + +### Outcome-Based Resilience +No `PredicateBuilder` or `PredicateResult` chains. Just `Func, bool>`: + +```csharp +var pipeline = Pipeline.Create(b => b.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 3, + ShouldHandle = outcome => + outcome.Exception is HttpRequestException // Handle exceptions + || (outcome.TryGetResult(out var r) && (int)r.StatusCode >= 500), // or result-based + })); +``` + +### Simple Callbacks +Assign callbacks directly. No `ValueTask` wrapping for synchronous logging: + +```csharp +new RetryStrategyOptions +{ + OnRetry = (context) => + { + logger.LogWarning($"Retry attempt {context.AttemptNumber}"); + } +} +``` + +### Composable Pipelines +Combine pre-built pipelines into larger ones: + +```csharp +var basePipeline = Pipeline.Create(b => b + .AddRetry(retryOptions) + .AddCircuitBreaker(cbOptions)); + +var fullPipeline = Pipeline.Create(b => b + .AddRateLimiter(rlOptions) + .AddPipeline(basePipeline)); +``` + +### Allocation-Conscious Design +- `Outcome` is a struct +- `ResilienceContext` is pooled +- State parameters avoid closures +- Designed for performance-critical paths + +## Installation + +### Core Package +```bash +dotnet add package Resilion +``` + +### With Dependency Injection & Telemetry +```bash +dotnet add package Resilion.Extensions +``` + +### Rate Limiting Strategy +```bash +dotnet add package Resilion.RateLimiting +``` + +## Packages & Dependencies + +| Package | Purpose | Dependencies | +|---------|---------|-------------| +| **Resilion** | Core library with all built-in strategies | None | +| **Resilion.Extensions** | DI registration, telemetry (Meter/ActivitySource), structured logging | Microsoft.Extensions.* | +| **Resilion.RateLimiting** | Rate limiting strategy with multiple algorithms | System.Threading.RateLimiting | + +## Strategies + +### Retry + +Automatically retry failed operations with customizable delay strategies. + +```csharp +var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions +{ + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + UseJitter = true, // Decorrelated jitter (on by default) +})); +``` + +**Delay Strategies:** +- `Exponential` — Exponential backoff: 1s, 2s, 4s, 8s, ... +- `Linear` — Linear backoff: 1s, 2s, 3s, 4s, ... +- `Constant` — Fixed delay between retries +- `Custom` — Supply your own delay function + +**Result-Based Retry** on typed pipelines: + +```csharp +var pipeline = Pipeline.Create(b => b.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + ShouldHandle = outcome => + outcome.Exception is HttpRequestException + || (outcome.TryGetResult(out var r) && (int)r.StatusCode >= 500), + })); +``` + +**Use when:** +- Calling unreliable remote services +- Handling transient failures (network glitches, temporary outages) +- Need to respect rate limits with backoff + +### Timeout + +Enforce operation timeouts with cooperative cancellation. + +```csharp +var pipeline = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(10))); +``` + +**Key Points:** +- Uses `CancellationToken` — the operation must observe it +- Perfect for async I/O operations +- Can be stacked (total timeout + per-attempt timeout) + +**Use when:** +- Preventing indefinite hangs on remote calls +- Enforcing SLA boundaries +- Protecting against slow endpoints + +### Circuit Breaker + +Prevent cascading failures by stopping requests when failure rates are too high. + +```csharp +var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions +{ + FailureRatioThreshold = 0.5, // Trip at 50% failure rate + MinimumThroughput = 10, // Need 10+ calls before evaluating + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(30), +})); +``` + +**States:** +- **Closed** — Normal operation, requests pass through +- **Open** — Too many failures, requests rejected immediately +- **Half-Open** — Testing if service has recovered + +**Use when:** +- Protecting against cascading failures +- Integrating with dependent services +- Need fast-fail when a service is down + +### Fallback + +Provide a fallback value or action when operations fail. + +```csharp +var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "default-value", // Static value + })); + +// Or with a function: +var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = (context) => GetCachedValue() ?? "default", + })); + +// Or async: +var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackActionAsync = async (context, ct) => + await GetCachedValueAsync(ct) ?? "default", + })); +``` + +**Use when:** +- You have a sensible default to return +- Calling a secondary data source +- Providing degraded service instead of failure + +### Rate Limiting + +Control request throughput to prevent overload. + +```csharp +using Resilion.RateLimiting; +using System.Threading.RateLimiting; + +var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions +{ + PermitLimit = 10, + QueueLimit = 0, // Reject excess requests +}); + +var pipeline = Pipeline.Create(b => b.AddRateLimiter( + new RateLimiterStrategyOptions { RateLimiter = limiter })); +``` + +**Built-in Limiters:** +- `ConcurrencyLimiter` — Limit concurrent operations +- `TokenBucketRateLimiter` — Token bucket algorithm +- `SlidingWindowRateLimiter` — Sliding window rate limiting +- Custom implementations via `System.Threading.RateLimiting` + +**Use when:** +- Limiting load on a resource +- Protecting downstream services +- Controlling API consumption + +### Hedging + +Send duplicate requests if the first one is slow, returning the fastest response. + +```csharp +var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.FromSeconds(2), + })); +``` + +**Hedging Delay:** +- `TimeSpan.Zero` — Fire all requests in parallel +- `TimeSpan.FromSeconds(2)` — Wait 2 seconds before sending next request +- `System.Threading.Timeout.InfiniteTimeSpan` — Sequential requests (no hedging, just fallback) + +**Use when:** +- Reducing tail latency in latency-sensitive systems +- You can afford duplicate requests +- Calling idempotent endpoints + +## Strategy Ordering (Canonical Pipeline) + +Strategies execute **outermost to innermost**. The recommended order is: + +```csharp +Pipeline.Create(b => b + .AddRateLimiter(...) // 1. Shed load FIRST + .AddTimeout(TimeSpan.FromSeconds(30)) // 2. Total timeout across all retries + .AddRetry(...) // 3. Retry failures + .AddCircuitBreaker(...) // 4. Track per-attempt success/failure + .AddTimeout(TimeSpan.FromSeconds(5))); // 5. Per-attempt timeout +``` + +**Why this order?** +1. **Rate Limit** prevents the system from being overwhelmed +2. **Outer Timeout** sets a hard boundary for the entire operation +3. **Retry** gives transient failures a chance to succeed +4. **Circuit Breaker** protects downstream services and fast-fails when they're down +5. **Inner Timeout** per-request prevents individual attempts from hanging + +Different use cases may require different orderings — this is the safe default. + +## Dependency Injection + +Register pipelines with Microsoft.Extensions.DependencyInjection: + +```csharp +using Resilion.Extensions; + +services.AddResiliencePipeline("http-api", b => b + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddTimeout(TimeSpan.FromSeconds(10))); + +// Later, resolve: +var registry = serviceProvider.GetRequiredService>(); +var pipeline = registry.GetPipeline("http-api"); + +var result = await pipeline.ExecuteAsync( + async (client, ct) => await client.GetStringAsync(url, ct), + httpClient); +``` + +## Telemetry + +Resilion integrates with `System.Diagnostics`: + +```csharp +// Metrics via System.Diagnostics.Metrics.Meter +var meter = new Meter("Resilion"); +var retryCount = meter.CreateCounter("resilion.retry.attempt_count"); + +// Activity tracking via ActivitySource +var activitySource = new ActivitySource("Resilion"); +using var activity = activitySource.StartActivity("pipeline.execute"); +``` + +Structured logging is also available through callbacks on strategy options. + +## Supported .NET Versions + +- **.NET 8.0+** + +Resilion is built for modern .NET with full support for: +- Top-level statements +- Records and nullable reference types +- Async/await patterns +- Source generators (future features) + +## Project Structure + +```text +src/ + Resilion/ Core library (zero dependencies) + Resilion.Extensions/ DI, telemetry, structured logging + Resilion.RateLimiting/ Rate limiting strategy +tests/ + Resilion.Tests/ Core strategy and pipeline tests + Resilion.Extensions.Tests/ DI and telemetry tests +benchmarks/ + Resilion.Benchmarks/ Performance benchmarks (BenchmarkDotNet) +samples/ + Resilion.Samples/ Real-world usage examples +docs/ + *.md Feature and architecture documentation +``` + +## Performance + +Resilion is designed for high-performance, latency-sensitive scenarios: + +- Struct-based `Outcome` eliminates heap allocations +- Pooled `ResilienceContext` for request-scoped state +- Zero-allocation happy path for most strategies +- Benchmarks included (see `benchmarks/` folder) + +Compare with alternatives like Polly to see allocation profiles and throughput characteristics. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on: +- Code style and standards +- Testing requirements +- PR expectations +- Building and running tests locally + +## License + +[Apache-2.0](LICENSE) + +--- + +**Built for developers who care about reliability.** diff --git a/Resilion.sln b/Resilion.sln new file mode 100644 index 0000000..d777650 --- /dev/null +++ b/Resilion.sln @@ -0,0 +1,135 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion", "src\Resilion\Resilion.csproj", "{6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Extensions", "src\Resilion.Extensions\Resilion.Extensions.csproj", "{F5340000-510D-40E8-8A20-FB2C721382E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.RateLimiting", "src\Resilion.RateLimiting\Resilion.RateLimiting.csproj", "{49A634E0-7EA3-48D6-B15D-93E84738A282}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Tests", "tests\Resilion.Tests\Resilion.Tests.csproj", "{7C800530-ADA9-48CF-95B0-2E4ECF7A5041}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Extensions.Tests", "tests\Resilion.Extensions.Tests\Resilion.Extensions.Tests.csproj", "{A0EC64D8-078D-43A7-AA73-74AF5319CABB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Samples", "samples\Resilion.Samples\Resilion.Samples.csproj", "{65299870-8203-4726-B350-B4C85586D162}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{9C7D7E4D-8F4E-4B5A-9E6D-1F8C3B2A5D6E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Benchmarks", "benchmarks\Resilion.Benchmarks\Resilion.Benchmarks.csproj", "{7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Debug|x64.Build.0 = Debug|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Debug|x86.Build.0 = Debug|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Release|Any CPU.Build.0 = Release|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Release|x64.ActiveCfg = Release|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Release|x64.Build.0 = Release|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Release|x86.ActiveCfg = Release|Any CPU + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7}.Release|x86.Build.0 = Release|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Debug|x64.Build.0 = Debug|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Debug|x86.Build.0 = Debug|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Release|Any CPU.Build.0 = Release|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Release|x64.ActiveCfg = Release|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Release|x64.Build.0 = Release|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Release|x86.ActiveCfg = Release|Any CPU + {F5340000-510D-40E8-8A20-FB2C721382E8}.Release|x86.Build.0 = Release|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Debug|x64.ActiveCfg = Debug|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Debug|x64.Build.0 = Debug|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Debug|x86.ActiveCfg = Debug|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Debug|x86.Build.0 = Debug|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Release|Any CPU.Build.0 = Release|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Release|x64.ActiveCfg = Release|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Release|x64.Build.0 = Release|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Release|x86.ActiveCfg = Release|Any CPU + {49A634E0-7EA3-48D6-B15D-93E84738A282}.Release|x86.Build.0 = Release|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Debug|x64.Build.0 = Debug|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Debug|x86.Build.0 = Debug|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Release|Any CPU.Build.0 = Release|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Release|x64.ActiveCfg = Release|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Release|x64.Build.0 = Release|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Release|x86.ActiveCfg = Release|Any CPU + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041}.Release|x86.Build.0 = Release|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Debug|x64.Build.0 = Debug|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Debug|x86.Build.0 = Debug|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Release|Any CPU.Build.0 = Release|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Release|x64.ActiveCfg = Release|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Release|x64.Build.0 = Release|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Release|x86.ActiveCfg = Release|Any CPU + {A0EC64D8-078D-43A7-AA73-74AF5319CABB}.Release|x86.Build.0 = Release|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Debug|x64.ActiveCfg = Debug|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Debug|x64.Build.0 = Debug|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Debug|x86.ActiveCfg = Debug|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Debug|x86.Build.0 = Debug|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Release|Any CPU.Build.0 = Release|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Release|x64.ActiveCfg = Release|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Release|x64.Build.0 = Release|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Release|x86.ActiveCfg = Release|Any CPU + {65299870-8203-4726-B350-B4C85586D162}.Release|x86.Build.0 = Release|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Debug|x64.Build.0 = Debug|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Debug|x86.Build.0 = Debug|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Release|Any CPU.Build.0 = Release|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Release|x64.ActiveCfg = Release|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Release|x64.Build.0 = Release|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Release|x86.ActiveCfg = Release|Any CPU + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6F40F09C-4EC7-45ED-834A-AF7A02AD14E7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F5340000-510D-40E8-8A20-FB2C721382E8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {49A634E0-7EA3-48D6-B15D-93E84738A282} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {7C800530-ADA9-48CF-95B0-2E4ECF7A5041} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A0EC64D8-078D-43A7-AA73-74AF5319CABB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {65299870-8203-4726-B350-B4C85586D162} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C} = {9C7D7E4D-8F4E-4B5A-9E6D-1F8C3B2A5D6E} + EndGlobalSection +EndGlobal diff --git a/benchmarks/Resilion.Benchmarks/BenchmarkConfig.cs b/benchmarks/Resilion.Benchmarks/BenchmarkConfig.cs new file mode 100644 index 0000000..1b1fcea --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/BenchmarkConfig.cs @@ -0,0 +1,22 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; + +namespace Resilion.Benchmarks; + +/// +/// Shared configuration for all Resilion benchmarks. +/// Ensures consistent memory diagnostics across runs. +/// When built as multi-target (net8.0;net10.0), BenchmarkDotNet automatically +/// runs against each runtime. +/// +public class BenchmarkConfig : ManualConfig +{ + public BenchmarkConfig() + { + // Memory diagnostics to catch allocation regressions + AddDiagnoser(MemoryDiagnoser.Default); + + // Baseline for visual comparison + WithOption(ConfigOptions.DisableOptimizationsValidator, true); + } +} diff --git a/benchmarks/Resilion.Benchmarks/PipelineOverheadBenchmarks.cs b/benchmarks/Resilion.Benchmarks/PipelineOverheadBenchmarks.cs new file mode 100644 index 0000000..063f354 --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/PipelineOverheadBenchmarks.cs @@ -0,0 +1,147 @@ +using BenchmarkDotNet.Attributes; +using Polly; + +namespace Resilion.Benchmarks; + +/// +/// Measures the overhead of executing through a pipeline vs a direct call. +/// Answers: "What tax does Resilion add on the happy path?" +/// +[MemoryDiagnoser] +public class PipelineOverheadBenchmarks +{ + private Resilion.Pipeline _resilionEmpty = null!; + private Resilion.Pipeline _resilionRetry = null!; + private Resilion.Pipeline _resilionComposite = null!; + private ResiliencePipeline _pollyEmpty = null!; + private ResiliencePipeline _pollyRetry = null!; + private ResiliencePipeline _pollyComposite = null!; + + [GlobalSetup] + public void Setup() + { + // Resilion pipelines + _resilionEmpty = Resilion.Pipeline.Empty; + + _resilionRetry = Resilion.Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + })); + + _resilionComposite = Resilion.Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(30)) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 100, + }) + .AddTimeout(TimeSpan.FromSeconds(5))); + + // Polly pipelines + _pollyEmpty = ResiliencePipeline.Empty; + + _pollyRetry = new ResiliencePipelineBuilder() + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + }) + .Build(); + + _pollyComposite = new ResiliencePipelineBuilder() + .AddTimeout(TimeSpan.FromSeconds(30)) + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + }) + .AddCircuitBreaker(new Polly.CircuitBreaker.CircuitBreakerStrategyOptions + { + FailureRatio = 0.5, + MinimumThroughput = 100, + }) + .AddTimeout(TimeSpan.FromSeconds(5)) + .Build(); + } + + // ── Direct call (baseline) ─────────────────────────────────── + + [Benchmark(Baseline = true)] + public ValueTask DirectCall() + { + return new ValueTask("ok"); + } + + // ── Empty pipeline (no strategies) ─────────────────────────── + + [Benchmark] + public ValueTask Resilion_Empty() + { + return _resilionEmpty.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + + [Benchmark] + public ValueTask Polly_Empty() + { + return _pollyEmpty.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + + // ── Single retry strategy (happy path, no failures) ────────── + + [Benchmark] + public ValueTask Resilion_Retry_HappyPath() + { + return _resilionRetry.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + + [Benchmark] + public ValueTask Polly_Retry_HappyPath() + { + return _pollyRetry.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + + // ── Composite pipeline (happy path) ────────────────────────── + + [Benchmark] + public ValueTask Resilion_Composite_HappyPath() + { + return _resilionComposite.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + + [Benchmark] + public ValueTask Polly_Composite_HappyPath() + { + return _pollyComposite.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + + // ── Sync execution ─────────────────────────────────────────── + + [Benchmark] + public string Resilion_Retry_Sync() + { + return _resilionRetry.Execute( + static (state, ct) => "ok", + (object?)null); + } +} diff --git a/benchmarks/Resilion.Benchmarks/Program.cs b/benchmarks/Resilion.Benchmarks/Program.cs new file mode 100644 index 0000000..feb6004 --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/Program.cs @@ -0,0 +1,4 @@ +using BenchmarkDotNet.Running; +using Resilion.Benchmarks; + +BenchmarkSwitcher.FromAssembly(typeof(PipelineOverheadBenchmarks).Assembly).Run(args); diff --git a/benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs b/benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs new file mode 100644 index 0000000..310287c --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs @@ -0,0 +1,208 @@ +using BenchmarkDotNet.Attributes; +using Polly; + +namespace Resilion.Benchmarks; + +/// +/// Real-world scenario benchmarks comparing Resilion with Polly.Core. +/// Measures performance in realistic patterns: HTTP clients, database queries, hedging. +/// +[MemoryDiagnoser] +public class RealWorldScenarioBenchmarks +{ + private Resilion.Pipeline _resilionHttpClient = null!; + private Resilion.Pipeline _resilionDbQuery = null!; + private Resilion.Pipeline _resilionHedging = null!; + private ResiliencePipeline _pollyHttpClient = null!; + private ResiliencePipeline _pollyDbQuery = null!; + + [GlobalSetup] + public void Setup() + { + // ────── Resilion: HTTP Client Pattern (Timeout + Retry + Circuit Breaker) ────── + // Typical for: API calls, microservice communication + _resilionHttpClient = Resilion.Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(5)) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.Exponential(TimeSpan.FromMilliseconds(100)), + }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.25, + MinimumThroughput = 10, + SamplingDuration = TimeSpan.FromSeconds(5), + })); + + // ────── Resilion: Database Query Pattern (Timeout + Retry + Fallback) ────── + // Typical for: Database reads with cache fallback + _resilionDbQuery = Resilion.Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(10)) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.Linear(TimeSpan.FromMilliseconds(50)), + }) + .AddFallback(new FallbackStrategyOptions + { + FallbackAction = "CACHED_DEFAULT" + })); + + // ────── Resilion: Hedging Pattern (Parallel attempts, fail fast) ────── + // Typical for: Latency-sensitive operations, request fan-out + _resilionHedging = Resilion.Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(5)) + .AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromMilliseconds(250), + })); + + // ────── Polly: HTTP Client Pattern ────── + _pollyHttpClient = new ResiliencePipelineBuilder() + .AddTimeout(TimeSpan.FromSeconds(5)) + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = TimeSpan.FromMilliseconds(100), + BackoffType = DelayBackoffType.Exponential, + }) + .AddCircuitBreaker(new Polly.CircuitBreaker.CircuitBreakerStrategyOptions + { + FailureRatio = 0.25, + MinimumThroughput = 10, + SamplingDuration = TimeSpan.FromSeconds(5), + }) + .Build(); + + // ────── Polly: Database Query Pattern ────── + _pollyDbQuery = new ResiliencePipelineBuilder() + .AddTimeout(TimeSpan.FromSeconds(10)) + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = TimeSpan.FromMilliseconds(50), + BackoffType = DelayBackoffType.Linear, + }) + .Build(); + } + + // ──────────────────────────────────────────────────────────── + // HTTP Client Pattern (API calls, microservice communication) + // ──────────────────────────────────────────────────────────── + + /// + /// Resilion: HTTP request happy path through timeout + retry + circuit breaker. + /// + [Benchmark] + public ValueTask Resilion_HttpClient_HappyPath() + { + return _resilionHttpClient.ExecuteAsync( + static (state, ct) => new ValueTask("OK"), + (object?)null); + } + + /// + /// Polly: HTTP request happy path through same pipeline. + /// + [Benchmark] + public ValueTask Polly_HttpClient_HappyPath() + { + return _pollyHttpClient.ExecuteAsync( + static (state, ct) => new ValueTask("OK"), + (object?)null); + } + + // ──────────────────────────────────────────────────────────── + // Database Query Pattern (read with fallback to cache) + // ──────────────────────────────────────────────────────────── + + /// + /// Resilion: Database query happy path through timeout + retry + fallback. + /// + [Benchmark] + public ValueTask Resilion_DbQuery_HappyPath() + { + return _resilionDbQuery.ExecuteAsync( + static (state, ct) => new ValueTask("user@example.com"), + (object?)null); + } + + /// + /// Polly: Database query happy path through same pipeline. + /// + [Benchmark] + public ValueTask Polly_DbQuery_HappyPath() + { + return _pollyDbQuery.ExecuteAsync( + static (state, ct) => new ValueTask("user@example.com"), + (object?)null); + } + + /// + /// Resilion: Database query triggers fallback (simulates failure). + /// + [Benchmark] + public ValueTask Resilion_DbQuery_WithFallback() + { + return _resilionDbQuery.ExecuteAsync( + static (state, ct) => throw new TimeoutException("DB timeout"), + (object?)null); + } + + + // ──────────────────────────────────────────────────────────── + // Hedging Pattern (parallel attempts for latency reduction) + // ──────────────────────────────────────────────────────────── + + /// + /// Resilion: Hedging fast path (first attempt succeeds quickly). + /// No benefit from hedging here, just overhead. + /// + [Benchmark] + public ValueTask Resilion_Hedging_FastResponse() + { + return _resilionHedging.ExecuteAsync( + static (state, ct) => new ValueTask("OK"), + (object?)null); + } + + // ──────────────────────────────────────────────────────────── + // Synchronous Execution (true sync, not sync-over-async) + // ──────────────────────────────────────────────────────────── + + /// + /// Resilion: Synchronous HTTP request (true blocking implementation). + /// Important for ASP.NET Framework, sync middleware, and backward compatibility. + /// + [Benchmark] + public string Resilion_HttpClient_Sync_HappyPath() + { + return _resilionHttpClient.Execute( + static (state, ct) => "OK", + (object?)null); + } + + /// + /// Resilion: Synchronous database query happy path. + /// + [Benchmark] + public string Resilion_DbQuery_Sync_HappyPath() + { + return _resilionDbQuery.Execute( + static (state, ct) => "user@example.com", + (object?)null); + } + + /// + /// Resilion: Synchronous database query with fallback triggered. + /// + [Benchmark] + public string Resilion_DbQuery_Sync_WithFallback() + { + return _resilionDbQuery.Execute( + static (state, ct) => throw new TimeoutException("DB timeout"), + (object?)null); + } +} diff --git a/benchmarks/Resilion.Benchmarks/Resilion.Benchmarks.csproj b/benchmarks/Resilion.Benchmarks/Resilion.Benchmarks.csproj new file mode 100644 index 0000000..80311e7 --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/Resilion.Benchmarks.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0;net10.0 + latest + enable + enable + false + Resilion.Benchmarks + + + + + + + + + + + + + diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..406be6e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,132 @@ +# Architecture + +How Resilion works under the hood. Read this if you're building custom strategies, contributing to the library, or want to understand the performance characteristics. + +## Core types + +### Outcome\ + +A `readonly struct` wrapping either a successful `TResult` or a captured `Exception`. The pipeline passes outcomes as data instead of throwing and catching, avoiding stack unwinding on the hot path. + +```csharp +// Success +Outcome.FromResult("ok") + +// Failure — exception captured, stack trace preserved +Outcome.FromException(ex) + +// Consumption +outcome.ThrowIfFailed(); // rethrow with original stack trace +outcome.Match(onSuccess, onFailure); // pattern match +outcome.TryGetResult(out var result); // try-pattern +``` + +Exceptions are stored as `ExceptionDispatchInfo` so `ThrowIfFailed()` preserves the original stack trace. + +### ResilienceContext + +Carries execution state through the pipeline: +- `CancellationToken` — strategies may replace this (Timeout creates a linked token) +- `OperationKey` — optional name for telemetry +- `ContinueOnCapturedContext` — controls `ConfigureAwait` behavior +- `Properties` — type-safe property bag for passing data between strategies + +Contexts are pooled via `ResilienceContextPool` to avoid per-call allocations. The pipeline rents one at entry and returns it at exit. + +### Pipeline / Pipeline\ + +Two variants exist because they represent different capabilities: + +- **`Pipeline`** — executes any operation. Strategies can react to exceptions only. Works with any return type because `TResult` is a method-level generic parameter. +- **`Pipeline`** — executes operations returning `TResult`. Strategies can react to both exceptions AND result values. Required for Fallback, Hedging, and result-based predicates. + +Both are sealed, immutable after construction, and thread-safe. Build once, cache, reuse. + +### Strategy / Strategy\ + +Abstract base classes: + +- **`Strategy`** — non-generic class, generic method. A single Timeout instance works with `string`, `int`, `HttpResponseMessage`, anything. +- **`Strategy`** — generic class. A `Strategy` can inspect status codes. + +```csharp +// Non-generic: one instance handles any TResult +public abstract class Strategy +{ + protected internal abstract ValueTask> ExecuteAsync(...); +} + +// Generic: bound to specific TResult +public abstract class Strategy +{ + protected internal abstract ValueTask> ExecuteAsync(...); +} +``` + +## Execution model + +### Pipeline chain + +At `Build()` time, strategies are compiled into a linked chain of internal `PipelineComponent` objects. No allocation per call for the chain structure itself. + +```text +Pipeline.ExecuteAsync(userAction, cancellationToken) + │ + ▼ +Rent ResilienceContext from pool + │ + ▼ +Wrap userAction (captures exceptions into Outcome) + │ + ▼ +[Strategy1] ──next──▸ [Strategy2] ──next──▸ ... ──▸ [wrappedAction] + │ + ▼ +Unwrap Outcome: success returns result, failure rethrows + │ + ▼ +Return ResilienceContext to pool +``` + +### Strategy ordering + +First added = outermost. Canonical order: + +```text +RateLimiter → TotalTimeout → Retry → CircuitBreaker → AttemptTimeout → UserCode +``` + +A call flows inward through each strategy to the user delegate, then outcomes flow back outward in reverse. + +### Sync vs async + +Both `Execute` and `ExecuteAsync` are provided. The sync path is a true sync implementation — strategies use `Thread.Sleep` / `WaitHandle` for delays, not sync-over-async wrapping. Exception: Hedging sync always runs sequentially (parallel execution requires async). + +## Performance model + +### Happy path allocations + +| Strategy | Allocations | Notes | +|----------|------------|-------| +| Retry (no retry) | 0 | Predicate check only | +| Circuit Breaker (Closed) | 0 | Volatile read + Interlocked.Increment | +| Timeout (in time) | 1 CTS + 1 ITimer | CTS poolable via TryReset | +| Fallback (not triggered) | 0 | Predicate check only | +| Rate Limiter (permitted) | 1 lease | Often a struct | +| Hedging (primary wins) | 1 CTS + 1 Task | Delay timer | + +### Per-call pipeline overhead + +Each `StrategyComponent` in the chain creates a closure `ctx => _next.ExecuteAsync(callback, ctx)`. In a pipeline with N strategies this means N small closure allocations per call. This is inherent to the middleware pattern — documented in [future-plans.md](future-plans.md#per-call-delegate-allocation-in-pipeline-chain). + +### Zero-cost telemetry + +`ResilionTelemetry` creates `Meter` and `Counter` instruments using BCL `System.Diagnostics.Metrics`. When no `MeterListener`, `dotnet-counters`, or OpenTelemetry SDK is attached, counter operations are no-ops at the runtime level. + +## Thread safety + +- **Pipeline** — immutable after construction, safe to share across threads +- **Circuit Breaker** — state machine uses `System.Threading.Lock` for transitions, `Interlocked` for sliding window counters. Callbacks fire outside lock. +- **Sliding Window** — internal lock protects bucket rotation and ratio computation +- **ResilienceContextPool** — uses `ConcurrentBag` with approximate size cap +- **ResilienceContext** — NOT thread-safe. One context per execution, which flows through the strategy chain sequentially. Hedging creates separate contexts per attempt. diff --git a/docs/cancellation.md b/docs/cancellation.md new file mode 100644 index 0000000..aa57c55 --- /dev/null +++ b/docs/cancellation.md @@ -0,0 +1,94 @@ +# Cancellation + +How `CancellationToken` flows through Resilion pipelines and how each strategy interacts with it. + +## The rule + +**If the user's original `CancellationToken` is canceled after passing pre-execution strategy gates (e.g., circuit breaker state check), `OperationCanceledException` propagates immediately. It is never retried, never counted as a circuit breaker failure, never caught by any strategy by default. However, strategies that reject before execution (e.g., open circuit breaker) return their own exceptions (e.g., `CircuitBrokenException`) instead.** + +This is the single most important cancellation guarantee for operations that reach the inner callback. + +## Token flow + +```text +User provides CancellationToken + │ + Pipeline sets context.CancellationToken = userToken + │ + [TotalTimeout] creates linkedCTS(userToken + 30s timer) + │ replaces context.CancellationToken = linkedCTS.Token + │ + [Retry] checks token before each attempt — does NOT modify token + │ + [CircuitBreaker] checks state — does NOT modify token + │ + [AttemptTimeout] creates attemptCTS(context.Token + 5s timer) + │ replaces context.CancellationToken = attemptCTS.Token + │ + [UserCallback] receives the most restrictive token + (linked to both total and attempt timeouts) +``` + +Each Timeout strategy creates a linked `CancellationTokenSource`. The inner delegate always gets the most restrictive token. After each strategy completes, it restores the original token. + +## Per-strategy behavior + +### Retry +- Checks `CancellationToken.IsCancellationRequested` before each attempt +- Checks cancellation during delay waits +- `OperationCanceledException` is never retried by default +- Cancelling during delay stops retries immediately + +### Timeout +- Creates linked CTS combining user token + timer +- Timeout fires: catches delegate's `OperationCanceledException`, wraps in `TimeoutRejectedException` +- User cancels: `OperationCanceledException` propagates unchanged (not wrapped) +- Linked CTS disposed in `finally` + +### Circuit Breaker +- Does not modify the token +- `OperationCanceledException` is never counted as a failure (not a dependency fault) +- `CircuitBrokenException` takes priority over cancellation (if circuit is open, reject immediately) + +### Fallback +- Does not modify the token +- `OperationCanceledException` is not handled by default (propagates, no fallback) +- Can be configured to catch OCE via custom `ShouldHandle` (unusual) + +### Rate Limiter +- Passes token to `RateLimiter.AcquireAsync` (cancels queue wait) +- Does not modify the token passed to the inner delegate + +### Hedging +- Each attempt gets its own linked CTS +- When one attempt wins, all other CTS instances cancel +- Cancelled tasks are given a bounded cleanup wait of 5 seconds; tasks that do not cooperate with cancellation may continue running +- User cancellation cancels all attempts simultaneously + +## Timeout vs user cancellation + +The Timeout strategy distinguishes the two by checking which token fired: + +| User token cancelled? | Linked CTS cancelled? | Result | +|----------------------|----------------------|--------| +| No | Yes | `TimeoutRejectedException` | +| Yes | Yes (linked) | `OperationCanceledException` | +| No | No | Success (completed in time) | + +There is a narrow TOCTOU race where user cancellation between checks could be misclassified as timeout. This is nanoseconds wide and not observable in practice. See [future-plans.md](future-plans.md#timeout-cancellation-classification-has-a-narrow-race-window). + +## Cooperative cancellation + +Resilion timeouts are cooperative. The delegate MUST observe the `CancellationToken` for timeout to work. If the delegate ignores the token, timeout cannot abort it. + +```csharp +// GOOD — observes token +await pipeline.ExecuteAsync(async ct => + await httpClient.GetStringAsync(url, ct)); // HttpClient checks ct + +// BAD — ignores token, timeout has no effect +await pipeline.ExecuteAsync(async ct => + await SomeBlockingCallThatIgnoresCancellation()); +``` + +There is no "pessimistic timeout" that abandons the operation on a background thread. Pessimistic timeout leaks resources and creates unpredictable state. diff --git a/docs/circuit-breaker.md b/docs/circuit-breaker.md new file mode 100644 index 0000000..e02221e --- /dev/null +++ b/docs/circuit-breaker.md @@ -0,0 +1,138 @@ +# Circuit Breaker + +Monitors failure rates and stops calling a failing dependency when the failure ratio exceeds a threshold. Protects your system from cascading failures and gives the downstream service time to recover. + +## Basic usage + +```csharp +var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions +{ + FailureRatioThreshold = 0.5, + MinimumThroughput = 10, + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(30), +})); + +try +{ + await pipeline.ExecuteAsync(async ct => + await httpClient.GetStringAsync("https://fragile-service.example.com", ct)); +} +catch (CircuitBrokenException ex) +{ + Console.WriteLine($"Circuit is {ex.CircuitState}, retry after {ex.RetryAfter.TotalSeconds:F0}s"); +} +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `FailureRatioThreshold` | `double` | 0.5 (50%) | Failure ratio that trips the circuit | +| `SamplingDuration` | `TimeSpan` | 30s | Sliding window for tracking | +| `MinimumThroughput` | `int` | 10 | Min calls before ratio is evaluated | +| `BreakDuration` | `TimeSpan` | 30s | How long circuit stays open | +| `ShouldHandle` | `Func` | All except `OperationCanceledException` | What counts as failure | +| `OnOpened` | `ResilienceEventHandler` | null | Fired on Closed → Open | +| `OnClosed` | `ResilienceEventHandler` | null | Fired on HalfOpen → Closed | +| `OnHalfOpened` | `ResilienceEventHandler` | null | Fired on Open → HalfOpen | +| `ManualControl` | `CircuitBreakerManualControl` | null | Manual isolate/reset | + +## State machine + +```text + ┌─────────┐ failure ratio exceeded ┌──────┐ + │ Closed │ ────────────────────────▸ │ Open │ + │ (normal)│ │(reject) + └────▲────┘ └──┬───┘ + │ │ + │ probe succeeds break duration expires + │ │ + ┌────┴─────┐ │ + │ HalfOpen │ ◂────────────────────────────┘ + │ (probe) │ + └──────────┘ + │ + │ probe fails + │ + └──────────────────────────────▸ Open +``` + +**Closed** — normal operation. Successes and failures tracked in sliding window. + +**Open** — all calls rejected immediately with `CircuitBrokenException`. No execution attempted. After `BreakDuration`, transitions to HalfOpen. + +**HalfOpen** — one probe call allowed through. If it succeeds, circuit closes. If it fails, circuit reopens. + +## Sliding window + +Failures are tracked using a bucketed sliding window (10 time buckets). This gives O(1) memory regardless of throughput. Old buckets expire automatically. + +The circuit trips when BOTH conditions are met: +1. Failure ratio ≥ `FailureRatioThreshold` +2. Total calls in window ≥ `MinimumThroughput` + +`MinimumThroughput` prevents false trips during low traffic (e.g., 1 failure out of 2 calls = 50%, but not meaningful). + +## Exception counting + +- Exceptions matching `ShouldHandle` are counted as failures AND rethrown +- Exceptions NOT matching `ShouldHandle` are counted as successes and rethrown +- `OperationCanceledException` is never counted as a failure (user-initiated, not a dependency failure) + +## Manual control + +```csharp +var control = new CircuitBreakerManualControl(); + +var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions +{ + ManualControl = control, +})); + +// Force open (e.g., during maintenance) +await control.IsolateAsync(); + +// Force closed (e.g., after deployment) +await control.ResetAsync(); +``` + +Each `CircuitBreakerManualControl` instance can only be bound to one circuit breaker. Reusing across multiple breakers throws `InvalidOperationException`. + +## Result-based circuit breaker + +On typed pipelines, specific result values count as failures: + +```csharp +var pipeline = Pipeline.Create(b => b.AddCircuitBreaker( + new CircuitBreakerStrategyOptions + { + ShouldHandle = outcome => + outcome.Exception is HttpRequestException + || (outcome.TryGetResult(out var r) && (int)r.StatusCode >= 500), + })); +``` + +## Thread safety + +- State reads (Closed check) use volatile — no lock on hot path +- State transitions acquire `System.Threading.Lock` — only one thread transitions +- Sliding window has its own internal lock for bucket rotation +- Event callbacks fire outside the lock to prevent deadlock + +## Composing with Retry + +Place circuit breaker inside retry so each attempt is tracked individually: + +```csharp +var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = ex => ex is CircuitBrokenException or HttpRequestException, + }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions())); +``` + +## Telemetry + +Emits `resilion.circuit_breaker.state_changes` counter on every state transition. Subscribe with `.AddMeter("Resilion")`. diff --git a/docs/custom-strategies.md b/docs/custom-strategies.md new file mode 100644 index 0000000..adfe882 --- /dev/null +++ b/docs/custom-strategies.md @@ -0,0 +1,132 @@ +# Custom Strategies + +Extend Resilion with your own resilience logic. + +## Delegate-based (typed pipelines) + +Quickest way — zero files, inline middleware: + +```csharp +var pipeline = Pipeline.Create(b => b + .AddStrategy("cache-check", async (context, next) => + { + // Check cache before calling downstream + if (cache.TryGet(context.OperationKey, out HttpResponseMessage cached)) + return Outcome.FromResult(cached); + + // Execute downstream + var outcome = await next(context); + + // Cache successful responses + if (outcome.TryGetResult(out var result) && result.IsSuccessStatusCode) + cache.Set(context.OperationKey, result); + + return outcome; + }) + .AddRetry(...) + .AddTimeout(TimeSpan.FromSeconds(5))); +``` + +Delegate signature: `Func>>, ValueTask>>` — standard middleware pattern. + +Only available on typed `PipelineBuilder`. + +## Class-based (any pipeline) + +For reusable strategies, inherit from `Strategy` (any result type) or `Strategy` (specific type): + +### Non-generic strategy + +Works with any `TResult`. Good for cross-cutting concerns (logging, tracing, metrics). + +```csharp +public sealed class LoggingStrategy : Strategy +{ + private readonly ILogger _logger; + + public LoggingStrategy(ILogger logger) => _logger = logger; + + protected override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + _logger.LogDebug("Executing {Op}", context.OperationKey); + var sw = Stopwatch.StartNew(); + + var outcome = await callback(context).ConfigureAwait(false); + + _logger.LogDebug("Completed {Op} in {Elapsed}ms, Success={S}", + context.OperationKey, sw.ElapsedMilliseconds, outcome.IsSuccess); + + return outcome; + } + + // Optional: override for true sync support + protected override Outcome Execute( + Func> callback, + ResilienceContext context) + { + _logger.LogDebug("Executing {Op}", context.OperationKey); + var sw = Stopwatch.StartNew(); + + var outcome = callback(context); + + _logger.LogDebug("Completed {Op} in {Elapsed}ms, Success={S}", + context.OperationKey, sw.ElapsedMilliseconds, outcome.IsSuccess); + + return outcome; + } +} + +// Usage: +Pipeline.Create(b => b + .AddStrategy(new LoggingStrategy(logger)) + .AddRetry(...) + .AddTimeout(TimeSpan.FromSeconds(10))); +``` + +### Typed strategy + +Bound to a specific result type. Can inspect and modify results. + +```csharp +public sealed class CachingStrategy : Strategy +{ + private readonly ICache _cache; + + public CachingStrategy(ICache cache) => _cache = cache; + + protected override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (context.OperationKey is not null + && _cache.TryGet(context.OperationKey, out var cached)) + { + return Outcome.FromResult(cached); + } + + var outcome = await callback(context).ConfigureAwait(false); + + if (outcome.TryGetResult(out var result) && context.OperationKey is not null) + { + _cache.Set(context.OperationKey, result); + } + + return outcome; + } +} +``` + +## Key points + +- `callback` is the next strategy in the chain (or the user delegate if this is the innermost strategy) +- Always call `callback` unless you're short-circuiting (e.g., cache hit) +- Use `ConfigureAwait(false)` on awaits in library code +- Override `Execute` for true sync support; default falls back to sync-over-async +- Implement `IDisposable` (inherited from `Strategy`) if you hold resources +- Pass `context.CancellationToken` to any async operations you call + +## Compared to Polly + +Polly requires four artifacts for a custom strategy: options class, arguments struct, strategy class, extension method. Resilion requires one class (or zero files for delegate-based). diff --git a/docs/fallback.md b/docs/fallback.md new file mode 100644 index 0000000..483cf9d --- /dev/null +++ b/docs/fallback.md @@ -0,0 +1,111 @@ +# Fallback + +Provides a substitute result when the primary operation fails. The simplest form of graceful degradation. + +## Basic usage + +```csharp +// Constant value — simplest case +var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "default-response", + })); + +var result = await pipeline.ExecuteAsync(ct => +{ + throw new HttpRequestException("Service down"); + return new ValueTask("unreachable"); +}); +// result == "default-response" +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `FallbackAction` | `FallbackAction` | **required** | Value, sync factory, or async factory | +| `ShouldHandle` | `Func, bool>` | All except `OperationCanceledException` | What triggers fallback | +| `OnFallback` | `ResilienceEventHandler>` | null | Fired when fallback activates | + +## FallbackAction — three forms + +`FallbackAction` supports implicit conversion from three types, so you write natural C#: + +```csharp +// 1. Constant value +FallbackAction = "cached-default" + +// 2. Sync factory — compute from the failure +Func, string> factory = + ctx => $"Error: {ctx.Exception?.Message}"; +FallbackAction = factory + +// 3. Async factory — call a secondary service or cache +Func, ValueTask> asyncFactory = + async ctx => await cache.GetAsync("fallback-key"); +FallbackAction = asyncFactory +``` + +The factory receives `FallbackContext` which includes: +- `Outcome` — the failed outcome (exception or bad result) +- `Exception` — shortcut to `Outcome.Exception` +- `Context` — the `ResilienceContext` with cancellation token and properties + +## Result-based fallback + +Fallback can trigger on specific result values, not just exceptions: + +```csharp +var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = 0, + ShouldHandle = outcome => + outcome.TryGetResult(out var val) && val < 0, // Negative = failure + })); +``` + +## Typed pipelines only + +Fallback requires `Pipeline` because it must produce a substitute value of the correct type. You cannot add fallback to a non-generic `Pipeline`. + +## Exception swallowing + +When fallback activates, the original exception is swallowed. The fallback result is returned as if the operation succeeded. If you need to log the original error, use `OnFallback`: + +```csharp +var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "cached-value", + OnFallback = e => + logger.LogWarning(e.Outcome.Exception, "Fallback activated, returning {Result}", e.FallbackResult), + })); +``` + +## Fallback factory that throws + +If `FallbackAction` itself throws, that exception propagates to the caller. The fallback strategy does not retry its own fallback. + +## Composing with Retry + +Place fallback outside retry to catch exhausted retries: + +```csharp +var pipeline = Pipeline.Create(b => b + .AddFallback(new FallbackStrategyOptions + { + FallbackAction = "all-retries-failed-fallback", + }) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, + })); +// Retries 3 times. If all fail, fallback returns the substitute. +``` + +## Telemetry + +Emits `resilion.fallback.activations` counter when fallback triggers. Subscribe with `.AddMeter("Resilion")`. diff --git a/docs/future-plans.md b/docs/future-plans.md new file mode 100644 index 0000000..0142aca --- /dev/null +++ b/docs/future-plans.md @@ -0,0 +1,202 @@ +# Future Plans + +Features, improvements, and known tradeoffs under consideration for future versions of Resilion. Each section includes enough detail to serve as a starting point for implementation. + +## Type Classification + +| Type | Meaning | Impact | Examples | +|------|---------|--------|----------| +| **Feature** | New capability that extends Resilion's functionality beyond current scope | Additive — users gain new resilience options or integrations | Rate Limiter, Hedging, DI integration, Telemetry | +| **Fix** | Correctness issue, performance bug, or missing validation in existing features | Blocking/Correctness — existing code may behave incorrectly or sub-optimally | Per-call closure allocation, WaitHandle allocation | +| **Documentation** | Gaps in docs, missing behavioral explanations, or unclear semantics | Clarity — improves developer experience and correctness of usage | Strategy ordering guide, cancellation semantics | +| **Improvement** | Enhancement to an existing feature that doesn't change its API contract | Quality — existing code works but could work better | CTS pooling, delegate caching | +| **Infrastructure** | CI/CD, packaging, tooling, or repository quality improvements | Adoption — removes friction for users and contributors | Benchmarks, icon, README | + +**How to use this guide:** +- **Fixes** should be prioritized over Features if they affect correctness +- **Documentation** should accompany every Feature (document what it does and why) +- New contributors reading this can quickly understand priority and scope + +## Priority Matrix + +| # | Item | Priority | Type | Impact | Effort | Notes | +|---|------|----------|------|--------|--------|-------| +| 26 | CB typed/non-generic strategy duplication (~200 lines) | P2 | Improvement | Medium | Medium | Extract shared state machine class | +| 27 | Retry typed/non-generic strategy duplication (~100 lines) | P2 | Improvement | Medium | Medium | Extract shared retry loop | +| 28 | PipelineBuilder typed/non-generic duplication (~80 lines) | P2 | Improvement | Medium | Medium | Extract shared base class | +| 29 | ShouldHandleOutcome copy-pasted across 4 options classes | P2 | Improvement | Low | Low | Extract to shared static helper | +| 4 | Per-call delegate allocation in pipeline chain | P2 | Fix | Medium | High | Middleware pattern inherent cost | +| 5 | CancellationTokenSource pooling for Timeout/Hedging | P2 | Improvement | Medium | Medium | Reduce alloc on hot path | +| 11 | Hedging: Task.WhenAny memory leak | P2 | Fix | Medium | Medium | Continuations not deregistered on losing tasks | +| 9 | WaitHandle allocation in sync retry | P3 | Fix | Low | Low | CancellationToken.WaitHandle lazy alloc | +| 10 | Resilion.Testing package | P3 | Feature | Medium | Low | Test doubles, assertion helpers | +| 12 | Hedging: HedgingDelayGenerator | P3 | Feature | Low | Low | Dynamic delay per attempt | +| 14 | Hedging: HedgingRejectedException with aggregated errors | P3 | Improvement | Low | Low | Currently dead code — never thrown | +| 30 | FallbackAction/ResilienceEventHandler Task.Run missing CancellationToken | P3 | Fix | Low | Low | Sync-over-async wrappers can't be interrupted by cancellation | + +--- + +## P2 — Do Next + +### 4. Per-Call Delegate Allocation in Pipeline Chain + +**Type:** Fix — Performance + +**Why** + +In `StrategyComponent.ExecuteAsync`, the lambda `ctx => _next.ExecuteAsync(callback, ctx)` creates a closure capturing `_next` and `callback` on every call. In a pipeline with N strategies, this means N small closure allocations per execution. + +**Current State** + +This is inherent to the middleware/chain-of-responsibility pattern. Polly v8 has the same cost. The allocations are small (one closure object + one delegate per strategy per call) and negligible compared to real I/O costs. + +**Potential Fix** + +Pre-compose the delegate chain at build time into a single `Func, ValueTask>>` that doesn't allocate per call. This requires complex generic type threading and may not be feasible without sacrificing API simplicity. + +**Decision:** Accept for now. Benchmark to confirm the cost is negligible relative to real workloads. Revisit if benchmarks show it matters. + +--- + +### 5. CancellationTokenSource Pooling for Timeout + +**Type:** Improvement — Performance + +**Why** + +The Timeout strategy allocates one `CancellationTokenSource` per execution. `CancellationTokenSource.TryReset()` (available since .NET 6) enables pooling — reset and reuse instead of allocating. + +**Design** + +Use `ObjectPool` with a custom policy that calls `TryReset()` on return. If `TryReset()` returns false (the CTS was cancelled), discard instead of pooling. + +**Complexity:** Medium. `TryReset()` has edge cases around timer state and concurrent cancellation. + +**Decision:** Implement after benchmarks confirm the CTS allocation is a meaningful cost. + +--- + + +### 11. Hedging: Task.WhenAny Memory Leak + +**Type:** Fix — Performance/Correctness + +**Why** + +`WaitForBestOutcome` uses `Task.WhenAny(remaining)` in a loop, removing completed tasks from the list. The standard `Task.WhenAny` implementation registers a continuation on every task in the list. When a task completes, the continuations on the *other* tasks are not deregistered — they hold references to the `Task.WhenAny` result task and its captured state, preventing garbage collection until all tasks in the list complete. + +In a pipeline with 3+ hedged attempts where the first wins quickly, the losing tasks' continuations hold memory until they eventually complete or are cancelled. + +**Potential Fix** + +Implement a custom `FirstCompletedAsync` that uses `TaskCompletionSource` + per-task continuations that deregister themselves on completion. Or use `WaitAsync` chaining. + +**Impact:** Medium. Only affects memory under hedging with many concurrent attempts. The cleanup in `finally` (awaiting all tasks) limits the leak duration. + +--- + +### 26–29. Strategy/Builder/Options Duplication + +**Type:** Improvement — Maintainability + +**Why** + +Code review identified significant duplication between typed and non-generic variants: +- **#26:** `CircuitBreakerStrategy` / `CircuitBreakerTypedStrategy` — ~200 lines of identical state machine logic +- **#27:** `RetryStrategy` / `RetryStrategy` — ~100 lines of identical retry loop (4 copies: async+sync × typed+untyped) +- **#28:** `PipelineBuilder` / `PipelineBuilder` — ~80 lines of identical Build/EmitWarnings/properties +- **#29:** `ShouldHandleOutcome` — identical method body across `FallbackStrategyOptions`, `RetryStrategyOptions`, `CircuitBreakerStrategyOptions`, `HedgingStrategyOptions` + +**Fix:** Extract shared base classes, helper classes, or static methods. For CB, a shared `CircuitBreakerStateMachine` class parameterized on a failure predicate. For Retry, a shared loop parameterized on `Func, bool>`. For Builder, a `PipelineBuilderBase`. For ShouldHandle, a static `OutcomePredicates.Default()`. + +**Risk:** A bug fix applied to one copy but not the other causes silent behavioral divergence between typed and non-generic variants of the same strategy. + +--- + +## P3 — Backlog + +### 9. WaitHandle Allocation in Sync Retry + +**Type:** Fix — Performance (minor) + +**Why** + +The sync retry path uses `context.CancellationToken.WaitHandle.WaitOne(delay)` for cancellation-aware sleep. Accessing `CancellationToken.WaitHandle` lazily allocates a `ManualResetEvent` that is never explicitly disposed. This happens on every retry delay in the sync path. + +**Potential Fix** + +Use `SpinWait` with periodic `IsCancellationRequested` checks for short delays, and `ManualResetEventSlim` with explicit disposal for longer delays. Or use `TimeProvider.CreateTimer` with a `ManualResetEventSlim` signal. + +**Decision:** Low priority. The sync retry path is not the hot path in most applications. Revisit if sync usage proves significant. + +--- + +### 10. Resilion.Testing Package + +**Type:** Feature — Test Infrastructure + +**Why** + +Users testing code that uses Resilion need test doubles and assertion helpers. Currently `Microsoft.Extensions.TimeProvider.Testing.FakeTimeProvider` covers the primary scenario, but a dedicated package could provide: +- `TestPipeline` / `TestPipeline` that record all executions +- Circuit breaker state assertion helpers +- Deterministic event capture +- Pre-configured pipelines for common test scenarios + +**Decision:** Defer until real usage patterns emerge from production users. + +--- + +### 12. Hedging: HedgingDelayGenerator + +**Type:** Feature — Strategy Enhancement + +**Why** + +The current `HedgingDelay` is a static `TimeSpan` — the same delay for every hedged attempt. Some scenarios benefit from dynamic delays (e.g., shorter delays for later attempts, or delays computed from recent latency percentiles). + +**Design** + +Add `Func? HedgingDelayGenerator` to `HedgingStrategyOptions`. When set, `HedgingDelay` is ignored for that attempt. + +```csharp +public readonly record struct HedgingDelayGeneratorArgs(int AttemptNumber, ResilienceContext Context); +``` + +**Complexity:** Low. Thread the generator call into the delay logic in the for-loop. + +--- + +### 14. Hedging: HedgingRejectedException with Aggregated Errors + +**Type:** Improvement — Error Reporting + +**Why** + +`HedgingRejectedException` is defined but never thrown. When all hedging attempts fail, the implementation currently returns the last failure's exception directly via `Outcome`. This means the user only sees the last attempt's exception, losing context about earlier failures. + +**Design** + +When all attempts fail, collect all attempt exceptions and either: +- Throw `HedgingRejectedException` with `AttemptExceptions` containing all failures, or +- Return an `Outcome` with the primary (first) attempt's exception but attach others via `AggregateException` + +The first option changes behavior for users who `catch (InvalidOperationException)` today — their catch would stop matching. The second is backward-compatible. + +**Decision:** Defer. The current behavior (last exception propagates) is simple and predictable. Revisit if users need aggregated error reporting. + +--- + +### 30. FallbackAction/ResilienceEventHandler Task.Run Missing CancellationToken + +**Type:** Fix — Minor correctness + +**Why** + +Both `FallbackAction.Execute` and `ResilienceEventHandler.Invoke` call `Task.Run(() => ...).GetAwaiter().GetResult()` for sync-over-async without passing `CancellationToken` to `Task.Run`. The blocking call can't be interrupted by cancellation. + +**Impact:** Low. Only affects sync path with async handlers/factories, and the underlying async operation would need to observe its own token anyway. + +--- + +See also: [tradeoffs.md](tradeoffs.md) — accepted design imperfections with reasoning for why they won't be fixed. diff --git a/docs/hedging.md b/docs/hedging.md new file mode 100644 index 0000000..b3264f5 --- /dev/null +++ b/docs/hedging.md @@ -0,0 +1,119 @@ +# Hedging + +Reduces tail latency by racing concurrent attempts. When the primary request is slow, a secondary fires. Outcomes selected by `ShouldHandle` (typically failures) trigger further attempts. Unhandled outcomes complete the operation immediately. + +## Basic usage + +```csharp +var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.FromSeconds(2), + })); + +var result = await pipeline.ExecuteAsync(async ct => + await httpClient.GetStringAsync("https://api.example.com", ct)); +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `MaxHedgedAttempts` | `int` | 2 | Total attempts including primary | +| `HedgingDelay` | `TimeSpan` | 2s | Wait before launching next attempt | +| `ShouldHandle` | `Func, bool>` | All except `OperationCanceledException` | What triggers hedging | +| `ActionGenerator` | `Func>?>` | null | Custom action per attempt | +| `OnHedging` | `ResilienceEventHandler>` | null | Fired before each hedged launch | + +## Three modes + +### Latency mode (delay > 0) — default + +Primary runs. If it hasn't completed after `HedgingDelay`, secondary fires. Both race. First success wins. + +```csharp +HedgingDelay = TimeSpan.FromSeconds(2) // Wait 2s, then hedge +``` + +Best for: reducing P99 latency when most requests are fast. + +### Parallel mode (delay = 0) + +All attempts fire simultaneously. Maximum parallelism, maximum resource usage. + +```csharp +HedgingDelay = TimeSpan.Zero // Fire all immediately +``` + +Best for: latency-critical paths where cost of extra requests is acceptable. + +### Sequential mode (InfiniteTimeSpan) + +Each attempt waits for the previous to fail before starting. No parallelism. + +```csharp +HedgingDelay = Timeout.InfiniteTimeSpan // Wait for failure, then try next +``` + +Best for: fallback to alternative endpoints without parallel overhead. + +## Custom actions per attempt + +Route different attempts to different endpoints: + +```csharp +var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = Timeout.InfiniteTimeSpan, + ActionGenerator = ctx => ctx.AttemptNumber switch + { + 0 => ct => CallPrimaryAsync(ct), + 1 => ct => CallSecondaryAsync(ct), + 2 => ct => CallCacheAsync(ct), + _ => null, // null = skip this attempt + }, + })); +``` + +Returning `null` from `ActionGenerator` skips that attempt index. + +## Cancellation + +- Each attempt gets its own linked `CancellationTokenSource` +- When one attempt wins, all others are cancelled +- **All cancelled tasks are awaited** before returning — prevents resource leaks (connections, streams) +- If user's original token cancels, all attempts cancel and `OperationCanceledException` propagates + +This is a key difference from Polly, which cancels but does not await losing tasks. + +## All attempts fail + +When every attempt fails, the last failure's exception propagates. Only the most recent exception surfaces — earlier attempt exceptions are not aggregated. (See [future-plans.md](future-plans.md#14-hedging-hedgingrejectedexception-with-aggregated-errors) for planned improvement.) + +## Typed pipelines only + +Hedging requires `Pipeline` because it must produce a result of the correct type. + +## Sync execution + +Sync path always runs sequentially regardless of `HedgingDelay` — parallel execution requires async. Each attempt runs to completion (or failure) before the next starts. + +## Result-based hedging + +Hedge on specific result values, not just exceptions: + +```csharp +var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + ShouldHandle = outcome => + outcome.TryGetResult(out var val) && val < 0, // Negative = retry + })); +``` + +## Telemetry + +Emits `resilion.hedging.attempts` counter for each hedged attempt launched (not the primary). Subscribe with `.AddMeter("Resilion")`. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..e5a0648 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,116 @@ +# Installation + +## The short version + +Add one package and you're done: + +```xml + + + +``` + +This gives you retry, circuit breaker, timeout, fallback, and hedging — zero external dependencies. + +--- + +## Package decision guide + +| Package | When to install | +|---------|----------------| +| `Resilion` | Always — core library with all built-in strategies, zero dependencies | +| `Resilion.RateLimiting` | When you need rate limiting (wraps `System.Threading.RateLimiting`) | +| `Resilion.Extensions` | When you use DI (`IServiceCollection`) for dependency injection | + +> **OpenTelemetry metrics**: The `ResilionTelemetry` meter is defined in the core `Resilion` package and can be consumed independently. The `Resilion.Extensions` package provides convenient integration with `IServiceCollection` for DI-based registration. + +--- + +## Dependency graph + +```text +Resilion (zero dependencies) + ↑ + ├── Resilion.Extensions (+ Microsoft.Extensions.*) + │ + └── Resilion.RateLimiting (+ System.Threading.RateLimiting) +``` + +`Resilion.Extensions` and `Resilion.RateLimiting` both reference `Resilion` — NuGet pulls it in transitively. + +--- + +## Quick setup + +### Console app / library (no DI) + +```csharp +using Resilion; + +var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddTimeout(TimeSpan.FromSeconds(10))); + +var result = await pipeline.ExecuteAsync( + static (client, ct) => client.GetStringAsync("https://api.example.com", ct), + httpClient); +``` + +### ASP.NET Core / DI + +```csharp +using Resilion; +using Resilion.Extensions; + +// In Program.cs or Startup: +services.AddResiliencePipeline("http-retry", b => b + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddTimeout(TimeSpan.FromSeconds(10))); + +// In your service: +public class MyService(ResiliencePipelineRegistry registry) +{ + private readonly Pipeline _pipeline = registry.GetPipeline("http-retry"); + + public Task GetDataAsync(CancellationToken ct) + => _pipeline.ExecuteAsync( + static (state, ct) => state.client.GetStringAsync(state.url, ct), + (client: _httpClient, url: "https://api.example.com"), + ct).AsTask(); +} +``` + +### With rate limiting + +```xml + + + + +``` + +```csharp +using Resilion; +using Resilion.RateLimiting; +using System.Threading.RateLimiting; + +var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions +{ + PermitLimit = 10, + QueueLimit = 0, +}); + +var pipeline = Pipeline.Create(b => b + .AddRateLimiter(new RateLimiterStrategyOptions { RateLimiter = limiter }) + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })); +``` + +--- + +## Supported .NET versions + +- .NET 9.0+ + +## License + +[Apache-2.0](../LICENSE) diff --git a/docs/pipelines.md b/docs/pipelines.md new file mode 100644 index 0000000..4afeccf --- /dev/null +++ b/docs/pipelines.md @@ -0,0 +1,151 @@ +# Pipelines + +Pipelines compose multiple strategies into a single execution unit. Strategies execute outermost to innermost — the first strategy added is the first to see each call. + +## Creating pipelines + +```csharp +// Non-generic — strategies react to exceptions only +var pipeline = Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(30)) + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions())); + +// Typed — strategies can react to result values too +var typed = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = outcome => outcome.Exception is HttpRequestException + || (outcome.TryGetResult(out var r) && (int)r.StatusCode >= 500), + }) + .AddTimeout(TimeSpan.FromSeconds(5))); +``` + +## Strategy ordering + +Order matters. A call flows inward (outermost first), then results flow back outward. + +### Canonical order + +```csharp +Pipeline.Create(b => b + .AddRateLimiter(...) // 1. Shed load before spending effort + .AddTimeout(30s) // 2. Total timeout across all retries + .AddRetry(...) // 3. Retry failures from inner strategies + .AddCircuitBreaker(...) // 4. Track per-attempt success/failure + .AddTimeout(5s)); // 5. Per-attempt timeout +``` + +### Why order matters + +Same strategies, different order, different behavior: + +```csharp +// Timeout OUTSIDE retry = one 10s budget for ALL attempts +b.AddTimeout(TimeSpan.FromSeconds(10)); +b.AddRetry(...); + +// Timeout INSIDE retry = each attempt gets 10s +b.AddRetry(...); +b.AddTimeout(TimeSpan.FromSeconds(10)); +``` + +## Pipeline composition + +Combine pre-built pipelines with `AddPipeline`: + +```csharp +var retryPolicy = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions +{ + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), +})); + +var timeoutPolicy = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(10))); + +var combined = Pipeline.Create(b => b + .AddPipeline(timeoutPolicy) // Timeout outermost + .AddPipeline(retryPolicy)); // Retry innermost +``` + +`AddPipeline` stores the source pipeline as a delegated component using delegated composition. It records `StrategyType.Custom` rather than flattening inner strategies. The inner strategies remain encapsulated and are not individually visible to ordering validators or strategy introspection. + +## Empty pipelines + +```csharp +Pipeline.Empty // No-op, passes through +Pipeline.Empty // Typed no-op +``` + +Useful for testing or conditional composition: + +```csharp +var pipeline = useResilience + ? Pipeline.Create(b => b.AddRetry().AddTimeout(TimeSpan.FromSeconds(10))) + : Pipeline.Empty; +``` + +## Execution methods + +### Async (primary) + +```csharp +// With state parameter — zero closure allocation +await pipeline.ExecuteAsync( + static (state, ct) => state.client.GetStringAsync(state.url, ct), + (client: httpClient, url: "https://api.example.com"), + cancellationToken); + +// Without state — simpler but allocates closure +await pipeline.ExecuteAsync(async ct => + await httpClient.GetStringAsync("https://api.example.com", ct)); + +// Void +await pipeline.ExecuteAsync(async ct => + await httpClient.PostAsync("https://api.example.com", content, ct)); +``` + +### Sync + +```csharp +// With state +var result = pipeline.Execute( + static (state, ct) => state.Compute(state.input), + (Compute: myFunc, input: 42)); + +// Without state +var result = pipeline.Execute(ct => ComputeSync(ct)); + +// Void +pipeline.Execute(ct => DoWork(ct)); +``` + +### Outcome-based (no throwing) + +```csharp +var context = ResilienceContextPool.Shared.Rent(cancellationToken); +try +{ + var outcome = await pipeline.ExecuteOutcomeAsync( + static (state, ctx) => /* ... */, + myState, + context); + + if (outcome.IsSuccess) + Console.WriteLine(outcome.Result); + else + Console.WriteLine(outcome.Exception); +} +finally +{ + ResilienceContextPool.Shared.Return(context); +} +``` + +## Disposal + +Pipelines implement `IDisposable`. Dispose when strategies hold resources (circuit breaker timers). Pipelines from `AddPipeline` composition share ownership — dispose composed pipelines separately. + +## Immutability and thread safety + +Pipelines are sealed, immutable after construction, and thread-safe. Build once, cache as a `static readonly` or singleton, reuse across threads. diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md new file mode 100644 index 0000000..a147b63 --- /dev/null +++ b/docs/rate-limiting.md @@ -0,0 +1,96 @@ +# Rate Limiting + +Throttles executions using .NET's built-in `System.Threading.RateLimiting`. Lives in the `Resilion.RateLimiting` package. + +## Basic usage + +```csharp +using Resilion; +using Resilion.RateLimiting; +using System.Threading.RateLimiting; + +var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions +{ + PermitLimit = 10, + QueueLimit = 0, +}); + +var pipeline = Pipeline.Create(b => b.AddRateLimiter( + new RateLimiterStrategyOptions { RateLimiter = limiter })); + +try +{ + await pipeline.ExecuteAsync(async ct => await CallApiAsync(ct)); +} +catch (RateLimitRejectedException ex) +{ + Console.WriteLine($"Rate limited. Retry after: {ex.RetryAfter?.TotalSeconds:F1}s"); +} +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `RateLimiter` | `RateLimiter` | **required** | .NET rate limiter instance | +| `OnRejected` | `ResilienceEventHandler` | null | Fired on rejection | + +## Supported rate limiter types + +All .NET built-in algorithms work: + +```csharp +// Concurrency — max simultaneous executions +new ConcurrencyLimiter(new ConcurrencyLimiterOptions { PermitLimit = 10 }) + +// Token bucket — tokens replenish over time +new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions +{ + TokenLimit = 100, + ReplenishmentPeriod = TimeSpan.FromSeconds(1), + TokensPerPeriod = 10, +}) + +// Fixed window — permits per time window +new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions +{ + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), +}) + +// Sliding window — smoother distribution +new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions +{ + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 6, +}) +``` + +## Lifetime management + +The strategy does NOT own the `RateLimiter`. You (or your DI container) are responsible for disposing it. The strategy acquires a lease before execution, runs the delegate, and disposes the lease in `finally` — even on exceptions. + +## RetryAfter + +When the rate limiter provides `RetryAfter` metadata (e.g., `TokenBucketRateLimiter`), it's included in `RateLimitRejectedException.RetryAfter`. Combine with Retry with a fixed delay strategy: + +```csharp +var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = ex => ex is RateLimitRejectedException, + Delay = RetryDelay.Custom(ctx => TimeSpan.FromSeconds(1)), // Fixed one-second retry delay + }) + .AddRateLimiter(new RateLimiterStrategyOptions { RateLimiter = limiter })); +``` + +Note: The retry delay is fixed at 1 second in this example. To use `RateLimitRejectedException.RetryAfter` dynamically, implement custom retry logic or extend the retry strategy. + +## Sync execution + +The sync path uses `AttemptAcquire` (non-blocking). If the limiter supports queueing (`QueueLimit > 0`), the sync path cannot wait in the queue — it only attempts immediate acquisition. + +## Telemetry + +Emits `resilion.rate_limiter.rejections` counter on rejection. Subscribe with `.AddMeter("Resilion")`. diff --git a/docs/retry.md b/docs/retry.md new file mode 100644 index 0000000..9ac5277 --- /dev/null +++ b/docs/retry.md @@ -0,0 +1,124 @@ +# Retry + +Re-executes a failed operation up to a configured number of times, with configurable delays between attempts. + +## Basic usage + +```csharp +var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions +{ + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + UseJitter = true, +})); + +var result = await pipeline.ExecuteAsync(async ct => +{ + var response = await httpClient.GetAsync("https://api.example.com", ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(ct); +}); +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `MaxRetryAttempts` | `int` | 3 | Number of retries (not counting initial call). 0 = no retries. | +| `Delay` | `RetryDelay` | Exponential(1s, max 30s) | Delay strategy between retries | +| `UseJitter` | `bool` | `true` | Apply decorrelated jitter to delays | +| `ShouldHandle` | `Func` | All except `OperationCanceledException` | Which exceptions trigger retry | +| `OnRetry` | `ResilienceEventHandler` | null | Callback before each retry wait | + +## Delay strategies + +Resilion uses `RetryDelay` — a discriminated union that makes delay configuration mutually exclusive by construction. No silent overrides. + +```csharp +// Constant: same delay every time +Delay = RetryDelay.Constant(TimeSpan.FromMilliseconds(500)) +// Produces: 500ms, 500ms, 500ms, ... + +// Linear: baseDelay × attemptNumber +Delay = RetryDelay.Linear(TimeSpan.FromSeconds(1)) +// Produces: 1s, 2s, 3s, ... + +// Exponential: baseDelay × 2^(attemptNumber-1) +Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)) +// Produces: 1s, 2s, 4s, 8s, ... (capped at maxDelay, default 30s) + +// Custom: any function +Delay = RetryDelay.Custom(ctx => TimeSpan.FromSeconds(ctx.AttemptNumber * 5)) + +// None: immediate retry (useful for testing) +Delay = RetryDelay.None +``` + +### Jitter + +When `UseJitter = true` (default), ±25% randomness is applied to computed delays. This prevents thundering herd problems where many clients retry at the same instant after a shared failure. + +Jitter is recommended for all production use. Disable only for deterministic testing. + +## Result-based retry (typed pipelines) + +On typed pipelines, retry can inspect return values — not just exceptions: + +```csharp +var pipeline = Pipeline.Create(b => b.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + ShouldHandle = outcome => + outcome.Exception is HttpRequestException + || (outcome.TryGetResult(out var r) && (int)r.StatusCode >= 500), + })); +``` + +`ShouldHandle` receives `Outcome` which wraps either the result or exception. Use `TryGetResult` to inspect results without throwing. + +## Exhaustion behavior + +When all retries are exhausted: +- **Exception failures**: last attempt's exception propagates with original stack trace. No wrapping in a `RetryExhaustedException` — your `catch (HttpRequestException)` still works. +- **Result failures**: last attempt's result is returned as-is. + +## Cancellation + +- `OperationCanceledException` is never retried by default +- Cancellation is checked before each attempt and during delay waits +- If token is cancelled during delay, retry stops immediately with `OperationCanceledException` + +## Composing with Timeout + +```csharp +// Total timeout (outer) + per-attempt timeout (inner) +var pipeline = Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(30)) // Total budget + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + ShouldHandle = ex => ex is TimeoutRejectedException, + }) + .AddTimeout(TimeSpan.FromSeconds(5))); // Per attempt +``` + +## OnRetry callback + +```csharp +Action onRetry = e => + logger.LogWarning("Retry #{Attempt}, waiting {Delay}ms. Error: {Error}", + e.AttemptNumber, e.RetryDelay.TotalMilliseconds, e.Exception.Message); + +var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions +{ + MaxRetryAttempts = 3, + OnRetry = onRetry, +})); +``` + +## Telemetry + +Emits `resilion.retry.attempts` counter on each retry attempt. Subscribe with `.AddMeter("Resilion")`. diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..d90decb --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,64 @@ +# Telemetry + +Resilion emits metrics via .NET's built-in `System.Diagnostics.Metrics`. Zero overhead when no listener is attached. + +## Metrics + +All metrics are on the `"Resilion"` meter. + +| Metric | Type | Emitted when | +|--------|------|-------------| +| `resilion.retry.attempts` | Counter | Each retry attempt | +| `resilion.timeout.expirations` | Counter | Timeout fires | +| `resilion.circuit_breaker.state_changes` | Counter | Any state transition | +| `resilion.fallback.activations` | Counter | Fallback triggers | +| `resilion.hedging.attempts` | Counter | Hedged attempt launches | +| `resilion.rate_limiter.rejections` | Counter | Rate limit rejection | + +## Subscribing + +### dotnet-counters (CLI) + +```bash +dotnet counters monitor --name --counters Resilion +``` + +Or monitor by process ID: + +```bash +dotnet counters monitor --process-id --counters Resilion +``` + +### OpenTelemetry + +```csharp +builder.Services.AddOpenTelemetry() + .WithMetrics(m => m.AddMeter("Resilion")); +``` + +### Manual MeterListener + +```csharp +var listener = new MeterListener(); +listener.InstrumentPublished = (instrument, listener) => +{ + if (instrument.Meter.Name == "Resilion") + listener.EnableMeasurementEvents(instrument); +}; +listener.SetMeasurementEventCallback((instrument, value, tags, state) => + Console.WriteLine($"{instrument.Name}: {value}")); +listener.Start(); +``` + +## Zero-cost when unused + +After initialization, `Counter.Add(1)` is a no-op at the runtime level when no `MeterListener` is subscribed. No allocation, no work during the metric recording path. Note: `ResilionTelemetry` eagerly allocates the `Meter`, `ActivitySource`, and measurement instruments at startup. Metrics only become active when something listens. + +## Strategy callbacks vs metrics + +Two complementary systems: + +- **Callbacks** (`OnRetry`, `OnTimeout`, etc.) — inline, per-pipeline, for custom logic (logging, alerting, request mutation) +- **Metrics** — global, aggregated, for observability dashboards and alerting systems + +Both fire on the same events. Use callbacks for per-request decisions. Use metrics for aggregate monitoring. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..8547f30 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,174 @@ +# Testing + +How to test code that uses Resilion. + +## Replace with empty pipeline + +Simplest approach — bypass resilience for unit tests: + +```csharp +// Production +var pipeline = Pipeline.Create(b => b.AddRetry().AddTimeout(TimeSpan.FromSeconds(10))); + +// Test +var pipeline = Pipeline.Empty; // No strategies, direct passthrough +``` + +## TimeProvider for deterministic tests + +All time-dependent strategies (Timeout, Retry delays, Circuit Breaker recovery) accept `TimeProvider` through the builder. Use `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`: + +```xml + +``` + +```csharp +using Microsoft.Extensions.Time.Testing; + +[Fact] +public async Task Timeout_Fires_After_Configured_Duration() +{ + var fakeTime = new FakeTimeProvider(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + var task = pipeline.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return "late"; + }); + + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + await Assert.ThrowsAsync(() => task.AsTask()); +} +``` + +## Testing retry behavior + +```csharp +[Fact] +public async Task Retries_Three_Times_Then_Succeeds() +{ + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, // No delays in tests + })); + + var result = await pipeline.ExecuteAsync(ct => + { + callCount++; + if (callCount < 3) + throw new InvalidOperationException("transient"); + return new ValueTask("recovered"); + }); + + Assert.Equal("recovered", result); + Assert.Equal(3, callCount); +} +``` + +## Testing circuit breaker state + +Trip the circuit and verify rejection: + +```csharp +[Fact] +public async Task Circuit_Trips_After_Failures() +{ + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 4, + BreakDuration = TimeSpan.FromSeconds(30), + })); + + // 4 failures = 100% failure rate, above 50% threshold + for (var i = 0; i < 4; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("fail")).AsTask()); + } + + // Next call rejected + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); +} +``` + +## Testing with manual circuit control + +```csharp +[Fact] +public async Task Manual_Isolation_Rejects_All() +{ + var control = new CircuitBreakerManualControl(); + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + ManualControl = control, + })); + + await control.IsolateAsync(); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); +} +``` + +## Testing fallback + +```csharp +[Fact] +public async Task Fallback_Returns_Default_On_Failure() +{ + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions { FallbackAction = "default" })); + + var result = await pipeline.ExecuteAsync(ct => + { + throw new Exception("fail"); + return new ValueTask("unreachable"); + }); + + Assert.Equal("default", result); +} +``` + +## Testing callbacks + +Capture event args to verify strategy behavior: + +```csharp +[Fact] +public async Task OnRetry_Receives_Correct_Attempt_Numbers() +{ + var attempts = new List(); + Action onRetry = e => attempts.Add(e.AttemptNumber); + + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, + OnRetry = onRetry, + })); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => throw new Exception("fail")).AsTask()); + + Assert.Equal([1, 2, 3], attempts); +} +``` + +## Tips + +- Use `RetryDelay.None` in tests to avoid real delays +- Use `FakeTimeProvider` for timeout/circuit breaker recovery testing +- Use `Pipeline.Empty` when resilience behavior is not under test +- Use callbacks (`OnRetry`, `OnTimeout`, etc.) to verify strategy activation without inspecting internals diff --git a/docs/timeout.md b/docs/timeout.md new file mode 100644 index 0000000..a358015 --- /dev/null +++ b/docs/timeout.md @@ -0,0 +1,92 @@ +# Timeout + +Enforces a time limit on operations using cooperative cancellation. + +## Basic usage + +```csharp +var pipeline = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(10))); + +try +{ + await pipeline.ExecuteAsync(async ct => + { + return await httpClient.GetStringAsync("https://slow-api.example.com", ct); + }); +} +catch (TimeoutRejectedException ex) +{ + Console.WriteLine($"Timed out after {ex.ElapsedTime.TotalSeconds:F1}s"); +} +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `Timeout` | `TimeSpan` | 30 seconds | Duration before timeout fires | +| `TimeoutGenerator` | `Func` | null | Dynamic timeout per execution | +| `OnTimeout` | `ResilienceEventHandler` | null | Callback when timeout occurs | + +### Special values + +- `TimeSpan.Zero` — immediately times out (useful for testing) +- `Timeout.InfiniteTimeSpan` — disables timeout (passthrough, no CTS allocated) + +## How it works + +1. Creates a linked `CancellationTokenSource` combining the user's token with a timer +2. Passes the linked token to the inner delegate via `ResilienceContext.CancellationToken` +3. If timer fires before delegate completes, linked token cancels +4. Catches `OperationCanceledException` from the delegate and wraps it in `TimeoutRejectedException` +5. If user's original token was cancelled (not the timer), `OperationCanceledException` propagates unchanged + +**Cooperative only.** The delegate MUST observe the `CancellationToken`. If it ignores the token, timeout has no effect — Resilion cannot forcibly abort operations. This is a deliberate design choice. Pessimistic timeout (background thread abandonment) leaks resources and creates unpredictable state. + +## User cancellation vs timeout + +Resilion distinguishes between the two: + +| Cause | Exception thrown | How to catch | +|-------|-----------------|-------------| +| Timeout expired | `TimeoutRejectedException` | `catch (TimeoutRejectedException)` | +| User cancelled | `OperationCanceledException` | `catch (OperationCanceledException)` | + +`TimeoutRejectedException` includes: +- `ConfiguredTimeout` — the duration that was set +- `ElapsedTime` — actual time elapsed before timeout +- `InnerException` — the original `OperationCanceledException` + +## Per-attempt vs total timeout + +Strategy ordering determines which timeout applies: + +```csharp +// Total timeout (outer) + per-attempt timeout (inner) +var pipeline = Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(30)) // Caps entire pipeline including retries + .AddRetry(...) + .AddTimeout(TimeSpan.FromSeconds(5))); // Each attempt gets 5s +``` + +## Dynamic timeout + +```csharp +var pipeline = Pipeline.Create(b => b.AddTimeout(new TimeoutStrategyOptions +{ + TimeoutGenerator = args => + { + // Different timeout based on operation + return args.Context.OperationKey switch + { + "fast-op" => TimeSpan.FromSeconds(2), + "slow-op" => TimeSpan.FromSeconds(60), + _ => TimeSpan.FromSeconds(10), + }; + }, +})); +``` + +## Telemetry + +Emits `resilion.timeout.expirations` counter when timeout fires. Subscribe with `.AddMeter("Resilion")`. diff --git a/docs/tradeoffs.md b/docs/tradeoffs.md new file mode 100644 index 0000000..a91d6ba --- /dev/null +++ b/docs/tradeoffs.md @@ -0,0 +1,65 @@ +# Known Tradeoffs + +Accepted design imperfections where the fix is worse than the problem, or the issue is theoretical rather than practical. Each entry explains what the tradeoff is, why it's acceptable, and what to watch for. + +For items we *plan to fix*, see [future-plans.md](future-plans.md). + +--- + +## Null implicit conversion creates "successful" null outcome + +`Outcome o = (string)null;` creates a success outcome with a null result. + +**Why it's acceptable:** Consistent with `Task.FromResult(null)` — a completed task with null is valid in .NET. For non-nullable value types this can't happen. For reference types, null is a legal value. + +**Watch for:** User code that assumes `outcome.IsSuccess` means `outcome.Result` is non-null. Use `TryGetResult` and null-check the result. + +**Location:** [Outcome.cs](../src/Resilion/Outcome.cs) — `implicit operator` + +--- + +## DelegatingComponent does not dispose composed pipeline resources + +When pipelines are composed via `AddPipeline()`, the `DelegatingComponent` does not dispose the inner component. + +**Why it's acceptable:** The inner pipeline may be shared — the same pre-built `Pipeline` can be flattened into multiple builders. Disposing the inner would break all other compositions sharing it. Ownership belongs to whoever created the original pipeline. + +**Watch for:** If you compose pipelines, dispose the source pipelines separately when they're no longer needed. The composed pipeline's `Dispose()` only disposes strategies it directly owns. + +**Location:** [PipelineBuilder.cs](../src/Resilion/PipelineBuilder.cs) — `DelegatingComponent.Dispose()` + +--- + +## ResilienceContextPool cap is approximate + +The pool size check (`_pool.Count < MaxPoolSize`) followed by `_pool.Add(context)` is a TOCTOU race — concurrent `Return()` calls can overshoot `MaxPoolSize` unboundedly. The excess contexts are held in the `ConcurrentBag` and are never collected — `Rent` via `_pool.TryTake` is the only removal path. + +**Why it's acceptable:** The cap is a heuristic, not a hard limit. Enforcing exactly 256 would require a lock on every return for zero practical benefit. Under burst traffic the pool might grow beyond the target, but this is transient and trades memory for lock-free concurrent access. The contexts are small (~200 bytes) and in steady state the pool converges to size. The alternative — `ConcurrentBag.Count` with exact enforcement — is worse because `Count` itself is expensive (see future-plans #25). + +**Watch for:** Don't rely on the pool being exactly 256 items or fewer. It's a soft cap. Under sustained traffic, the pool size may exceed the target. + +**Location:** [ResilienceContextPool.cs](../src/Resilion/ResilienceContextPool.cs) — `Return()` + +--- + +## Timeout cancellation classification has a narrow race window + +`WasCancelledByTimeout` checks `linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested`. Between the two checks, the user token could become cancelled, causing user cancellation to be misclassified as a timeout. + +**Why it's acceptable:** The window is nanoseconds wide and requires the user token to cancel at the exact instant between two boolean reads. In practice this is not observable. The consequence (a `TimeoutRejectedException` instead of `OperationCanceledException`) is also recoverable — the caller catches `ResilionException` or the base `Exception` either way. + +**Watch for:** If your code strictly distinguishes timeout from cancellation and you're seeing rare misclassification under extreme concurrent cancellation, this is the cause. + +**Location:** [TimeoutStrategy.cs](../src/Resilion/Timeout/TimeoutStrategy.cs) — `WasCancelledByTimeout()` + +--- + +## Per-call delegate allocation in pipeline chain + +Each `StrategyComponent` in the chain creates a closure `ctx => _next.ExecuteAsync(callback, ctx)` per execution. N strategies = N closure allocations per call. + +**Why it's acceptable:** This is inherent to the middleware/chain-of-responsibility pattern. Polly v8 has the same cost. The closures are small (one display class + one delegate), short-lived (Gen0 collected), and negligible relative to the I/O cost of the operations being protected. Pre-composing at build time would require complex generic threading that doesn't compose with the non-generic `Strategy` base. + +**Watch for:** If benchmarks show this matters for your workload (unlikely unless you're running 100K+ pipeline executions/sec with no I/O), consider reducing pipeline depth or using `ExecuteOutcomeAsync` with a manually composed chain. + +**Location:** [PipelineComponent.cs](../src/Resilion/Internal/PipelineComponent.cs) — `StrategyComponent.ExecuteAsync` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..eb237de --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,136 @@ +# Troubleshooting + +## Timeout has no effect + +**Problem**: Operation runs past the configured timeout without being cancelled. + +**Cause**: Timeout relies on cooperative cancellation. The delegate must observe `CancellationToken`. + +**Fix**: Pass the `ct` parameter to all async operations: + +```csharp +// BAD — ignores cancellation +await pipeline.ExecuteAsync(async ct => + await SomeCall()); // token not passed + +// GOOD +await pipeline.ExecuteAsync(async ct => + await SomeCall(ct)); // token forwarded +``` + +See [cancellation.md](cancellation.md) for details. + +--- + +## Retry doesn't retry my exception + +**Problem**: Exception thrown but not retried. + +**Checklist**: +- `OperationCanceledException` is never retried by default +- Custom `ShouldHandle` predicate may not match your exception type +- `MaxRetryAttempts = 0` disables retries + +**Fix**: Check your `ShouldHandle` predicate. Default handles all exceptions except `OperationCanceledException`: + +```csharp +// Only retries HttpRequestException: +ShouldHandle = ex => ex is HttpRequestException + +// Retries everything including OCE (unusual): +ShouldHandle = _ => true +``` + +--- + +## Circuit breaker never trips + +**Problem**: Lots of failures but circuit stays Closed. + +**Checklist**: +- `MinimumThroughput` not met? (default: 10 calls required before ratio evaluated) +- `FailureRatioThreshold` too high? (default: 0.5 = 50%) +- Exception not matching `ShouldHandle`? (`OperationCanceledException` doesn't count) +- `SamplingDuration` too short? (failures expire from the window) + +--- + +## Circuit breaker stays open + +**Problem**: Circuit tripped and never recovers. + +**Cause**: `BreakDuration` hasn't elapsed, or probe calls in HalfOpen keep failing. + +**Fix**: Check `BreakDuration` (default: 30s). After it expires, one probe call is allowed. If the probe fails, circuit reopens for another `BreakDuration`. + +To force-close: use `CircuitBreakerManualControl.ResetAsync()`. + +--- + +## "No pipeline registered with key" from DI + +**Problem**: `KeyNotFoundException` when resolving from `ResiliencePipelineRegistry`. + +**Cause**: Pipeline configurations registered via `AddResiliencePipeline` need to be applied to the registry before accessing it. + +**Fix**: When you resolve `ResiliencePipelineRegistry` from the service provider, all registered `IPipelineConfigurator` services are automatically applied: + +```csharp +var services = new ServiceCollection(); +services.AddResiliencePipeline("my-pipeline", b => b.AddRetry(...)); +var sp = services.BuildServiceProvider(); + +// Registry is built and configured when resolved +var registry = sp.GetRequiredService>(); +var pipeline = registry.GetPipeline("my-pipeline"); // Works! +``` + +--- + +## Fallback not available on non-generic Pipeline + +**Problem**: `AddFallback` method doesn't appear on `PipelineBuilder`. + +**Cause**: Fallback requires `Pipeline` because it produces a substitute value of a specific type. + +**Fix**: Use `Pipeline.Create(...)` instead of `Pipeline.Create(...)`: + +```csharp +// Won't compile — no AddFallback on PipelineBuilder +Pipeline.Create(b => b.AddFallback(...)); + +// Works — typed pipeline +Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions { FallbackAction = "default" })); +``` + +Same applies to `AddHedging`. + +--- + +## ResilienceEventHandler won't accept my lambda + +**Problem**: `CS1660: Cannot convert lambda expression to type 'ResilienceEventHandler?'` + +**Cause**: C# can't chain two implicit conversions (lambda → `Action` → `ResilienceEventHandler`). + +**Fix**: Assign to a typed variable first: + +```csharp +// Won't compile directly in options initializer +OnRetry = (RetryAttemptEvent e) => logger.Log(e.AttemptNumber) // CS1660 + +// Works — explicit Action +Action onRetry = e => logger.Log(e.AttemptNumber); +// Then: OnRetry = onRetry +``` + +--- + +## Hedging sync path runs sequentially + +**Problem**: Hedging with `HedgingDelay = TimeSpan.Zero` (parallel mode) doesn't run attempts in parallel when using `Execute` (sync). + +**Cause**: Parallel execution requires async. The sync path always runs sequentially regardless of `HedgingDelay`. + +**Fix**: Use `ExecuteAsync` for parallel hedging. diff --git a/global.json b/global.json new file mode 100644 index 0000000..d07970a --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.100", + "rollForward": "latestMajor" + } +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..9f609bd Binary files /dev/null and b/icon.png differ diff --git a/samples/Resilion.Samples/Program.cs b/samples/Resilion.Samples/Program.cs new file mode 100644 index 0000000..0c84479 --- /dev/null +++ b/samples/Resilion.Samples/Program.cs @@ -0,0 +1,197 @@ +using Resilion; + +Console.WriteLine("Resilion Samples"); +Console.WriteLine("================"); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 1. Basic retry pipeline +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("1. Basic Retry"); +Console.WriteLine("──────────────"); + +Action onRetry = e => + Console.WriteLine($" Retry #{e.AttemptNumber}, waiting {e.RetryDelay.TotalMilliseconds:F0}ms"); + +var retryPipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromMilliseconds(100)), + UseJitter = true, + OnRetry = onRetry, + })); + +var attemptCount = 0; +var result = await retryPipeline.ExecuteAsync(ct => +{ + attemptCount++; + if (attemptCount < 3) + { + throw new HttpRequestException($"Transient failure (attempt {attemptCount})"); + } + + return new ValueTask("Success!"); +}); + +Console.WriteLine($" Result: {result} (after {attemptCount} attempts)"); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 2. Timeout +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("2. Timeout"); +Console.WriteLine("──────────"); + +Action onTimeout = e => + Console.WriteLine($" Timed out after {e.ElapsedTime.TotalMilliseconds:F0}ms"); + +var timeoutPipeline = Pipeline.Create(b => b + .AddTimeout(new TimeoutStrategyOptions + { + Timeout = TimeSpan.FromMilliseconds(500), + OnTimeout = onTimeout, + })); + +try +{ + await timeoutPipeline.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + return "should not reach"; + }); +} +catch (TimeoutRejectedException ex) +{ + Console.WriteLine($" Caught: {ex.GetType().Name} ({ex.ConfiguredTimeout.TotalMilliseconds}ms timeout)"); +} + +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 3. Circuit Breaker +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("3. Circuit Breaker"); +Console.WriteLine("──────────────────"); + +Action onOpened = e => + Console.WriteLine($" Circuit OPENED (was {e.PreviousState})"); +Action onClosed = e => + Console.WriteLine($" Circuit CLOSED (was {e.PreviousState})"); + +var cbPipeline = Pipeline.Create(b => b + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 3, + BreakDuration = TimeSpan.FromSeconds(1), + OnOpened = onOpened, + OnClosed = onClosed, + })); + +for (var i = 0; i < 5; i++) +{ + try + { + await cbPipeline.ExecuteAsync(ct => + throw new InvalidOperationException("Service down")); + } + catch (CircuitBrokenException) + { + Console.WriteLine($" Call {i + 1}: Rejected (circuit open)"); + } + catch (InvalidOperationException) + { + Console.WriteLine($" Call {i + 1}: Failed (circuit tracking)"); + } +} + +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 4. Fallback with typed pipeline +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("4. Fallback"); +Console.WriteLine("───────────"); + +Action> onFallback = e => + Console.WriteLine($" Fallback activated: {e.Outcome.Exception?.GetType().Name}"); + +var fallbackPipeline = Pipeline.Create(b => b + .AddFallback(new FallbackStrategyOptions + { + FallbackAction = "default-response", + OnFallback = onFallback, + })); + +var fallbackResult = await fallbackPipeline.ExecuteAsync(ct => +{ + throw new InvalidOperationException("Primary failed"); +#pragma warning disable CS0162 + return new ValueTask("unreachable"); +#pragma warning restore CS0162 +}); +Console.WriteLine($" Result: {fallbackResult}"); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 5. Composite pipeline (the real-world pattern) +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("5. Composite Pipeline"); +Console.WriteLine("─────────────────────"); + +var compositePipeline = Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(30)) // Total timeout + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromMilliseconds(200)), + }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 10, + }) + .AddTimeout(TimeSpan.FromSeconds(5))); // Per-attempt timeout + +var compositeResult = await compositePipeline.ExecuteAsync( + static (state, ct) => new ValueTask($"Hello from {state}!"), + "composite pipeline"); + +Console.WriteLine($" Result: {compositeResult}"); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 6. Sync execution +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("6. Sync Execution"); +Console.WriteLine("─────────────────"); + +var syncPipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions +{ + MaxRetryAttempts = 2, + Delay = RetryDelay.None, +})); + +var syncAttempts = 0; +var syncResult = syncPipeline.Execute(ct => +{ + syncAttempts++; + if (syncAttempts < 2) + { + throw new InvalidOperationException("transient"); + } + + return "sync-success"; +}); + +Console.WriteLine($" Result: {syncResult} (after {syncAttempts} attempts)"); +Console.WriteLine(); + +Console.WriteLine("All samples completed."); diff --git a/samples/Resilion.Samples/Resilion.Samples.csproj b/samples/Resilion.Samples/Resilion.Samples.csproj new file mode 100644 index 0000000..4d72357 --- /dev/null +++ b/samples/Resilion.Samples/Resilion.Samples.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + latest + enable + enable + false + Resilion.Samples + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..fcd822c --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,32 @@ + + + + 0.1.0 + FreakyAli + Copyright 2026 + en + Apache-2.0 + icon.png + README.md + git + https://github.com/FreakyAli/Resilion + https://github.com/FreakyAli/Resilion + resilience;retry;circuit-breaker;timeout;fallback;hedging;rate-limiting;fault-tolerance + See https://github.com/FreakyAli/Resilion/blob/master/CHANGELOG.md + + + + net8.0 + 12.0 + enable + enable + true + true + + + + + + + + diff --git a/src/Resilion.Extensions/ResiliencePipelineRegistry.cs b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs new file mode 100644 index 0000000..f6bc888 --- /dev/null +++ b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs @@ -0,0 +1,140 @@ +using System.Collections.Concurrent; + +namespace Resilion.Extensions; + +/// +/// A thread-safe registry of named resilience pipelines. Pipelines are created lazily +/// on first access and cached for the lifetime of the registry. +/// +/// The key type for pipeline lookup. Typically . +public sealed class ResiliencePipelineRegistry : IDisposable + where TKey : notnull +{ + private readonly ConcurrentDictionary> _pipelines = new(); + private readonly ConcurrentDictionary<(TKey, Type), Lazy> _typedPipelines = new(); + private readonly ConcurrentDictionary> _factories = new(); + private readonly ConcurrentDictionary<(TKey, Type), object> _typedFactories = new(); + + /// + /// Registers a factory for a named pipeline. The factory is invoked lazily on first access. + /// + /// The pipeline name. + /// A delegate that configures the pipeline builder. + /// A pipeline with the same key is already registered. + public void RegisterPipeline(TKey key, Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + if (!_factories.TryAdd(key, builder => { configure(builder); return builder; })) + { + throw new ArgumentException($"A pipeline with key '{key}' is already registered.", nameof(key)); + } + } + + /// + /// Registers a factory for a named typed pipeline. + /// + /// The result type for the pipeline. + /// The pipeline name. + /// A delegate that configures the typed pipeline builder. + public void RegisterPipeline(TKey key, Action> configure) + { + ArgumentNullException.ThrowIfNull(configure); + var compositeKey = (key, typeof(TResult)); + if (!_typedFactories.TryAdd(compositeKey, configure)) + { + throw new ArgumentException( + $"A pipeline with key '{key}' and result type '{typeof(TResult).Name}' is already registered.", + nameof(key)); + } + } + + /// + /// Gets or creates the pipeline registered under the specified key. + /// + /// The pipeline name. + /// The cached pipeline instance. + /// No pipeline is registered with the specified key. + public Pipeline GetPipeline(TKey key) + { + var lazy = _pipelines.GetOrAdd(key, _ => new Lazy(() => + { + if (!_factories.TryGetValue(key, out var factory)) + { + throw new KeyNotFoundException($"No pipeline registered with key '{key}'."); + } + + var builder = new PipelineBuilder(); + factory(builder); + return builder.Build(); + })); + + return lazy.Value; + } + + /// + /// Gets or creates the typed pipeline registered under the specified key. + /// + /// The result type for the pipeline. + /// The pipeline name. + /// The cached typed pipeline instance. + public Pipeline GetPipeline(TKey key) + { + var compositeKey = (key, typeof(TResult)); + var lazy = _typedPipelines.GetOrAdd(compositeKey, _ => new Lazy(() => + { + if (!_typedFactories.TryGetValue(compositeKey, out var factory)) + { + throw new KeyNotFoundException( + $"No pipeline registered with key '{key}' and result type '{typeof(TResult).Name}'."); + } + + var configure = (Action>)factory; + var builder = new PipelineBuilder(); + configure(builder); + return (object)builder.Build(); + })); + + return (Pipeline)lazy.Value; + } + + /// + /// Attempts to get a pipeline registered under the specified key. + /// + /// The pipeline name. + /// The pipeline, if found. + /// true if the pipeline exists; false otherwise. + public bool TryGetPipeline(TKey key, out Pipeline? pipeline) + { + if (_factories.ContainsKey(key)) + { + pipeline = GetPipeline(key); + return true; + } + + pipeline = null; + return false; + } + + /// + public void Dispose() + { + foreach (var lazy in _pipelines.Values) + { + if (lazy.IsValueCreated) + { + lazy.Value.Dispose(); + } + } + + foreach (var lazy in _typedPipelines.Values) + { + if (lazy.IsValueCreated && lazy.Value is IDisposable disposable) + { + disposable.Dispose(); + } + } + + _pipelines.Clear(); + _typedPipelines.Clear(); + } +} diff --git a/src/Resilion.Extensions/Resilion.Extensions.csproj b/src/Resilion.Extensions/Resilion.Extensions.csproj new file mode 100644 index 0000000..c539d9f --- /dev/null +++ b/src/Resilion.Extensions/Resilion.Extensions.csproj @@ -0,0 +1,25 @@ + + + + Resilion.Extensions + Resilion.Extensions + Resilion.Extensions + DI, logging, and OpenTelemetry integration for Resilion. + false + + + + + + + + + + + + + + + + + diff --git a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs new file mode 100644 index 0000000..10cad18 --- /dev/null +++ b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs @@ -0,0 +1,142 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Resilion.Extensions; + +/// +/// Extension methods for registering Resilion services with . +/// +public static class ResilionServiceCollectionExtensions +{ + /// + /// Adds Resilion core services to the service collection, including a shared + /// with string keys. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddResilion(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton>(sp => BuildRegistry(sp)); + services.TryAddSingleton(ResilienceContextPool.Shared); + return services; + } + + /// + /// Registers a named resilience pipeline that is created lazily on first access + /// and cached as a singleton. + /// + /// The service collection. + /// A unique name for the pipeline. + /// A delegate that configures the pipeline builder. + /// The service collection for chaining. + /// + /// + /// services.AddResiliencePipeline("my-pipeline", builder => builder + /// .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + /// .AddTimeout(TimeSpan.FromSeconds(10))); + /// + /// // Resolve later: + /// var registry = serviceProvider.GetRequiredService<ResiliencePipelineRegistry<string>>(); + /// var pipeline = registry.GetPipeline("my-pipeline"); + /// + /// + public static IServiceCollection AddResiliencePipeline( + this IServiceCollection services, + string name, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(configure); + + services.AddResilion(); + + // Register a post-configuration action that runs when the registry is first resolved. + var capturedName = name; + var capturedConfigure = configure; + services.AddSingleton( + new PipelineConfigurator(capturedName, capturedConfigure)); + + return services; + } + + /// + /// Registers a named typed resilience pipeline. + /// + /// The result type for the pipeline. + /// The service collection. + /// A unique name for the pipeline. + /// A delegate that configures the typed pipeline builder. + /// The service collection for chaining. + public static IServiceCollection AddResiliencePipeline( + this IServiceCollection services, + string name, + Action> configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(configure); + + services.AddResilion(); + + services.AddSingleton( + new TypedPipelineConfigurator(name, configure)); + + return services; + } + + /// + /// Builds and returns the with all registered + /// pipeline configurations applied. Call this after all AddResiliencePipeline calls. + /// + internal static ResiliencePipelineRegistry BuildRegistry(IServiceProvider sp) + { + // Create a new registry instance (don't call GetRequiredService to avoid infinite recursion) + var registry = new ResiliencePipelineRegistry(); + + // Apply all registered pipeline configurations + var configurators = sp.GetServices(); + foreach (var configurator in configurators) + { + configurator.Configure(registry); + } + + return registry; + } +} + +internal interface IPipelineConfigurator +{ + void Configure(ResiliencePipelineRegistry registry); +} + +internal sealed class PipelineConfigurator : IPipelineConfigurator +{ + private readonly string _name; + private readonly Action _configure; + + public PipelineConfigurator(string name, Action configure) + { + _name = name; + _configure = configure; + } + + public void Configure(ResiliencePipelineRegistry registry) + => registry.RegisterPipeline(_name, _configure); +} + +internal sealed class TypedPipelineConfigurator : IPipelineConfigurator +{ + private readonly string _name; + private readonly Action> _configure; + + public TypedPipelineConfigurator(string name, Action> configure) + { + _name = name; + _configure = configure; + } + + public void Configure(ResiliencePipelineRegistry registry) + => registry.RegisterPipeline(_name, _configure); +} diff --git a/src/Resilion.RateLimiting/RateLimitRejectedException.cs b/src/Resilion.RateLimiting/RateLimitRejectedException.cs new file mode 100644 index 0000000..34c4e91 --- /dev/null +++ b/src/Resilion.RateLimiting/RateLimitRejectedException.cs @@ -0,0 +1,27 @@ +using Resilion; + +namespace Resilion.RateLimiting; + +/// +/// Thrown when the rate limiter rejects an execution because the rate limit has been exceeded. +/// +public sealed class RateLimitRejectedException : ResilionException +{ + /// + /// Initializes a new instance of the class. + /// + /// The suggested duration to wait before retrying, or null if not available. + internal RateLimitRejectedException(TimeSpan? retryAfter) + : base(retryAfter.HasValue + ? $"The rate limit has been exceeded. Retry after {retryAfter.Value.TotalSeconds:F1}s." + : "The rate limit has been exceeded.") + { + RetryAfter = retryAfter; + } + + /// + /// Gets the suggested duration to wait before retrying, extracted from the rate limiter's + /// lease metadata. Returns null if the rate limiter did not provide this information. + /// + public TimeSpan? RetryAfter { get; } +} diff --git a/src/Resilion.RateLimiting/RateLimiterExtensions.cs b/src/Resilion.RateLimiting/RateLimiterExtensions.cs new file mode 100644 index 0000000..257e337 --- /dev/null +++ b/src/Resilion.RateLimiting/RateLimiterExtensions.cs @@ -0,0 +1,38 @@ +namespace Resilion.RateLimiting; + +/// +/// Extension methods for adding the Rate Limiter strategy to a pipeline builder. +/// +public static class RateLimiterExtensions +{ + /// + /// Adds a rate limiter strategy with the specified options. + /// + /// The pipeline builder. + /// The rate limiter options. + /// The builder for chaining. + public static PipelineBuilder AddRateLimiter( + this PipelineBuilder builder, + RateLimiterStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new RateLimiterStrategy(options), Resilion.Internal.StrategyType.RateLimiter); + } + + /// + /// Adds a rate limiter strategy to a typed pipeline. + /// + /// The result type. + /// The typed pipeline builder. + /// The rate limiter options. + /// The builder for chaining. + public static PipelineBuilder AddRateLimiter( + this PipelineBuilder builder, + RateLimiterStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new RateLimiterStrategy(options), Resilion.Internal.StrategyType.RateLimiter); + } +} diff --git a/src/Resilion.RateLimiting/RateLimiterStrategy.cs b/src/Resilion.RateLimiting/RateLimiterStrategy.cs new file mode 100644 index 0000000..39e98fa --- /dev/null +++ b/src/Resilion.RateLimiting/RateLimiterStrategy.cs @@ -0,0 +1,97 @@ +using System.Threading.RateLimiting; + +namespace Resilion.RateLimiting; + +/// +/// Rate limiter strategy — acquires a lease before execution, rejects if the limit is exceeded. +/// +internal sealed class RateLimiterStrategy : Strategy +{ + private readonly RateLimiterStrategyOptions _options; + + internal RateLimiterStrategy(RateLimiterStrategyOptions options) + { + _options = options; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + var lease = await _options.RateLimiter!.AcquireAsync( + permitCount: 1, + cancellationToken: context.CancellationToken).ConfigureAwait(false); + + try + { + if (!lease.IsAcquired) + { + return await HandleRejection(lease, context).ConfigureAwait(false); + } + + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + finally + { + lease.Dispose(); + } + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + // Sync acquire — AttemptAcquire does not wait in a queue. + using var lease = _options.RateLimiter!.AttemptAcquire(permitCount: 1); + + if (!lease.IsAcquired) + { + return HandleRejectionSync(lease, context); + } + + return callback(context); + } + + private async ValueTask> HandleRejection( + RateLimitLease lease, + ResilienceContext context) + { + var retryAfter = GetRetryAfter(lease); + + Resilion.ResilionTelemetry.RateLimiterRejections.Add(1); + + if (_options.OnRejected is { } handler && handler.HasHandler) + { + await handler.InvokeAsync(new OnRateLimitRejectedEvent(retryAfter, context)) + .ConfigureAwait(false); + } + + return Outcome.FromException(new RateLimitRejectedException(retryAfter)); + } + + private Outcome HandleRejectionSync( + RateLimitLease lease, + ResilienceContext context) + { + var retryAfter = GetRetryAfter(lease); + + Resilion.ResilionTelemetry.RateLimiterRejections.Add(1); + + if (_options.OnRejected is { } handler && handler.HasHandler) + { + handler.Invoke(new OnRateLimitRejectedEvent(retryAfter, context)); + } + + return Outcome.FromException(new RateLimitRejectedException(retryAfter)); + } + + private static TimeSpan? GetRetryAfter(RateLimitLease lease) + { + if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + return retryAfter; + } + + return null; + } +} diff --git a/src/Resilion.RateLimiting/RateLimiterStrategyOptions.cs b/src/Resilion.RateLimiting/RateLimiterStrategyOptions.cs new file mode 100644 index 0000000..aa0d4f5 --- /dev/null +++ b/src/Resilion.RateLimiting/RateLimiterStrategyOptions.cs @@ -0,0 +1,50 @@ +using System.Threading.RateLimiting; + +namespace Resilion.RateLimiting; + +/// +/// Options for the Rate Limiter resilience strategy. +/// +/// +/// +/// The strategy wraps — it does not +/// reimplement rate limiting. Configure the limiter using the .NET built-in algorithms: +/// , , +/// , or . +/// +/// +/// The strategy does NOT own the 's lifetime. The caller (or DI container) +/// is responsible for disposing it. +/// +/// +public sealed record RateLimiterStrategyOptions +{ + /// + /// Gets the rate limiter instance. Required — there is no default. + /// + public RateLimiter? RateLimiter { get; init; } + + /// + /// Gets an optional event handler fired when a request is rejected by the rate limiter. + /// + public ResilienceEventHandler? OnRejected { get; init; } + + internal void Validate() + { + if (RateLimiter is null) + { + throw new InvalidOperationException( + "RateLimiter must be configured. Provide a RateLimiter instance " + + "(e.g., new ConcurrencyLimiter, new TokenBucketRateLimiter, etc.)."); + } + } +} + +/// +/// Event arguments for the callback. +/// +/// The suggested wait duration before retrying, or null. +/// The execution context. +public readonly record struct OnRateLimitRejectedEvent( + TimeSpan? RetryAfter, + ResilienceContext Context); diff --git a/src/Resilion.RateLimiting/Resilion.RateLimiting.csproj b/src/Resilion.RateLimiting/Resilion.RateLimiting.csproj new file mode 100644 index 0000000..dd4c98d --- /dev/null +++ b/src/Resilion.RateLimiting/Resilion.RateLimiting.csproj @@ -0,0 +1,18 @@ + + + + Resilion.RateLimiting + Resilion.RateLimiting + Resilion.RateLimiting + Rate limiting strategy for Resilion wrapping System.Threading.RateLimiting. + + + + + + + + + + + diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerExtensions.cs b/src/Resilion/CircuitBreaker/CircuitBreakerExtensions.cs new file mode 100644 index 0000000..a5c7521 --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBreakerExtensions.cs @@ -0,0 +1,54 @@ +namespace Resilion; + +/// +/// Extension methods for adding the Circuit Breaker strategy to a pipeline builder. +/// +public static class CircuitBreakerExtensions +{ + /// + /// Adds an exception-only circuit breaker strategy with the specified options. + /// + /// The pipeline builder. + /// The circuit breaker options. When null, uses defaults. + /// The builder for chaining. + public static PipelineBuilder AddCircuitBreaker( + this PipelineBuilder builder, + CircuitBreakerStrategyOptions? options = null) + { + options ??= new CircuitBreakerStrategyOptions(); + options.Validate(); + return builder.AddStrategy(new CircuitBreakerStrategy(options, builder.TimeProvider), Internal.StrategyType.CircuitBreaker); + } + + /// + /// Adds a result-aware circuit breaker strategy to a typed pipeline. + /// + /// The result type. + /// The typed pipeline builder. + /// The typed circuit breaker options. + /// The builder for chaining. + public static PipelineBuilder AddCircuitBreaker( + this PipelineBuilder builder, + CircuitBreakerStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new CircuitBreakerTypedStrategy(options, builder.TimeProvider), Internal.StrategyType.CircuitBreaker); + } + + /// + /// Adds an exception-only circuit breaker strategy to a typed pipeline. + /// + /// The result type. + /// The typed pipeline builder. + /// The circuit breaker options. When null, uses defaults. + /// The builder for chaining. + public static PipelineBuilder AddCircuitBreaker( + this PipelineBuilder builder, + CircuitBreakerStrategyOptions? options = null) + { + options ??= new CircuitBreakerStrategyOptions(); + options.Validate(); + return builder.AddStrategy(new CircuitBreakerStrategy(options, builder.TimeProvider), Internal.StrategyType.CircuitBreaker); + } +} diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerManualControl.cs b/src/Resilion/CircuitBreaker/CircuitBreakerManualControl.cs new file mode 100644 index 0000000..402bec1 --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBreakerManualControl.cs @@ -0,0 +1,71 @@ +namespace Resilion; + +/// +/// Provides manual control over a circuit breaker's state. +/// Use to force the circuit open and to close it. +/// +/// +/// Create an instance and pass it via . +/// The control must be associated with a strategy before it can be used. +/// +public sealed class CircuitBreakerManualControl +{ + private readonly object _initLock = new(); + private Func? _onIsolate; + private Func? _onReset; + private bool _initialized; + + /// + /// Forces the circuit into the state. + /// All subsequent calls will be rejected until is called. + /// + /// + /// Thrown when this control is not associated with any circuit breaker strategy. + /// + public Task IsolateAsync() + { + if (_onIsolate is null) + { + throw new InvalidOperationException( + "This CircuitBreakerManualControl is not associated with a circuit breaker strategy. " + + "Pass it via CircuitBreakerStrategyOptions.ManualControl when building the pipeline."); + } + + return _onIsolate(); + } + + /// + /// Resets the circuit to the state and clears the sliding window. + /// + /// + /// Thrown when this control is not associated with any circuit breaker strategy. + /// + public Task ResetAsync() + { + if (_onReset is null) + { + throw new InvalidOperationException( + "This CircuitBreakerManualControl is not associated with a circuit breaker strategy. " + + "Pass it via CircuitBreakerStrategyOptions.ManualControl when building the pipeline."); + } + + return _onReset(); + } + + internal void Initialize(Func onIsolate, Func onReset) + { + lock (_initLock) + { + if (_initialized) + { + throw new InvalidOperationException( + "This CircuitBreakerManualControl is already bound to a circuit breaker strategy. " + + "Create a separate instance for each circuit breaker."); + } + + _onIsolate = onIsolate; + _onReset = onReset; + _initialized = true; + } + } +} diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs new file mode 100644 index 0000000..973ba96 --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs @@ -0,0 +1,338 @@ +using Resilion.Internal; + +namespace Resilion; + +/// +/// Non-generic circuit breaker strategy — tracks failures by exception only. +/// +internal sealed class CircuitBreakerStrategy : Strategy +{ + private readonly CircuitBreakerStrategyOptions _options; + private readonly TimeProvider _timeProvider; + private readonly SlidingWindow _window; + private readonly object _lock = new(); + + private volatile CircuitState _state = CircuitState.Closed; + private long _openedAtTimestamp; + private int _halfOpenAttempts; + + internal CircuitBreakerStrategy(CircuitBreakerStrategyOptions options, TimeProvider timeProvider) + { + _options = options; + _timeProvider = timeProvider; + _window = new SlidingWindow(options.SamplingDuration, timeProvider); + + options.ManualControl?.Initialize( + onIsolate: () => { Isolate(); return Task.CompletedTask; }, + onReset: () => { Reset(); return Task.CompletedTask; }); + } + + internal CircuitState State => _state; + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + var rejection = TryReject(context); + if (rejection is not null) + { + return Outcome.FromException(rejection); + } + + try + { + var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + await RecordOutcomeAsync(outcome.Exception, context).ConfigureAwait(context.ContinueOnCapturedContext); + return outcome; + } + catch (Exception ex) + { + await RecordOutcomeAsync(ex, context).ConfigureAwait(context.ContinueOnCapturedContext); + throw; + } + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + var rejection = TryReject(context); + if (rejection is not null) + { + return Outcome.FromException(rejection); + } + + try + { + var outcome = callback(context); + RecordOutcome(outcome.Exception, context); + return outcome; + } + catch (Exception ex) + { + RecordOutcome(ex, context); + throw; + } + } + + private CircuitBrokenException? TryReject(ResilienceContext context) + { + var state = _state; + + switch (state) + { + case CircuitState.Closed: + return null; + + case CircuitState.HalfOpen: + lock (_lock) + { + if (_state != CircuitState.HalfOpen) + { + return _state is CircuitState.Open or CircuitState.Isolated + ? CreateRejectException() + : null; + } + + if (_halfOpenAttempts > 0) + { + return CreateRejectException(); + } + + _halfOpenAttempts++; + return null; + } + + case CircuitState.Open: + var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); + if (elapsed >= _options.BreakDuration) + { + CircuitStateChangedEvent? pendingEvent = null; + lock (_lock) + { + if (_state == CircuitState.Open) + { + pendingEvent = TransitionTo(CircuitState.HalfOpen, context); + _halfOpenAttempts = 1; + } + } + + FireEvent(pendingEvent); + + return _state is CircuitState.Open or CircuitState.Isolated + ? CreateRejectException() + : null; + } + + return CreateRejectException(); + + case CircuitState.Isolated: + return new CircuitBrokenException(CircuitState.Isolated, TimeSpan.Zero); + + default: + return null; + } + } + + private void RecordOutcome(Exception? exception, ResilienceContext context) + { + var isFailure = exception is not null && _options.ShouldHandleException(exception); + RecordAndTransition(isFailure, context); + } + + private async ValueTask RecordOutcomeAsync(Exception? exception, ResilienceContext context) + { + var isFailure = exception is not null && _options.ShouldHandleException(exception); + await RecordAndTransitionAsync(isFailure, context).ConfigureAwait(false); + } + + private async ValueTask RecordAndTransitionAsync(bool isFailure, ResilienceContext context) + { + var pendingEvent = ComputeTransition(isFailure, context); + if (pendingEvent.HasValue) + { + await FireEventAsync(pendingEvent.Value).ConfigureAwait(false); + } + } + + private CircuitStateChangedEvent? ComputeTransition(bool isFailure, ResilienceContext context) + { + var currentState = _state; + + switch (currentState) + { + case CircuitState.Closed: + // Record outcome and get ratio under single lock for efficiency + var failureRatio = _window.RecordAndGetRatio(isFailure, out var totalCount); + if (failureRatio >= _options.FailureRatioThreshold + && totalCount >= _options.MinimumThroughput) + { + lock (_lock) + { + if (_state == CircuitState.Closed) + { + return Trip(context); + } + } + } + + return null; + + case CircuitState.HalfOpen: + lock (_lock) + { + if (_state != CircuitState.HalfOpen) + { + return null; + } + + if (isFailure) + { + return Trip(context); + } + + _window.Reset(); + return TransitionTo(CircuitState.Closed, context); + } + + default: + return null; + } + } + + private async ValueTask FireEventAsync(CircuitStateChangedEvent? evt) + { + if (evt is null) + { + return; + } + + ResilionTelemetry.CircuitBreakerStateChanges.Add(1); + + var e = evt.Value; + switch (e.CurrentState) + { + case CircuitState.Open: + if (_options.OnOpened is { } onOpened && onOpened.HasHandler) + { + await onOpened.InvokeAsync(e).ConfigureAwait(false); + } + + break; + + case CircuitState.Closed: + if (_options.OnClosed is { } onClosed && onClosed.HasHandler) + { + await onClosed.InvokeAsync(e).ConfigureAwait(false); + } + + break; + + case CircuitState.HalfOpen: + if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + { + await onHalfOpened.InvokeAsync(e).ConfigureAwait(false); + } + + break; + } + } + + private void RecordAndTransition(bool isFailure, ResilienceContext context) + { + var pendingEvent = ComputeTransition(isFailure, context); + if (pendingEvent.HasValue) + { + FireEvent(pendingEvent.Value); + } + } + + /// Must be called under lock. + private CircuitStateChangedEvent? Trip(ResilienceContext context) + { + _openedAtTimestamp = _timeProvider.GetTimestamp(); + _halfOpenAttempts = 0; + return TransitionTo(CircuitState.Open, context); + } + + /// Must be called under lock. Returns event to fire outside lock. + private CircuitStateChangedEvent? TransitionTo(CircuitState newState, ResilienceContext context) + { + var previous = _state; + _state = newState; + return new CircuitStateChangedEvent(previous, newState, context); + } + + /// Fires event callback outside lock. Emits telemetry. + private void FireEvent(CircuitStateChangedEvent? evt) + { + if (evt is null) + { + return; + } + + ResilionTelemetry.CircuitBreakerStateChanges.Add(1); + + var e = evt.Value; + switch (e.CurrentState) + { + case CircuitState.Open: + if (_options.OnOpened is { } onOpened && onOpened.HasHandler) + { + onOpened.Invoke(e); + } + + break; + + case CircuitState.Closed: + if (_options.OnClosed is { } onClosed && onClosed.HasHandler) + { + onClosed.Invoke(e); + } + + break; + + case CircuitState.HalfOpen: + if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + { + onHalfOpened.Invoke(e); + } + + break; + } + } + + private CircuitBrokenException CreateRejectException() + { + var remaining = TimeSpan.Zero; + if (_state == CircuitState.Open) + { + var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); + remaining = _options.BreakDuration - elapsed; + if (remaining < TimeSpan.Zero) + { + remaining = TimeSpan.Zero; + } + } + + return new CircuitBrokenException(_state, remaining); + } + + private void Isolate() + { + lock (_lock) + { + _state = CircuitState.Isolated; + } + } + + private void Reset() + { + lock (_lock) + { + _window.Reset(); + _state = CircuitState.Closed; + _halfOpenAttempts = 0; + } + } +} diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs new file mode 100644 index 0000000..dd995e0 --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs @@ -0,0 +1,178 @@ +namespace Resilion; + +/// +/// Options for the Circuit Breaker resilience strategy (exception-only predicates). +/// +public sealed record CircuitBreakerStrategyOptions +{ + /// + /// Gets the failure ratio threshold (0.0 to 1.0) that trips the circuit. + /// Defaults to 0.5 (50%). + /// + public double FailureRatioThreshold { get; init; } = 0.5; + + /// + /// Gets the sliding window duration for tracking successes and failures. + /// Defaults to 30 seconds. + /// + public TimeSpan SamplingDuration { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Gets the minimum number of executions in the window before the circuit can trip. + /// Prevents tripping on 1 failure out of 2 calls. Defaults to 10. + /// + public int MinimumThroughput { get; init; } = 10; + + /// + /// Gets the duration the circuit stays open before transitioning to half-open. + /// Defaults to 30 seconds. + /// + public TimeSpan BreakDuration { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Gets the predicate that determines which exceptions count as failures. + /// Defaults to all exceptions except . + /// + public Func? ShouldHandle { get; init; } + + /// + /// Gets an optional event handler fired when the circuit transitions to open. + /// + public ResilienceEventHandler? OnOpened { get; init; } + + /// + /// Gets an optional event handler fired when the circuit transitions to closed. + /// + public ResilienceEventHandler? OnClosed { get; init; } + + /// + /// Gets an optional event handler fired when the circuit transitions to half-open. + /// + public ResilienceEventHandler? OnHalfOpened { get; init; } + + /// + /// Gets an optional manual control for isolating and resetting the circuit. + /// + public CircuitBreakerManualControl? ManualControl { get; init; } + + internal void Validate() + { + if (FailureRatioThreshold is < 0.0 or > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(FailureRatioThreshold), FailureRatioThreshold, + "FailureRatioThreshold must be between 0.0 and 1.0."); + } + + if (SamplingDuration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(SamplingDuration), SamplingDuration, + "SamplingDuration must be positive."); + } + + if (MinimumThroughput < 1) + { + throw new ArgumentOutOfRangeException(nameof(MinimumThroughput), MinimumThroughput, + "MinimumThroughput must be >= 1."); + } + + if (BreakDuration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(BreakDuration), BreakDuration, + "BreakDuration must be positive."); + } + } + + internal bool ShouldHandleException(Exception exception) + { + if (exception is OperationCanceledException) + { + return ShouldHandle?.Invoke(exception) ?? false; + } + + return ShouldHandle?.Invoke(exception) ?? true; + } +} + +/// +/// Options for the Circuit Breaker with result-based predicates. +/// +/// The result type to inspect. +public sealed record CircuitBreakerStrategyOptions +{ + /// + public double FailureRatioThreshold { get; init; } = 0.5; + + /// + public TimeSpan SamplingDuration { get; init; } = TimeSpan.FromSeconds(30); + + /// + public int MinimumThroughput { get; init; } = 10; + + /// + public TimeSpan BreakDuration { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Gets the predicate that determines which outcomes count as failures. + /// + public Func, bool>? ShouldHandle { get; init; } + + /// + public ResilienceEventHandler? OnOpened { get; init; } + + /// + public ResilienceEventHandler? OnClosed { get; init; } + + /// + public ResilienceEventHandler? OnHalfOpened { get; init; } + + /// + public CircuitBreakerManualControl? ManualControl { get; init; } + + internal void Validate() + { + if (FailureRatioThreshold is < 0.0 or > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(FailureRatioThreshold), FailureRatioThreshold, + "FailureRatioThreshold must be between 0.0 and 1.0."); + } + + if (SamplingDuration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(SamplingDuration), SamplingDuration, + "SamplingDuration must be positive."); + } + + if (MinimumThroughput < 1) + { + throw new ArgumentOutOfRangeException(nameof(MinimumThroughput), MinimumThroughput, + "MinimumThroughput must be >= 1."); + } + + if (BreakDuration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(BreakDuration), BreakDuration, + "BreakDuration must be positive."); + } + } + + internal bool ShouldHandleOutcome(Outcome outcome) + { + if (ShouldHandle is not null) + { + return ShouldHandle(outcome); + } + + return outcome.Exception is not null and not OperationCanceledException; + } +} + +/// +/// Event arguments for circuit breaker state change events. +/// +/// The state before the transition. +/// The state after the transition. +/// The execution context. +public readonly record struct CircuitStateChangedEvent( + CircuitState PreviousState, + CircuitState CurrentState, + ResilienceContext Context); diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs b/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs new file mode 100644 index 0000000..7be60c3 --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs @@ -0,0 +1,347 @@ +using Resilion.Internal; + +namespace Resilion; + +/// +/// Typed circuit breaker strategy — tracks failures by both exceptions and result values. +/// +internal sealed class CircuitBreakerTypedStrategy : Strategy +{ + private readonly CircuitBreakerStrategyOptions _options; + private readonly TimeProvider _timeProvider; + private readonly SlidingWindow _window; + private readonly object _lock = new(); + + private volatile CircuitState _state = CircuitState.Closed; + private long _openedAtTimestamp; + private int _halfOpenAttempts; + + internal CircuitBreakerTypedStrategy(CircuitBreakerStrategyOptions options, TimeProvider timeProvider) + { + _options = options; + _timeProvider = timeProvider; + _window = new SlidingWindow(options.SamplingDuration, timeProvider); + + options.ManualControl?.Initialize( + onIsolate: () => { Isolate(); return Task.CompletedTask; }, + onReset: () => { Reset(); return Task.CompletedTask; }); + } + + internal CircuitState State => _state; + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + var rejection = TryReject(context); + if (rejection is not null) + { + return Outcome.FromException(rejection); + } + + try + { + var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + var isFailure = _options.ShouldHandleOutcome(outcome); + await ComputeAndFireTransitionAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); + return outcome; + } + catch (Exception ex) + { + var failureOutcome = Outcome.FromException(ex); + var isFailure = _options.ShouldHandleOutcome(failureOutcome); + await ComputeAndFireTransitionAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); + throw; + } + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + var rejection = TryReject(context); + if (rejection is not null) + { + return Outcome.FromException(rejection); + } + + try + { + var outcome = callback(context); + var isFailure = _options.ShouldHandleOutcome(outcome); + RecordAndTransition(isFailure, context); + return outcome; + } + catch (Exception ex) + { + var failureOutcome = Outcome.FromException(ex); + var isFailure = _options.ShouldHandleOutcome(failureOutcome); + RecordAndTransition(isFailure, context); + throw; + } + } + + private CircuitBrokenException? TryReject(ResilienceContext context) + { + var state = _state; + + switch (state) + { + case CircuitState.Closed: + return null; + + case CircuitState.HalfOpen: + lock (_lock) + { + if (_state != CircuitState.HalfOpen) + { + return _state is CircuitState.Open or CircuitState.Isolated + ? CreateRejectException() + : null; + } + + if (_halfOpenAttempts > 0) + { + return CreateRejectException(); + } + + _halfOpenAttempts++; + return null; + } + + case CircuitState.Open: + var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); + if (elapsed >= _options.BreakDuration) + { + CircuitStateChangedEvent? pendingEvent = null; + lock (_lock) + { + if (_state == CircuitState.Open) + { + pendingEvent = TransitionTo(CircuitState.HalfOpen, context); + _halfOpenAttempts = 1; + } + } + + FireEvent(pendingEvent); + + return _state is CircuitState.Open or CircuitState.Isolated + ? CreateRejectException() + : null; + } + + return CreateRejectException(); + + case CircuitState.Isolated: + return new CircuitBrokenException(CircuitState.Isolated, TimeSpan.Zero); + + default: + return null; + } + } + + private void RecordAndTransition(bool isFailure, ResilienceContext context) + { + var pendingEvent = ComputeTransition(isFailure, context); + if (pendingEvent.HasValue) + { + FireEvent(pendingEvent.Value); + } + } + + private async ValueTask ComputeAndFireTransitionAsync(bool isFailure, ResilienceContext context) + { + var pendingEvent = ComputeTransition(isFailure, context); + if (pendingEvent.HasValue) + { + await FireEventAsync(pendingEvent.Value).ConfigureAwait(false); + } + } + + private CircuitStateChangedEvent? ComputeTransition(bool isFailure, ResilienceContext context) + { + var currentState = _state; + + switch (currentState) + { + case CircuitState.Closed: + if (isFailure) + { + _window.RecordFailure(); + } + else + { + _window.RecordSuccess(); + } + + var failureRatio = _window.GetFailureRatio(out var totalCount); + if (failureRatio >= _options.FailureRatioThreshold + && totalCount >= _options.MinimumThroughput) + { + lock (_lock) + { + if (_state == CircuitState.Closed) + { + return Trip(context); + } + } + } + + return null; + + case CircuitState.HalfOpen: + lock (_lock) + { + if (_state != CircuitState.HalfOpen) + { + return null; + } + + if (isFailure) + { + return Trip(context); + } + + _window.Reset(); + return TransitionTo(CircuitState.Closed, context); + } + + default: + return null; + } + } + + private async ValueTask FireEventAsync(CircuitStateChangedEvent? evt) + { + if (evt is null) + { + return; + } + + ResilionTelemetry.CircuitBreakerStateChanges.Add(1); + + var e = evt.Value; + switch (e.CurrentState) + { + case CircuitState.Open: + if (_options.OnOpened is { } onOpened && onOpened.HasHandler) + { + await onOpened.InvokeAsync(e).ConfigureAwait(false); + } + + break; + + case CircuitState.Closed: + if (_options.OnClosed is { } onClosed && onClosed.HasHandler) + { + await onClosed.InvokeAsync(e).ConfigureAwait(false); + } + + break; + + case CircuitState.HalfOpen: + if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + { + await onHalfOpened.InvokeAsync(e).ConfigureAwait(false); + } + + break; + } + } + + /// + /// Transitions to Open state. Must be called under lock. + /// + private CircuitStateChangedEvent? Trip(ResilienceContext context) + { + _openedAtTimestamp = _timeProvider.GetTimestamp(); + _halfOpenAttempts = 0; + return TransitionTo(CircuitState.Open, context); + } + + /// + /// Transitions state and returns the event to fire (outside the lock). + /// Must be called under lock. + /// + private CircuitStateChangedEvent? TransitionTo(CircuitState newState, ResilienceContext context) + { + var previous = _state; + _state = newState; + return new CircuitStateChangedEvent(previous, newState, context); + } + + /// + /// Fires the event callback outside the lock. + /// + private void FireEvent(CircuitStateChangedEvent? evt) + { + if (evt is null) + { + return; + } + + ResilionTelemetry.CircuitBreakerStateChanges.Add(1); + + var e = evt.Value; + switch (e.CurrentState) + { + case CircuitState.Open: + if (_options.OnOpened is { } onOpened && onOpened.HasHandler) + { + onOpened.Invoke(e); + } + + break; + + case CircuitState.Closed: + if (_options.OnClosed is { } onClosed && onClosed.HasHandler) + { + onClosed.Invoke(e); + } + + break; + + case CircuitState.HalfOpen: + if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + { + onHalfOpened.Invoke(e); + } + + break; + } + } + + private CircuitBrokenException CreateRejectException() + { + var remaining = TimeSpan.Zero; + if (_state == CircuitState.Open) + { + var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); + remaining = _options.BreakDuration - elapsed; + if (remaining < TimeSpan.Zero) + { + remaining = TimeSpan.Zero; + } + } + + return new CircuitBrokenException(_state, remaining); + } + + private void Isolate() + { + lock (_lock) + { + _state = CircuitState.Isolated; + } + } + + private void Reset() + { + lock (_lock) + { + _window.Reset(); + _state = CircuitState.Closed; + _halfOpenAttempts = 0; + } + } +} diff --git a/src/Resilion/CircuitBreaker/CircuitBrokenException.cs b/src/Resilion/CircuitBreaker/CircuitBrokenException.cs new file mode 100644 index 0000000..15c0849 --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBrokenException.cs @@ -0,0 +1,27 @@ +namespace Resilion; + +/// +/// Thrown when the circuit breaker is open or isolated and rejects an execution attempt. +/// +public sealed class CircuitBrokenException : ResilionException +{ + internal CircuitBrokenException(CircuitState state, TimeSpan retryAfter) + : base(state == CircuitState.Isolated + ? "The circuit breaker is manually isolated and rejecting all calls." + : $"The circuit breaker is open and rejecting calls. Retry after {retryAfter.TotalSeconds:F1}s.") + { + CircuitState = state; + RetryAfter = retryAfter; + } + + /// + /// Gets the state of the circuit breaker when the rejection occurred. + /// + public CircuitState CircuitState { get; } + + /// + /// Gets the estimated duration until the circuit transitions to . + /// Returns if the circuit is already half-open or isolated. + /// + public TimeSpan RetryAfter { get; } +} diff --git a/src/Resilion/CircuitBreaker/CircuitState.cs b/src/Resilion/CircuitBreaker/CircuitState.cs new file mode 100644 index 0000000..c4df0e2 --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitState.cs @@ -0,0 +1,30 @@ +namespace Resilion; + +/// +/// The current state of a circuit breaker. +/// +public enum CircuitState +{ + /// + /// Normal operation. Requests flow through and failures are tracked. + /// + Closed, + + /// + /// The circuit is tripped. All requests are immediately rejected + /// with . + /// + Open, + + /// + /// A limited number of probe requests are allowed through to test recovery. + /// Success transitions to ; failure transitions back to . + /// + HalfOpen, + + /// + /// Manually isolated via . + /// All requests are rejected until is called. + /// + Isolated, +} diff --git a/src/Resilion/CircuitBreaker/SlidingWindow.cs b/src/Resilion/CircuitBreaker/SlidingWindow.cs new file mode 100644 index 0000000..cae9d1f --- /dev/null +++ b/src/Resilion/CircuitBreaker/SlidingWindow.cs @@ -0,0 +1,195 @@ +namespace Resilion.Internal; + +/// +/// A bucketed sliding window that tracks success/failure counts over a configurable duration. +/// Uses a fixed-size ring buffer of time buckets, each covering a fraction of the total window. +/// +/// +/// Thread-safe. All public methods acquire the internal lock. Counter increments on the current +/// bucket use Interlocked for minimal contention on the hot path (Closed state, call succeeds). +/// +internal sealed class SlidingWindow +{ + private const int BucketCount = 10; + + private readonly Bucket[] _buckets; + private readonly TimeSpan _bucketDuration; + private readonly TimeProvider _timeProvider; + private readonly object _lock = new(); + + private long _currentBucketStartTimestamp; + private int _currentBucketIndex; + + internal SlidingWindow(TimeSpan samplingDuration, TimeProvider timeProvider) + { + _timeProvider = timeProvider; + _bucketDuration = samplingDuration / BucketCount; + _buckets = new Bucket[BucketCount]; + for (var i = 0; i < BucketCount; i++) + { + _buckets[i] = new Bucket(); + } + + _currentBucketStartTimestamp = timeProvider.GetTimestamp(); + _currentBucketIndex = 0; + } + + /// + /// Records a successful execution. + /// + internal void RecordSuccess() + { + lock (_lock) + { + AdvanceWindow(); + _buckets[_currentBucketIndex].Successes++; + } + } + + /// + /// Records a failed execution. + /// + internal void RecordFailure() + { + lock (_lock) + { + AdvanceWindow(); + _buckets[_currentBucketIndex].Failures++; + } + } + + /// + /// Records an outcome and gets the current failure ratio and total throughput across all active buckets. + /// Combines both operations under a single lock acquisition for efficiency. + /// + /// true to record a failure; false to record success. + /// The total number of executions in the window. + /// The failure ratio clamped to [0.0, 1.0], or 0.0 if there are no executions. + internal double RecordAndGetRatio(bool isFailure, out int totalCount) + { + lock (_lock) + { + AdvanceWindow(); + + // Record the outcome + if (isFailure) + { + _buckets[_currentBucketIndex].Failures++; + } + else + { + _buckets[_currentBucketIndex].Successes++; + } + + // Get the ratio + var successes = 0; + var failures = 0; + + for (var i = 0; i < BucketCount; i++) + { + successes += _buckets[i].Successes; + failures += _buckets[i].Failures; + } + + totalCount = successes + failures; + if (totalCount == 0) + { + return 0.0; + } + + var ratio = (double)failures / totalCount; + return Math.Clamp(ratio, 0.0, 1.0); + } + } + + /// + /// Gets the current failure ratio and total throughput across all active buckets. + /// + /// The total number of executions in the window. + /// The failure ratio clamped to [0.0, 1.0], or 0.0 if there are no executions. + internal double GetFailureRatio(out int totalCount) + { + lock (_lock) + { + AdvanceWindow(); + + var successes = 0; + var failures = 0; + + for (var i = 0; i < BucketCount; i++) + { + successes += _buckets[i].Successes; + failures += _buckets[i].Failures; + } + + totalCount = successes + failures; + if (totalCount == 0) + { + return 0.0; + } + + var ratio = (double)failures / totalCount; + return Math.Clamp(ratio, 0.0, 1.0); + } + } + + /// + /// Resets all buckets to zero. Called when transitioning to Closed state. + /// Must be called under the circuit breaker's lock. + /// + internal void Reset() + { + lock (_lock) + { + for (var i = 0; i < BucketCount; i++) + { + _buckets[i].Successes = 0; + _buckets[i].Failures = 0; + } + + _currentBucketStartTimestamp = _timeProvider.GetTimestamp(); + _currentBucketIndex = 0; + } + } + + /// + /// Advances the window by rotating expired buckets. Must be called under _lock. + /// + private void AdvanceWindow() + { + var elapsed = _timeProvider.GetElapsedTime(_currentBucketStartTimestamp); + if (elapsed < _bucketDuration) + { + return; + } + + var bucketsToAdvance = (int)(elapsed / _bucketDuration); + if (bucketsToAdvance >= BucketCount) + { + for (var i = 0; i < BucketCount; i++) + { + _buckets[i].Successes = 0; + _buckets[i].Failures = 0; + } + + _currentBucketStartTimestamp = _timeProvider.GetTimestamp(); + _currentBucketIndex = 0; + return; + } + + for (var i = 0; i < bucketsToAdvance; i++) + { + _currentBucketIndex = (_currentBucketIndex + 1) % BucketCount; + _buckets[_currentBucketIndex].Successes = 0; + _buckets[_currentBucketIndex].Failures = 0; + } + + _currentBucketStartTimestamp = _timeProvider.GetTimestamp(); + } + + private sealed class Bucket + { + public int Successes; + public int Failures; + } +} diff --git a/src/Resilion/Fallback/FallbackAction.cs b/src/Resilion/Fallback/FallbackAction.cs new file mode 100644 index 0000000..074d18b --- /dev/null +++ b/src/Resilion/Fallback/FallbackAction.cs @@ -0,0 +1,116 @@ +namespace Resilion; + +/// +/// Represents a fallback action that produces a substitute result. Supports three forms +/// via implicit conversion: a constant value, a synchronous factory, or an async factory. +/// +/// The result type. +/// +/// +/// // Constant value — simplest case +/// FallbackAction = "default" +/// +/// // Sync factory — compute from the failure +/// FallbackAction = ctx => ComputeDefault(ctx.Exception) +/// +/// // Async factory — call a cache or secondary service +/// FallbackAction = async ctx => await cache.GetAsync("fallback-key") +/// +/// +public readonly struct FallbackAction +{ + private readonly TResult? _value; + private readonly Func, TResult>? _syncFactory; + private readonly Func, ValueTask>? _asyncFactory; + private readonly FallbackKind _kind; + + private FallbackAction(TResult? value, Func, TResult>? syncFactory, + Func, ValueTask>? asyncFactory, FallbackKind kind) + { + _value = value; + _syncFactory = syncFactory; + _asyncFactory = asyncFactory; + _kind = kind; + } + + /// + /// Gets a value indicating whether this action has been assigned. + /// + public bool HasValue => _kind != FallbackKind.None; + + /// + /// Produces the fallback result asynchronously. + /// + internal async ValueTask ExecuteAsync(FallbackContext context) + { + return _kind switch + { + FallbackKind.Constant => _value!, + FallbackKind.Sync => _syncFactory!(context), + FallbackKind.Async => await _asyncFactory!(context).ConfigureAwait(false), + _ => throw new InvalidOperationException("FallbackAction has not been configured."), + }; + } + + /// + /// Produces the fallback result synchronously. + /// + internal TResult Execute(FallbackContext context) + { + if (_kind == FallbackKind.Async) + { + // Copy to local to avoid struct 'this' capture in lambda. + var factory = _asyncFactory!; + return Task.Run(() => factory(context).AsTask()).GetAwaiter().GetResult(); + } + + return _kind switch + { + FallbackKind.Constant => _value!, + FallbackKind.Sync => _syncFactory!(context), + _ => throw new InvalidOperationException("FallbackAction has not been configured."), + }; + } + + /// + /// Implicitly converts a constant value to a . + /// + public static implicit operator FallbackAction(TResult value) + => new(value, null, null, FallbackKind.Constant); + + /// + /// Implicitly converts a synchronous factory to a . + /// + public static implicit operator FallbackAction(Func, TResult> factory) + => new(default, factory, null, FallbackKind.Sync); + + /// + /// Implicitly converts an async factory to a . + /// + public static implicit operator FallbackAction(Func, ValueTask> factory) + => new(default, null, factory, FallbackKind.Async); + + private enum FallbackKind : byte + { + None, + Constant, + Sync, + Async, + } +} + +/// +/// Context provided to fallback action factories, containing information about the failure. +/// +/// The result type. +/// The failed outcome that triggered the fallback. +/// The execution context. +public readonly record struct FallbackContext( + Outcome Outcome, + ResilienceContext Context) +{ + /// + /// Gets the exception from the failed outcome, if any. + /// + public Exception? Exception => Outcome.Exception; +} diff --git a/src/Resilion/Fallback/FallbackExtensions.cs b/src/Resilion/Fallback/FallbackExtensions.cs new file mode 100644 index 0000000..fad5346 --- /dev/null +++ b/src/Resilion/Fallback/FallbackExtensions.cs @@ -0,0 +1,27 @@ +namespace Resilion; + +/// +/// Extension methods for adding the Fallback strategy to a typed pipeline builder. +/// +/// +/// Fallback is only available on typed pipelines because it must produce a substitute +/// value of the correct type. +/// +public static class FallbackExtensions +{ + /// + /// Adds a fallback strategy to a typed pipeline. + /// + /// The result type. + /// The typed pipeline builder. + /// The fallback options. + /// The builder for chaining. + public static PipelineBuilder AddFallback( + this PipelineBuilder builder, + FallbackStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new FallbackStrategy(options), Internal.StrategyType.Fallback); + } +} diff --git a/src/Resilion/Fallback/FallbackStrategy.cs b/src/Resilion/Fallback/FallbackStrategy.cs new file mode 100644 index 0000000..ed6ddad --- /dev/null +++ b/src/Resilion/Fallback/FallbackStrategy.cs @@ -0,0 +1,63 @@ +namespace Resilion; + +/// +/// Typed fallback strategy — provides a substitute result when the operation fails. +/// +internal sealed class FallbackStrategy : Strategy +{ + private readonly FallbackStrategyOptions _options; + + internal FallbackStrategy(FallbackStrategyOptions options) + { + _options = options; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + + if (!_options.ShouldHandleOutcome(outcome)) + { + return outcome; + } + + ResilionTelemetry.FallbackActivations.Add(1); + + var fallbackCtx = new FallbackContext(outcome, context); + var fallbackResult = await _options.FallbackAction.ExecuteAsync(fallbackCtx).ConfigureAwait(false); + + if (_options.OnFallback is { } handler && handler.HasHandler) + { + await handler.InvokeAsync(new OnFallbackEvent(outcome, fallbackResult, context)) + .ConfigureAwait(false); + } + + return Outcome.FromResult(fallbackResult); + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + var outcome = callback(context); + + if (!_options.ShouldHandleOutcome(outcome)) + { + return outcome; + } + + ResilionTelemetry.FallbackActivations.Add(1); + + var fallbackCtx = new FallbackContext(outcome, context); + var fallbackResult = _options.FallbackAction.Execute(fallbackCtx); + + if (_options.OnFallback is { } handler && handler.HasHandler) + { + handler.Invoke(new OnFallbackEvent(outcome, fallbackResult, context)); + } + + return Outcome.FromResult(fallbackResult); + } +} diff --git a/src/Resilion/Fallback/FallbackStrategyOptions.cs b/src/Resilion/Fallback/FallbackStrategyOptions.cs new file mode 100644 index 0000000..f00e1eb --- /dev/null +++ b/src/Resilion/Fallback/FallbackStrategyOptions.cs @@ -0,0 +1,60 @@ +namespace Resilion; + +/// +/// Options for the Fallback resilience strategy. +/// +/// The result type. +/// +/// Fallback is only available on typed pipelines () +/// because it must produce a substitute value of the correct type. +/// +public sealed record FallbackStrategyOptions +{ + /// + /// Gets the fallback action that produces a substitute result. + /// Supports constant values, sync factories, and async factories via implicit conversion. + /// + public FallbackAction FallbackAction { get; init; } + + /// + /// Gets the predicate that determines which outcomes trigger the fallback. + /// Defaults to all exceptions except . + /// + public Func, bool>? ShouldHandle { get; init; } + + /// + /// Gets an optional event handler fired when the fallback is activated. + /// + public ResilienceEventHandler>? OnFallback { get; init; } + + internal void Validate() + { + if (!FallbackAction.HasValue) + { + throw new InvalidOperationException( + "FallbackAction must be configured. Assign a value, sync factory, or async factory."); + } + } + + internal bool ShouldHandleOutcome(Outcome outcome) + { + if (ShouldHandle is not null) + { + return ShouldHandle(outcome); + } + + return outcome.Exception is not null and not OperationCanceledException; + } +} + +/// +/// Event arguments for the callback. +/// +/// The result type. +/// The failed outcome that triggered the fallback. +/// The substitute result that will be returned. +/// The execution context. +public readonly record struct OnFallbackEvent( + Outcome Outcome, + TResult FallbackResult, + ResilienceContext Context); diff --git a/src/Resilion/Hedging/HedgingExtensions.cs b/src/Resilion/Hedging/HedgingExtensions.cs new file mode 100644 index 0000000..2051108 --- /dev/null +++ b/src/Resilion/Hedging/HedgingExtensions.cs @@ -0,0 +1,26 @@ +namespace Resilion; + +/// +/// Extension methods for adding the Hedging strategy to a typed pipeline builder. +/// +/// +/// Hedging is only available on typed pipelines because it must produce a result of the correct type. +/// +public static class HedgingExtensions +{ + /// + /// Adds a hedging strategy to a typed pipeline. + /// + /// The result type. + /// The typed pipeline builder. + /// The hedging options. + /// The builder for chaining. + public static PipelineBuilder AddHedging( + this PipelineBuilder builder, + HedgingStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new HedgingStrategy(options, builder.TimeProvider), Internal.StrategyType.Hedging); + } +} diff --git a/src/Resilion/Hedging/HedgingRejectedException.cs b/src/Resilion/Hedging/HedgingRejectedException.cs new file mode 100644 index 0000000..a34272a --- /dev/null +++ b/src/Resilion/Hedging/HedgingRejectedException.cs @@ -0,0 +1,19 @@ +namespace Resilion; + +/// +/// Thrown when all hedging attempts fail. +/// +public sealed class HedgingRejectedException : ResilionException +{ + internal HedgingRejectedException(IReadOnlyList attemptExceptions) + : base("All hedging attempts failed.") + { + AttemptExceptions = attemptExceptions; + } + + /// + /// Gets the exceptions from all hedging attempts, in attempt order. + /// The first element is the primary attempt's exception. + /// + public IReadOnlyList AttemptExceptions { get; } +} diff --git a/src/Resilion/Hedging/HedgingStrategy.cs b/src/Resilion/Hedging/HedgingStrategy.cs new file mode 100644 index 0000000..a95f48b --- /dev/null +++ b/src/Resilion/Hedging/HedgingStrategy.cs @@ -0,0 +1,321 @@ +namespace Resilion; + +/// +/// Hedging strategy — races concurrent attempts and returns the first successful result. +/// All losing attempts are cancelled and awaited for proper resource cleanup. +/// +internal sealed class HedgingStrategy : Strategy +{ + private readonly HedgingStrategyOptions _options; + private readonly TimeProvider _timeProvider; + + internal HedgingStrategy(HedgingStrategyOptions options, TimeProvider timeProvider) + { + _options = options; + _timeProvider = timeProvider; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (_options.MaxHedgedAttempts == 1) + { + // No hedging — just execute the primary. + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + var userToken = context.CancellationToken; + var attempts = new List>>(_options.MaxHedgedAttempts); + var perAttemptCts = new List(_options.MaxHedgedAttempts); + + try + { + // Launch primary attempt (attempt 0). + LaunchAttempt(0, callback, context, userToken, attempts, perAttemptCts); + + for (var attemptIndex = 1; attemptIndex < _options.MaxHedgedAttempts; attemptIndex++) + { + // Wait for the hedging delay, or for the primary/earlier attempt to complete. + if (_options.HedgingDelay == System.Threading.Timeout.InfiniteTimeSpan) + { + // Sequential mode: wait for the current attempt to complete before launching next. + var completed = await attempts[^1].ConfigureAwait(false); + if (!_options.ShouldHandleOutcome(completed)) + { + // Success — no need for more attempts. + return completed; + } + } + else if (_options.HedgingDelay > TimeSpan.Zero) + { + // Latency mode: wait for the delay, but also check if any attempt completes early. + var delayTask = Task.Delay(_options.HedgingDelay, _timeProvider, userToken); + var whenAnyTask = Task.WhenAny(attempts); + var winner = await Task.WhenAny(whenAnyTask, delayTask).ConfigureAwait(false); + + if (winner != delayTask) + { + // An attempt completed before the delay. The whenAnyTask has completed, + // revealing the first completed task from attempts. Await it to unwrap + // without calling WhenAny again (which would risk returning a different + // task if multiple completed between our WhenAny call and the second one). + var completedTask = await whenAnyTask.ConfigureAwait(false); + var completed = await completedTask.ConfigureAwait(false); + if (!_options.ShouldHandleOutcome(completed)) + { + return completed; + } + + // Task is a handled failure; remove it from the list so it doesn't + // immediately win WhenAny on the next iteration, bypassing the delay. + attempts.Remove(completedTask); + } + } + // Parallel mode (delay == 0): launch immediately, no waiting. + + ResilionTelemetry.HedgingAttempts.Add(1); + + // Fire OnHedging event. + if (_options.OnHedging is { } handler && handler.HasHandler) + { + await handler.InvokeAsync(new OnHedgingEvent(attemptIndex, context)) + .ConfigureAwait(false); + } + + // Launch next hedged attempt. + LaunchAttempt(attemptIndex, callback, context, userToken, attempts, perAttemptCts); + } + + // All attempts launched. Wait for the first success or all to fail. + return await WaitForBestOutcome(attempts, userToken).ConfigureAwait(false); + } + finally + { + // Cancel all remaining in-flight attempts. + foreach (var cts in perAttemptCts) + { + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Already disposed — fine. + } + } + + // Await all tasks to ensure resource cleanup (connections, streams, etc.). + // Use a shared bounded deadline to prevent indefinite hangs on non-cooperative tasks. + var cleanupTimeout = TimeSpan.FromSeconds(5); + var cleanupStart = _timeProvider.GetTimestamp(); + foreach (var task in attempts) + { + try + { + var elapsed = _timeProvider.GetElapsedTime(cleanupStart); + var remaining = cleanupTimeout - elapsed; + if (remaining <= TimeSpan.Zero) + { + break; // Deadline expired; stop waiting. + } + + await Task.WhenAny(task, Task.Delay(remaining)).ConfigureAwait(false); + } + catch + { + // Swallow — we've already got our result or failure. + } + } + + // Dispose all CTS instances. + foreach (var cts in perAttemptCts) + { + cts.Dispose(); + } + } + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + // Hedging is inherently async (parallel execution). + // For the sync path, execute sequentially (equivalent to InfiniteTimeSpan mode). + Outcome lastOutcome = default; + + for (var attemptIndex = 0; attemptIndex < _options.MaxHedgedAttempts; attemptIndex++) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + var action = ResolveAction(attemptIndex, callback, context); + + // Create per-attempt context copy to avoid stale state from previous attempts. + // Matches async path behavior (ResilienceContextPool.Shared.Rent + CopyFrom). + var attemptContext = ResilienceContextPool.Shared.Rent(context.CancellationToken); + attemptContext.OperationKey = context.OperationKey; + attemptContext.ContinueOnCapturedContext = context.ContinueOnCapturedContext; + attemptContext.Properties.CopyFrom(context.Properties); + try + { + lastOutcome = action(attemptContext); + } + finally + { + ResilienceContextPool.Shared.Return(attemptContext); + } + + if (!_options.ShouldHandleOutcome(lastOutcome)) + { + return lastOutcome; + } + + if (attemptIndex < _options.MaxHedgedAttempts - 1) + { + if (_options.OnHedging is { } handler && handler.HasHandler) + { + handler.Invoke(new OnHedgingEvent(attemptIndex + 1, context)); + } + } + } + + return lastOutcome; + } + + private void LaunchAttempt( + int attemptIndex, + Func>> callback, + ResilienceContext context, + CancellationToken userToken, + List>> attempts, + List perAttemptCts) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(userToken); + perAttemptCts.Add(cts); + + var action = ResolveAsyncAction(attemptIndex, callback, context); + var token = cts.Token; + + var task = Task.Run(async () => + { + try + { + return await action(token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested && !userToken.IsCancellationRequested) + { + // Cancelled by hedging strategy (not by user) — treat as a handled failure. + return Outcome.FromException(new OperationCanceledException("Hedging attempt was cancelled.")); + } + }, userToken); + + attempts.Add(task); + } + + private Func>> ResolveAsyncAction( + int attemptIndex, + Func>> callback, + ResilienceContext context) + { + if (_options.ActionGenerator is not null) + { + var customAction = _options.ActionGenerator(new HedgingActionContext(attemptIndex)); + if (customAction is not null) + { + return async ct => + { + try + { + var result = await customAction(ct).ConfigureAwait(false); + return Outcome.FromResult(result); + } + catch (Exception ex) + { + return Outcome.FromException(ex); + } + }; + } + } + + // Default: re-execute the original callback. + return async ct => + { + // Create a lightweight context copy with the per-attempt token. + var attemptContext = ResilienceContextPool.Shared.Rent(ct); + attemptContext.OperationKey = context.OperationKey; + attemptContext.ContinueOnCapturedContext = context.ContinueOnCapturedContext; + attemptContext.Properties.CopyFrom(context.Properties); + try + { + return await callback(attemptContext).ConfigureAwait(false); + } + finally + { + ResilienceContextPool.Shared.Return(attemptContext); + } + }; + } + + private Func> ResolveAction( + int attemptIndex, + Func> callback, + ResilienceContext context) + { + if (_options.ActionGenerator is not null) + { + var customAction = _options.ActionGenerator(new HedgingActionContext(attemptIndex)); + if (customAction is not null) + { + return ctx => + { + try + { + // Wrap async call with Task.Run to escape SynchronizationContext, + // preventing deadlock when ActionGenerator returns async action. + var result = Task.Run( + () => customAction(ctx.CancellationToken).AsTask(), + ctx.CancellationToken) + .GetAwaiter() + .GetResult(); + return Outcome.FromResult(result); + } + catch (Exception ex) + { + return Outcome.FromException(ex); + } + }; + } + } + + return callback; + } + + private async Task> WaitForBestOutcome( + List>> attempts, + CancellationToken userToken) + { + var remaining = new List>>(attempts); + Outcome lastFailure = default; + + while (remaining.Count > 0) + { + userToken.ThrowIfCancellationRequested(); + + var completed = await Task.WhenAny(remaining).ConfigureAwait(false); + remaining.Remove(completed); + + var outcome = await completed.ConfigureAwait(false); + + if (!_options.ShouldHandleOutcome(outcome)) + { + return outcome; + } + + // Track the last failure in case all attempts fail. + lastFailure = outcome; + } + + // All attempts failed — return the last failure outcome. + return lastFailure; + } +} diff --git a/src/Resilion/Hedging/HedgingStrategyOptions.cs b/src/Resilion/Hedging/HedgingStrategyOptions.cs new file mode 100644 index 0000000..0e5a23c --- /dev/null +++ b/src/Resilion/Hedging/HedgingStrategyOptions.cs @@ -0,0 +1,94 @@ +namespace Resilion; + +/// +/// Options for the Hedging resilience strategy. +/// +/// The result type. +/// +/// +/// Hedging reduces tail latency by racing multiple concurrent attempts. +/// The first successful attempt wins; all others are cancelled. +/// +/// +/// Three modes based on : +/// +/// Latency mode (delay > 0): Wait, then fire secondary if primary is still pending +/// Parallel mode (delay = 0): Fire all attempts simultaneously +/// Sequential mode (): Wait for failure before trying next +/// +/// +/// +public sealed record HedgingStrategyOptions +{ + /// + /// Gets the maximum number of hedged attempts, including the primary. + /// Defaults to 2 (one primary + one hedged). + /// + public int MaxHedgedAttempts { get; init; } = 2; + + /// + /// Gets the delay before launching each additional hedged attempt. + /// Defaults to 2 seconds. Set to for parallel mode. + /// Set to for sequential mode. + /// + public TimeSpan HedgingDelay { get; init; } = TimeSpan.FromSeconds(2); + + /// + /// Gets the predicate that determines which outcomes should trigger hedging. + /// Defaults to all exceptions except . + /// + public Func, bool>? ShouldHandle { get; init; } + + /// + /// Gets an optional action generator for hedged attempts. When null, the original + /// action is re-executed. When set, can return different actions per attempt + /// (e.g., call a different endpoint). + /// + public Func>?>? ActionGenerator { get; init; } + + /// + /// Gets an optional event handler fired before each hedged attempt is launched. + /// + public ResilienceEventHandler>? OnHedging { get; init; } + + internal void Validate() + { + if (MaxHedgedAttempts < 1) + { + throw new ArgumentOutOfRangeException(nameof(MaxHedgedAttempts), MaxHedgedAttempts, + "MaxHedgedAttempts must be >= 1."); + } + + if (HedgingDelay < TimeSpan.Zero && HedgingDelay != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(HedgingDelay), HedgingDelay, + "HedgingDelay must be non-negative, TimeSpan.Zero, or Timeout.InfiniteTimeSpan."); + } + } + + internal bool ShouldHandleOutcome(Outcome outcome) + { + if (ShouldHandle is not null) + { + return ShouldHandle(outcome); + } + + return outcome.Exception is not null and not OperationCanceledException; + } +} + +/// +/// Context passed to the delegate. +/// +/// The 0-based attempt index (0 = primary, 1 = first hedge, etc.). +public readonly record struct HedgingActionContext(int AttemptNumber); + +/// +/// Event arguments for the callback. +/// +/// The result type. +/// The 0-based attempt index being launched. +/// The execution context. +public readonly record struct OnHedgingEvent( + int AttemptNumber, + ResilienceContext Context); diff --git a/src/Resilion/Internal/OrderingValidator.cs b/src/Resilion/Internal/OrderingValidator.cs new file mode 100644 index 0000000..f287ffe --- /dev/null +++ b/src/Resilion/Internal/OrderingValidator.cs @@ -0,0 +1,102 @@ +namespace Resilion.Internal; + +/// +/// Validates strategy ordering at Build() time and returns diagnostic warnings +/// for common misorderings. Warnings only — never blocks the build. +/// +internal static class OrderingValidator +{ + /// + /// Validates the order of strategies and returns any warnings. + /// + internal static List Validate(IReadOnlyList strategies) + { + var warnings = new List(); + + if (strategies.Count < 2) + { + return warnings; + } + + // Build index maps for quick lookup. + var positions = new Dictionary>(); + for (var i = 0; i < strategies.Count; i++) + { + var type = strategies[i]; + if (!positions.TryGetValue(type, out var list)) + { + list = []; + positions[type] = list; + } + + list.Add(i); + } + + // Rule 1: CircuitBreaker should be inside (after) Retry, not outside (before). + // If CB is before Retry, retries bypass the breaker entirely. + if (positions.TryGetValue(StrategyType.CircuitBreaker, out var cbPositions) + && positions.TryGetValue(StrategyType.Retry, out var retryPositions)) + { + foreach (var cbPos in cbPositions) + { + foreach (var retryPos in retryPositions) + { + if (cbPos < retryPos) + { + warnings.Add( + "CircuitBreaker is outside Retry (position " + cbPos + " vs " + retryPos + "). " + + "This means retries bypass the circuit breaker. Usually CircuitBreaker should be " + + "inside (after) Retry so each attempt is tracked independently."); + } + } + } + } + + // Rule 2: Fallback should typically be outermost (first) to catch all failures. + if (positions.TryGetValue(StrategyType.Fallback, out var fallbackPositions)) + { + foreach (var fbPos in fallbackPositions) + { + if (fbPos > 0) + { + warnings.Add( + "Fallback is at position " + fbPos + ", not outermost (position 0). " + + "Fallback typically goes first so it catches failures from all inner strategies."); + } + } + } + + // Rule 3: Hedging and Retry together — may cause excessive load. + if (positions.ContainsKey(StrategyType.Hedging) && positions.ContainsKey(StrategyType.Retry)) + { + warnings.Add( + "Both Hedging and Retry are present. Hedging launches parallel attempts, and Retry " + + "re-executes on failure. Together they can generate a large number of requests. " + + "Verify this is intentional."); + } + + // Rule 4: Multiple Timeout strategies without clear total/per-attempt separation. + if (positions.TryGetValue(StrategyType.Timeout, out var timeoutPositions) && timeoutPositions.Count > 2) + { + warnings.Add( + "More than 2 Timeout strategies detected. Typically you need at most 2: " + + "one total timeout (outermost) and one per-attempt timeout (innermost)."); + } + + // Rule 5: Retry as outermost strategy with no Timeout outside it. + if (positions.TryGetValue(StrategyType.Retry, out var retryPos2)) + { + foreach (var rPos in retryPos2) + { + if (rPos == 0 && !positions.ContainsKey(StrategyType.Timeout)) + { + warnings.Add( + "Retry is the outermost strategy with no Timeout. Without a total timeout, " + + "retries could run indefinitely if each attempt takes a long time."); + } + } + } + + return warnings; + } +} diff --git a/src/Resilion/Internal/PipelineComponent.cs b/src/Resilion/Internal/PipelineComponent.cs new file mode 100644 index 0000000..d4f6912 --- /dev/null +++ b/src/Resilion/Internal/PipelineComponent.cs @@ -0,0 +1,167 @@ +namespace Resilion.Internal; + +/// +/// Internal component in the pipeline execution chain. Each strategy is wrapped in a component +/// that calls the next component in the chain, forming a middleware-like pipeline. +/// +internal abstract class PipelineComponent : IDisposable +{ + /// + /// Executes this component's logic and optionally delegates to the next component. + /// + internal abstract ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context); + + /// + /// Executes this component's logic synchronously. + /// + internal abstract Outcome Execute( + Func> callback, + ResilienceContext context); + + public virtual void Dispose() + { + } + + /// + /// A no-op component that directly invokes the callback. Used as the terminal component. + /// + internal static PipelineComponent Empty { get; } = new EmptyComponent(); + + private sealed class EmptyComponent : PipelineComponent + { + internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + => callback(context); + + internal override Outcome Execute( + Func> callback, + ResilienceContext context) + => callback(context); + } +} + +/// +/// Wraps a non-generic as a . +/// +internal sealed class StrategyComponent : PipelineComponent +{ + private readonly Strategy _strategy; + private readonly PipelineComponent _next; + + internal StrategyComponent(Strategy strategy, PipelineComponent next) + { + _strategy = strategy; + _next = next; + } + + internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + return _strategy.ExecuteAsync( + ctx => _next.ExecuteAsync(callback, ctx), + context); + } + + internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + return _strategy.Execute( + ctx => _next.Execute(callback, ctx), + context); + } + + public override void Dispose() + { + _strategy.Dispose(); + _next.Dispose(); + } +} + +/// +/// Wraps a generic as a . +/// When the execution result type does not match, the component passes through to the next component. +/// +internal sealed class TypedStrategyComponent : PipelineComponent +{ + private readonly Strategy _strategy; + private readonly PipelineComponent _next; + + internal TypedStrategyComponent(Strategy strategy, PipelineComponent next) + { + _strategy = strategy; + _next = next; + } + + internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + // Type check is a JIT-time constant for specific TResult instantiations. + if (typeof(TResult) == typeof(TStrategyResult)) + { + return ExecuteTypedAsync(callback, context); + } + + // Type mismatch — skip this strategy entirely. + return _next.ExecuteAsync(callback, context); + } + + internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + if (typeof(TResult) == typeof(TStrategyResult)) + { + return ExecuteTyped(callback, context); + } + + return _next.Execute(callback, context); + } + + private ValueTask> ExecuteTypedAsync( + Func>> callback, + ResilienceContext context) + { + // We know TResult == TStrategyResult at this point. + // Reinterpret-cast the callback — zero allocation since Outcome and + // Outcome have identical layout when TResult == TStrategyResult. + var typedCallback = (Func>>)(object)callback; + + // Build the "next" callback that chains through _next then back to the user callback. + var next = _next; + var task = _strategy.ExecuteAsync( + ctx => next.ExecuteAsync(typedCallback, ctx), + context); + + return System.Runtime.CompilerServices.Unsafe.As< + ValueTask>, + ValueTask>>(ref task); + } + + private Outcome ExecuteTyped( + Func> callback, + ResilienceContext context) + { + var typedCallback = (Func>)(object)callback; + + var next = _next; + var result = _strategy.Execute( + ctx => next.Execute(typedCallback, ctx), + context); + + return System.Runtime.CompilerServices.Unsafe.As< + Outcome, + Outcome>(ref result); + } + + public override void Dispose() + { + _strategy.Dispose(); + _next.Dispose(); + } +} diff --git a/src/Resilion/Internal/StrategyType.cs b/src/Resilion/Internal/StrategyType.cs new file mode 100644 index 0000000..5fb1057 --- /dev/null +++ b/src/Resilion/Internal/StrategyType.cs @@ -0,0 +1,16 @@ +namespace Resilion.Internal; + +/// +/// Identifies the kind of strategy for ordering validation. +/// +internal enum StrategyType +{ + Unknown, + Timeout, + Retry, + CircuitBreaker, + Fallback, + RateLimiter, + Hedging, + Custom, +} diff --git a/src/Resilion/Outcome.cs b/src/Resilion/Outcome.cs new file mode 100644 index 0000000..5b248db --- /dev/null +++ b/src/Resilion/Outcome.cs @@ -0,0 +1,186 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; + +namespace Resilion; + +/// +/// A discriminated union representing either a successful result of type +/// or a captured . Used throughout the pipeline to avoid re-throwing exceptions +/// on the hot path — strategies pass outcomes as data rather than catching and rethrowing. +/// +/// The type of the successful result. +[StructLayout(LayoutKind.Auto)] +public readonly struct Outcome : IEquatable> +{ + private readonly TResult? _result; + private readonly ExceptionDispatchInfo? _exceptionDispatchInfo; + + private Outcome(TResult? result, ExceptionDispatchInfo? exceptionDispatchInfo) + { + _result = result; + _exceptionDispatchInfo = exceptionDispatchInfo; + } + + /// + /// Gets a value indicating whether this outcome represents a successful result. + /// + public bool IsSuccess => _exceptionDispatchInfo is null; + + /// + /// Gets a value indicating whether this outcome represents a failure (captured exception). + /// + public bool IsFailure => _exceptionDispatchInfo is not null; + + /// + /// Gets the exception that caused the failure, or null if this outcome is a success. + /// + public Exception? Exception => _exceptionDispatchInfo?.SourceException; + + /// + /// Gets the successful result value. + /// + /// Thrown when the outcome is a failure. + public TResult Result + { + get + { + if (_exceptionDispatchInfo is not null) + { + throw new InvalidOperationException( + "Cannot access Result on a failed Outcome. Check IsSuccess before accessing Result, " + + "or use TryGetResult/GetResultOrDefault.", + _exceptionDispatchInfo.SourceException); + } + + return _result!; + } + } + + /// + /// Attempts to get the successful result value. + /// + /// When this method returns true, contains the result value. + /// true if this outcome is a success; false otherwise. + public bool TryGetResult([MaybeNullWhen(false)] out TResult result) + { + if (_exceptionDispatchInfo is null) + { + result = _result!; + return true; + } + + result = default; + return false; + } + + /// + /// Returns the successful result value, or the specified default if this outcome is a failure. + /// + /// The value to return if this outcome is a failure. + /// The result value on success, or on failure. + public TResult GetResultOrDefault(TResult defaultValue = default!) + => _exceptionDispatchInfo is null ? _result! : defaultValue; + + /// + /// Returns the result if successful, or rethrows the captured exception with its original stack trace preserved. + /// + /// The successful result value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public TResult ThrowIfFailed() + { + _exceptionDispatchInfo?.Throw(); + return _result!; + } + + /// + /// Applies one of two functions depending on whether this outcome is a success or failure. + /// + /// The return type of both functions. + /// Function to apply if the outcome is a success. + /// Function to apply if the outcome is a failure. + /// The result of the applied function. + public T Match(Func onSuccess, Func onFailure) + { + ArgumentNullException.ThrowIfNull(onSuccess); + ArgumentNullException.ThrowIfNull(onFailure); + + return _exceptionDispatchInfo is null + ? onSuccess(_result!) + : onFailure(_exceptionDispatchInfo.SourceException); + } + + /// + /// Creates a successful outcome containing the specified result. + /// + /// The result value. + /// A successful . + public static Outcome FromResult(TResult result) + => new(result, null); + + /// + /// Creates a failed outcome containing the specified exception. + /// + /// The exception representing the failure. + /// A failed . + /// Thrown when is null. + public static Outcome FromException(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + return new(default, ExceptionDispatchInfo.Capture(exception)); + } + + /// + /// Implicitly converts a result value to a successful . + /// + /// The result value. + public static implicit operator Outcome(TResult result) + => FromResult(result); + + /// + public bool Equals(Outcome other) + { + if (IsSuccess != other.IsSuccess) + { + return false; + } + + if (IsSuccess) + { + return EqualityComparer.Default.Equals(_result, other._result); + } + + return ReferenceEquals( + _exceptionDispatchInfo!.SourceException, + other._exceptionDispatchInfo!.SourceException); + } + + /// + public override bool Equals(object? obj) + => obj is Outcome other && Equals(other); + + /// + public override int GetHashCode() + => IsSuccess + ? HashCode.Combine(true, _result) + : HashCode.Combine(false, _exceptionDispatchInfo!.SourceException); + + /// + /// Determines whether two outcomes are equal. + /// + public static bool operator ==(Outcome left, Outcome right) + => left.Equals(right); + + /// + /// Determines whether two outcomes are not equal. + /// + public static bool operator !=(Outcome left, Outcome right) + => !left.Equals(right); + + /// + public override string ToString() + => IsSuccess + ? $"Outcome<{typeof(TResult).Name}>(Success: {_result})" + : $"Outcome<{typeof(TResult).Name}>(Failure: {_exceptionDispatchInfo!.SourceException.GetType().Name}: {_exceptionDispatchInfo.SourceException.Message})"; +} diff --git a/src/Resilion/Pipeline.cs b/src/Resilion/Pipeline.cs new file mode 100644 index 0000000..4ae66e8 --- /dev/null +++ b/src/Resilion/Pipeline.cs @@ -0,0 +1,282 @@ +using System.Runtime.ExceptionServices; +using Resilion.Internal; + +namespace Resilion; + +/// +/// Executes operations with resilience. Strategies in the pipeline can react to exceptions +/// but do not inspect result values. Use this when you don't need result-based predicates +/// (e.g., timeout, rate limiting, exception-only retry). +/// +/// +/// +/// Pipelines are thread-safe and immutable after construction. Build once, cache, and reuse. +/// +/// +/// For pipelines that need to inspect return values (e.g., retry on HTTP 500, fallback), +/// use . +/// +/// +public sealed class Pipeline : IDisposable +{ + private readonly PipelineComponent _component; + private readonly ResilienceContextPool _pool; + + internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = null) + { + _component = component; + _pool = pool ?? ResilienceContextPool.Shared; + } + + /// + /// Gets a no-op pipeline that passes through to the user delegate without applying any strategies. + /// Useful for testing and conditional composition. + /// + public static Pipeline Empty { get; } = new(PipelineComponent.Empty); + + /// + /// Gets the list of internal components for pipeline composition via AddPipeline. + /// + internal PipelineComponent Component => _component; + + /// + /// Creates a pipeline by configuring strategies on a builder. + /// + /// A delegate that adds strategies to the builder. + /// A configured, immutable . + public static Pipeline Create(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = new PipelineBuilder(); + configure(builder); + return builder.Build(); + } + + /// + /// Creates a typed pipeline by configuring strategies on a builder. + /// + /// The result type for the pipeline. + /// A delegate that adds strategies to the builder. + /// A configured, immutable . + public static Pipeline Create(Action> configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = new PipelineBuilder(); + configure(builder); + return builder.Build(); + } + + // ────────────────────────────────────────────────────────────────── + // Async execution + // ────────────────────────────────────────────────────────────────── + + /// + /// Executes an async operation through the pipeline with a state parameter to avoid closure allocations. + /// + /// The result type. + /// The type of the state passed to the callback. + /// The operation to execute. Use a static lambda with to avoid closure allocations. + /// State passed to the callback. + /// Cancellation token for the operation. + /// The result of the operation. + public async ValueTask ExecuteAsync( + Func> action, + TState state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + var context = _pool.Rent(cancellationToken); + try + { + var outcome = await _component.ExecuteAsync( + async ctx => + { + try + { + var result = await action(state, ctx.CancellationToken).ConfigureAwait(ctx.ContinueOnCapturedContext); + return Outcome.FromResult(result); + } + catch (Exception ex) + { + return Outcome.FromException(ex); + } + }, + context).ConfigureAwait(context.ContinueOnCapturedContext); + + return outcome.ThrowIfFailed(); + } + finally + { + _pool.Return(context); + } + } + + /// + /// Executes an async operation through the pipeline. + /// + /// The result type. + /// The operation to execute. + /// Cancellation token for the operation. + /// The result of the operation. + public ValueTask ExecuteAsync( + Func> action, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + return ExecuteAsync( + static (act, ct) => act(ct), + action, + cancellationToken); + } + + /// + /// Executes an async void operation through the pipeline. + /// + /// The operation to execute. + /// Cancellation token for the operation. + public async ValueTask ExecuteAsync( + Func action, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + var context = _pool.Rent(cancellationToken); + try + { + var outcome = await _component.ExecuteAsync( + async ctx => + { + try + { + await action(ctx.CancellationToken).ConfigureAwait(ctx.ContinueOnCapturedContext); + return Outcome.FromResult(VoidResult.Instance); + } + catch (Exception ex) + { + return Outcome.FromException(ex); + } + }, + context).ConfigureAwait(context.ContinueOnCapturedContext); + + outcome.ThrowIfFailed(); + } + finally + { + _pool.Return(context); + } + } + + /// + /// Executes an async operation and returns the without rethrowing. + /// For advanced scenarios where you want to inspect the outcome without exception handling. + /// + /// The result type. + /// The type of the state passed to the callback. + /// The operation to execute. + /// State passed to the callback. + /// The execution context (not pooled — caller manages lifetime). + /// The outcome of the operation. + public ValueTask> ExecuteOutcomeAsync( + Func>> action, + TState state, + ResilienceContext context) + { + ArgumentNullException.ThrowIfNull(action); + ArgumentNullException.ThrowIfNull(context); + + return _component.ExecuteAsync( + ctx => action(state, ctx), + context); + } + + // ────────────────────────────────────────────────────────────────── + // Sync execution + // ────────────────────────────────────────────────────────────────── + + /// + /// Executes a synchronous operation through the pipeline with a state parameter to avoid closure allocations. + /// + /// The result type. + /// The type of the state passed to the callback. + /// The operation to execute. Use a static lambda with to avoid closure allocations. + /// State passed to the callback. + /// Cancellation token for the operation. + /// The result of the operation. + public TResult Execute( + Func action, + TState state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + var context = _pool.Rent(cancellationToken); + context.IsSynchronous = true; + try + { + var outcome = _component.Execute( + ctx => + { + try + { + var result = action(state, ctx.CancellationToken); + return Outcome.FromResult(result); + } + catch (Exception ex) + { + return Outcome.FromException(ex); + } + }, + context); + + return outcome.ThrowIfFailed(); + } + finally + { + _pool.Return(context); + } + } + + /// + /// Executes a synchronous operation through the pipeline. + /// + /// The result type. + /// The operation to execute. + /// Cancellation token for the operation. + /// The result of the operation. + public TResult Execute( + Func action, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + return Execute(static (act, ct) => act(ct), action, cancellationToken); + } + + /// + /// Executes a synchronous void operation through the pipeline. + /// + /// The operation to execute. + /// Cancellation token for the operation. + public void Execute( + Action action, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + Execute( + static (act, ct) => + { + act(ct); + return VoidResult.Instance; + }, + action, + cancellationToken); + } + + /// + public void Dispose() => _component.Dispose(); +} + +/// +/// Internal sentinel type for void pipeline executions. +/// +internal readonly struct VoidResult +{ + internal static readonly VoidResult Instance = default; +} diff --git a/src/Resilion/PipelineBuilder.cs b/src/Resilion/PipelineBuilder.cs new file mode 100644 index 0000000..a77d8c4 --- /dev/null +++ b/src/Resilion/PipelineBuilder.cs @@ -0,0 +1,336 @@ +using Resilion.Internal; + +namespace Resilion; + +/// +/// Builds a by composing resilience strategies. Strategies execute +/// outermost to innermost — the first strategy added is the first to see each call. +/// +/// +/// +/// The canonical strategy order is: RateLimiter → TotalTimeout → Retry → CircuitBreaker → AttemptTimeout. +/// +/// +/// Builders are not thread-safe and should not be shared. Build once, then cache and reuse +/// the resulting . +/// +/// +public sealed class PipelineBuilder +{ + private readonly List> _componentFactories = []; + private readonly List _strategyTypes = []; + + /// + /// Gets or sets an optional name for the pipeline, used in telemetry and diagnostics. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the used by time-dependent strategies. + /// Defaults to . + /// + public TimeProvider TimeProvider { get; set; } = TimeProvider.System; + + /// + /// When true, suppresses ordering validation warnings at time. + /// Defaults to false. + /// + public bool SuppressOrderingWarnings { get; set; } + + /// + /// Optional callback that receives ordering validation warnings at time. + /// When null, warnings are written to . + /// + public Action? OnValidationWarning { get; set; } + + /// + /// Adds a non-generic strategy to the pipeline. + /// + /// The strategy to add. + /// This builder for chaining. + public PipelineBuilder AddStrategy(Strategy strategy) + { + ArgumentNullException.ThrowIfNull(strategy); + _componentFactories.Add(next => new StrategyComponent(strategy, next)); + _strategyTypes.Add(StrategyType.Unknown); + return this; + } + + internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType type) + { + ArgumentNullException.ThrowIfNull(strategy); + _componentFactories.Add(next => new StrategyComponent(strategy, next)); + _strategyTypes.Add(type); + return this; + } + + /// + /// Flattens another pipeline's strategies into this builder, enabling composition of pre-built pipelines. + /// + /// The pipeline whose strategies will be added. + /// This builder for chaining. + public PipelineBuilder AddPipeline(Pipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + var outerComponent = pipeline.Component; + _componentFactories.Add(next => new DelegatingComponent(outerComponent, next)); + _strategyTypes.Add(StrategyType.Custom); + return this; + } + + /// + /// Builds the pipeline. The builder should not be used after calling this method. + /// + /// An immutable, thread-safe . + public Pipeline Build() + { + if (_componentFactories.Count == 0) + { + return Pipeline.Empty; + } + + // Validate ordering unless suppressed. + if (!SuppressOrderingWarnings && _strategyTypes.Count >= 2) + { + var warnings = OrderingValidator.Validate(_strategyTypes); + EmitWarnings(warnings); + } + + var component = PipelineComponent.Empty; + for (var i = _componentFactories.Count - 1; i >= 0; i--) + { + component = _componentFactories[i](component); + } + + return new Pipeline(component); + } + + private void EmitWarnings(List warnings) + { + foreach (var warning in warnings) + { + if (OnValidationWarning is not null) + { + OnValidationWarning(warning); + } + else + { + System.Diagnostics.Debug.WriteLine($"[Resilion] Ordering warning: {warning}"); + } + } + } +} + +/// +/// Builds a by composing resilience strategies. Supports both +/// non-generic strategies (Timeout, RateLimiter) and typed strategies (Fallback, Hedging). +/// +/// The result type for the pipeline. +public sealed class PipelineBuilder +{ + private readonly List> _componentFactories = []; + private readonly List _strategyTypes = []; + + /// + /// Gets or sets an optional name for the pipeline, used in telemetry and diagnostics. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the used by time-dependent strategies. + /// Defaults to . + /// + public TimeProvider TimeProvider { get; set; } = TimeProvider.System; + + /// + /// When true, suppresses ordering validation warnings at time. + /// + public bool SuppressOrderingWarnings { get; set; } + + /// + /// Optional callback that receives ordering validation warnings. + /// When null, warnings go to . + /// + public Action? OnValidationWarning { get; set; } + + /// + /// Adds a non-generic strategy (e.g., Timeout, RateLimiter) to the typed pipeline. + /// + public PipelineBuilder AddStrategy(Strategy strategy) + { + ArgumentNullException.ThrowIfNull(strategy); + _componentFactories.Add(next => new StrategyComponent(strategy, next)); + _strategyTypes.Add(StrategyType.Unknown); + return this; + } + + internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType type) + { + ArgumentNullException.ThrowIfNull(strategy); + _componentFactories.Add(next => new StrategyComponent(strategy, next)); + _strategyTypes.Add(type); + return this; + } + + /// + /// Adds a typed strategy (e.g., Fallback, result-based Retry) to the pipeline. + /// + public PipelineBuilder AddStrategy(Strategy strategy) + { + ArgumentNullException.ThrowIfNull(strategy); + _componentFactories.Add(next => new TypedStrategyComponent(strategy, next)); + _strategyTypes.Add(StrategyType.Unknown); + return this; + } + + internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType type) + { + ArgumentNullException.ThrowIfNull(strategy); + _componentFactories.Add(next => new TypedStrategyComponent(strategy, next)); + _strategyTypes.Add(type); + return this; + } + + /// + /// Adds a delegate-based strategy for quick inline resilience logic. + /// + public PipelineBuilder AddStrategy( + string name, + Func>>, ValueTask>> handler) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(handler); + _componentFactories.Add(next => + new TypedStrategyComponent(new DelegateStrategy(name, handler), next)); + _strategyTypes.Add(StrategyType.Custom); + return this; + } + + /// + /// Flattens another typed pipeline's strategies into this builder. + /// + public PipelineBuilder AddPipeline(Pipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + var outerComponent = pipeline.Component; + _componentFactories.Add(next => new DelegatingComponent(outerComponent, next)); + _strategyTypes.Add(StrategyType.Custom); + return this; + } + + /// + /// Flattens a non-generic pipeline's strategies into this typed builder. + /// + public PipelineBuilder AddPipeline(Pipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + var outerComponent = pipeline.Component; + _componentFactories.Add(next => new DelegatingComponent(outerComponent, next)); + _strategyTypes.Add(StrategyType.Custom); + return this; + } + + /// + /// Builds the pipeline. + /// + public Pipeline Build() + { + if (_componentFactories.Count == 0) + { + return Pipeline.Empty; + } + + if (!SuppressOrderingWarnings && _strategyTypes.Count >= 2) + { + var warnings = OrderingValidator.Validate(_strategyTypes); + EmitWarnings(warnings); + } + + var component = PipelineComponent.Empty; + for (var i = _componentFactories.Count - 1; i >= 0; i--) + { + component = _componentFactories[i](component); + } + + return new Pipeline(component); + } + + private void EmitWarnings(List warnings) + { + foreach (var warning in warnings) + { + if (OnValidationWarning is not null) + { + OnValidationWarning(warning); + } + else + { + System.Diagnostics.Debug.WriteLine($"[Resilion] Ordering warning: {warning}"); + } + } + } +} + +/// +/// Internal strategy wrapper for delegate-based strategies on typed pipelines. +/// +internal sealed class DelegateStrategy : Strategy +{ + private readonly string _name; + private readonly Func>>, ValueTask>> _handler; + + internal DelegateStrategy( + string name, + Func>>, ValueTask>> handler) + { + _name = name; + _handler = handler; + } + + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + => _handler(context, callback); + + public override string ToString() => $"DelegateStrategy<{typeof(TResult).Name}>({_name})"; +} + +/// +/// Internal component that delegates to an existing pipeline's component chain, +/// then continues to the next component in the current chain. +/// +internal sealed class DelegatingComponent : PipelineComponent +{ + private readonly PipelineComponent _inner; + private readonly PipelineComponent _next; + + internal DelegatingComponent(PipelineComponent inner, PipelineComponent next) + { + _inner = inner; + _next = next; + } + + internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + return _inner.ExecuteAsync( + ctx => _next.ExecuteAsync(callback, ctx), + context); + } + + internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + return _inner.Execute( + ctx => _next.Execute(callback, ctx), + context); + } + + public override void Dispose() + { + // Don't dispose inner — it may be shared via AddPipeline. + // Don't dispose next — the parent chain owns it. + } +} diff --git a/src/Resilion/PipelineOfT.cs b/src/Resilion/PipelineOfT.cs new file mode 100644 index 0000000..9372277 --- /dev/null +++ b/src/Resilion/PipelineOfT.cs @@ -0,0 +1,175 @@ +using Resilion.Internal; + +namespace Resilion; + +/// +/// Executes operations returning with resilience. Strategies can react +/// to both exceptions AND specific result values (e.g., retry on HTTP 500, fallback to a default). +/// Required for Fallback, Hedging, and result-based predicates. +/// +/// The result type of operations executed through this pipeline. +/// +/// Pipelines are thread-safe and immutable after construction. Build once, cache, and reuse. +/// +public sealed class Pipeline : IDisposable +{ + private readonly PipelineComponent _component; + private readonly ResilienceContextPool _pool; + + internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = null) + { + _component = component; + _pool = pool ?? ResilienceContextPool.Shared; + } + + /// + /// Gets a no-op pipeline that passes through to the user delegate without applying any strategies. + /// + public static Pipeline Empty { get; } = new(PipelineComponent.Empty); + + /// + /// Gets the internal component for pipeline composition. + /// + internal PipelineComponent Component => _component; + + // ────────────────────────────────────────────────────────────────── + // Async execution + // ────────────────────────────────────────────────────────────────── + + /// + /// Executes an async operation through the pipeline with a state parameter to avoid closure allocations. + /// + /// The type of the state passed to the callback. + /// The operation to execute. Use a static lambda with to avoid closure allocations. + /// State passed to the callback. + /// Cancellation token for the operation. + /// The result of the operation. + public async ValueTask ExecuteAsync( + Func> action, + TState state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + var context = _pool.Rent(cancellationToken); + try + { + var outcome = await _component.ExecuteAsync( + async ctx => + { + try + { + var result = await action(state, ctx.CancellationToken).ConfigureAwait(ctx.ContinueOnCapturedContext); + return Outcome.FromResult(result); + } + catch (Exception ex) + { + return Outcome.FromException(ex); + } + }, + context).ConfigureAwait(context.ContinueOnCapturedContext); + + return outcome.ThrowIfFailed(); + } + finally + { + _pool.Return(context); + } + } + + /// + /// Executes an async operation through the pipeline. + /// + /// The operation to execute. + /// Cancellation token for the operation. + /// The result of the operation. + public ValueTask ExecuteAsync( + Func> action, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + return ExecuteAsync(static (act, ct) => act(ct), action, cancellationToken); + } + + /// + /// Executes an async operation and returns the without rethrowing. + /// + /// The type of the state passed to the callback. + /// The operation to execute. + /// State passed to the callback. + /// The execution context (not pooled — caller manages lifetime). + /// The outcome of the operation. + public ValueTask> ExecuteOutcomeAsync( + Func>> action, + TState state, + ResilienceContext context) + { + ArgumentNullException.ThrowIfNull(action); + ArgumentNullException.ThrowIfNull(context); + + return _component.ExecuteAsync( + ctx => action(state, ctx), + context); + } + + // ────────────────────────────────────────────────────────────────── + // Sync execution + // ────────────────────────────────────────────────────────────────── + + /// + /// Executes a synchronous operation through the pipeline with a state parameter to avoid closure allocations. + /// + /// The type of the state passed to the callback. + /// The operation to execute. + /// State passed to the callback. + /// Cancellation token for the operation. + /// The result of the operation. + public TResult Execute( + Func action, + TState state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + var context = _pool.Rent(cancellationToken); + context.IsSynchronous = true; + try + { + var outcome = _component.Execute( + ctx => + { + try + { + var result = action(state, ctx.CancellationToken); + return Outcome.FromResult(result); + } + catch (Exception ex) + { + return Outcome.FromException(ex); + } + }, + context); + + return outcome.ThrowIfFailed(); + } + finally + { + _pool.Return(context); + } + } + + /// + /// Executes a synchronous operation through the pipeline. + /// + /// The operation to execute. + /// Cancellation token for the operation. + /// The result of the operation. + public TResult Execute( + Func action, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(action); + return Execute(static (act, ct) => act(ct), action, cancellationToken); + } + + /// + public void Dispose() => _component.Dispose(); +} diff --git a/src/Resilion/ResilienceContext.cs b/src/Resilion/ResilienceContext.cs new file mode 100644 index 0000000..ebb7384 --- /dev/null +++ b/src/Resilion/ResilienceContext.cs @@ -0,0 +1,74 @@ +namespace Resilion; + +/// +/// Carries execution state through the resilience pipeline. Each pipeline execution +/// gets its own context, which provides the , operation metadata, +/// and a type-safe bag for passing data between strategies. +/// +/// +/// +/// Contexts are pooled to avoid per-call allocations. Never construct a +/// directly — use to rent and return instances. +/// +/// +/// The pipeline's ExecuteAsync methods handle context pooling automatically. +/// Only use the pool directly when calling ExecuteOutcomeAsync with a custom context. +/// +/// +public sealed class ResilienceContext +{ + // Internal constructor — only the pool creates instances. + internal ResilienceContext() + { + } + + /// + /// Gets the cancellation token for this execution. + /// Strategies may replace this with a linked token (e.g., Timeout strategy). + /// + public CancellationToken CancellationToken { get; internal set; } + + /// + /// Gets or sets an optional name identifying this operation, used for telemetry and diagnostics. + /// + /// + /// + /// var context = ResilienceContextPool.Shared.Rent(cancellationToken); + /// context.OperationKey = "GetUserProfile"; + /// + /// + public string? OperationKey { get; set; } + + /// + /// Gets or sets a value indicating whether async continuations should run on the captured + /// synchronization context. Defaults to false (library-friendly behavior). + /// + /// + /// When false, all awaits inside the pipeline use ConfigureAwait(false). + /// Set to true when executing from a UI thread or a context that requires + /// synchronization context flow. + /// + public bool ContinueOnCapturedContext { get; set; } + + /// + /// Gets the type-safe property bag for passing arbitrary data through the pipeline. + /// + public ResilienceProperties Properties { get; } = new(); + + /// + /// Gets a value indicating whether this execution is synchronous. + /// + internal bool IsSynchronous { get; set; } + + /// + /// Resets this context to its initial state so it can be returned to the pool. + /// + internal void Reset() + { + CancellationToken = default; + OperationKey = null; + ContinueOnCapturedContext = false; + IsSynchronous = false; + Properties.Clear(); + } +} diff --git a/src/Resilion/ResilienceContextPool.cs b/src/Resilion/ResilienceContextPool.cs new file mode 100644 index 0000000..fc29ba9 --- /dev/null +++ b/src/Resilion/ResilienceContextPool.cs @@ -0,0 +1,72 @@ +using System.Collections.Concurrent; + +namespace Resilion; + +/// +/// Provides pooled instances to avoid per-call allocations. +/// +/// +/// The pipeline's ExecuteAsync methods use this pool automatically. Only use it directly +/// when you need a custom context for ExecuteOutcomeAsync. +/// +public sealed class ResilienceContextPool +{ + // Thread-safe bag for pooled contexts. ConcurrentBag is well-suited here because + // contexts are rented and returned from the same thread most of the time (LIFO affinity). + private readonly ConcurrentBag _pool = new(); + + // Cap the pool size to avoid unbounded memory growth under burst traffic. + // 256 is generous — most applications use far fewer concurrent pipeline executions. + private const int MaxPoolSize = 256; + + // Tracks pool count with Interlocked to avoid O(n) ConcurrentBag.Count enumeration. + private int _count; + + /// + /// Gets the shared default pool instance. Use this unless you have a specific reason + /// to create a separate pool. + /// + public static ResilienceContextPool Shared { get; } = new(); + + /// + /// Rents a from the pool with the specified cancellation token. + /// + /// The cancellation token for this execution. + /// A pooled or newly created . + public ResilienceContext Rent(CancellationToken cancellationToken = default) + { + if (!_pool.TryTake(out var context)) + { + context = new ResilienceContext(); + } + else + { + Interlocked.Decrement(ref _count); + } + + context.CancellationToken = cancellationToken; + return context; + } + + /// + /// Returns a to the pool. The context is reset to its + /// initial state before being pooled. + /// + /// The context to return. + public void Return(ResilienceContext context) + { + ArgumentNullException.ThrowIfNull(context); + context.Reset(); + + // Don't let the pool grow without bound. Use Interlocked counter to avoid O(n) ConcurrentBag.Count. + if (Interlocked.Increment(ref _count) <= MaxPoolSize) + { + _pool.Add(context); + } + else + { + // Pool is full; discard this context instead of adding it. + Interlocked.Decrement(ref _count); + } + } +} diff --git a/src/Resilion/ResilienceEventHandler.cs b/src/Resilion/ResilienceEventHandler.cs new file mode 100644 index 0000000..f061441 --- /dev/null +++ b/src/Resilion/ResilienceEventHandler.cs @@ -0,0 +1,95 @@ +namespace Resilion; + +/// +/// A union type that holds either a synchronous or an asynchronous +/// returning . Implicit conversions +/// let the compiler pick the right path automatically. +/// +/// The event arguments type (typically a readonly record struct). +/// +/// +/// This solves Polly v8's callback verbosity problem. Polly forces all callbacks to return +/// ValueTask even when they're synchronous (the 90% case — logging, incrementing counters). +/// +/// +/// +/// // Sync — zero overhead, no ValueTask wrapping: +/// OnRetry = (RetryAttemptEvent e) => logger.Log(e.AttemptNumber) +/// +/// // Async — when genuinely needed: +/// OnRetry = async (RetryAttemptEvent e) => await telemetry.TrackAsync(e) +/// +/// +/// +public readonly struct ResilienceEventHandler +{ + private readonly Action? _syncHandler; + private readonly Func? _asyncHandler; + + private ResilienceEventHandler(Action? syncHandler, Func? asyncHandler) + { + _syncHandler = syncHandler; + _asyncHandler = asyncHandler; + } + + /// + /// Gets a value indicating whether this handler has been assigned. + /// + public bool HasHandler => _syncHandler is not null || _asyncHandler is not null; + + /// + /// Invokes the handler. If the handler is synchronous, returns a completed . + /// + /// The event arguments. + /// A representing the completion of the handler. + internal ValueTask InvokeAsync(TArgs args) + { + if (_syncHandler is not null) + { + _syncHandler(args); + return default; + } + + if (_asyncHandler is not null) + { + return _asyncHandler(args); + } + + return default; + } + + /// + /// Invokes the handler synchronously. If the handler is async, blocks until completion. + /// + /// The event arguments. + internal void Invoke(TArgs args) + { + if (_syncHandler is not null) + { + _syncHandler(args); + return; + } + + if (_asyncHandler is not null) + { + // Copy to local to avoid struct 'this' capture in lambda. + // Run on the thread pool to avoid deadlocking when a SynchronizationContext + // is present (WPF, WinForms). + var handler = _asyncHandler; + Task.Run(() => handler(args).AsTask()).GetAwaiter().GetResult(); + } + } + + /// + /// Implicitly converts a synchronous to a . + /// + public static implicit operator ResilienceEventHandler(Action handler) + => new(handler, null); + + /// + /// Implicitly converts an asynchronous returning + /// to a . + /// + public static implicit operator ResilienceEventHandler(Func handler) + => new(null, handler); +} diff --git a/src/Resilion/ResilienceProperties.cs b/src/Resilion/ResilienceProperties.cs new file mode 100644 index 0000000..04f0b3b --- /dev/null +++ b/src/Resilion/ResilienceProperties.cs @@ -0,0 +1,97 @@ +namespace Resilion; + +/// +/// A type-safe property bag for passing arbitrary data through the resilience pipeline. +/// Strategies and user callbacks can read and write values using . +/// +/// +/// This is not thread-safe. It is expected to be used within a single pipeline execution +/// (one per execution), which is inherently single-threaded +/// through the strategy chain. +/// +public sealed class ResilienceProperties +{ + private Dictionary? _properties; + + /// + /// Sets a property value for the specified key. + /// + /// The type of the value. + /// The property key. + /// The value to set. + public void Set(ResiliencePropertyKey key, TValue value) + { + _properties ??= new Dictionary(StringComparer.Ordinal); + _properties[key.Key] = value; + } + + /// + /// Gets the property value for the specified key. + /// + /// The type of the value. + /// The property key. + /// When this method returns true, contains the value. + /// true if the property was found and is of the correct type; false otherwise. + public bool TryGetValue(ResiliencePropertyKey key, out TValue? value) + { + if (_properties is not null && _properties.TryGetValue(key.Key, out var raw)) + { + // Type-safe cast: return false if the stored value is not of the expected type + if (raw is TValue typed) + { + value = typed; + return true; + } + } + + value = default; + return false; + } + + /// + /// Gets the property value for the specified key, or the default value if not found. + /// + /// The type of the value. + /// The property key. + /// The value to return if the key is not found. + /// The property value, or if not found. + public TValue GetValue(ResiliencePropertyKey key, TValue defaultValue = default!) + => TryGetValue(key, out var value) ? value! : defaultValue; + + /// + /// Removes the property with the specified key. + /// + /// The type of the value. + /// The property key. + /// true if the property was found and removed; false otherwise. + public bool Remove(ResiliencePropertyKey key) + => _properties?.Remove(key.Key) ?? false; + + /// + /// Gets the number of properties stored. + /// + public int Count => _properties?.Count ?? 0; + + /// + /// Removes all properties. + /// + internal void Clear() => _properties?.Clear(); + + /// + /// Copies all properties from another instance. + /// Existing properties with the same key are overwritten. + /// + internal void CopyFrom(ResilienceProperties source) + { + if (source._properties is null || source._properties.Count == 0) + { + return; + } + + _properties ??= new Dictionary(StringComparer.Ordinal); + foreach (var kvp in source._properties) + { + _properties[kvp.Key] = kvp.Value; + } + } +} diff --git a/src/Resilion/ResiliencePropertyKey.cs b/src/Resilion/ResiliencePropertyKey.cs new file mode 100644 index 0000000..0ba37c3 --- /dev/null +++ b/src/Resilion/ResiliencePropertyKey.cs @@ -0,0 +1,48 @@ +namespace Resilion; + +/// +/// A strongly-typed key for storing and retrieving values from . +/// Using typed keys prevents accidental type mismatches when passing data through the pipeline. +/// +/// The type of the value associated with this key. +public readonly struct ResiliencePropertyKey : IEquatable> +{ + /// + /// Initializes a new instance of the struct. + /// + /// The unique string identifier for this property key. + /// Thrown when is null or empty. + public ResiliencePropertyKey(string key) + { + ArgumentException.ThrowIfNullOrEmpty(key); + Key = key; + } + + /// + /// Gets the unique string identifier for this property key. + /// + public string Key { get; } + + /// + public bool Equals(ResiliencePropertyKey other) + => string.Equals(Key, other.Key, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) + => obj is ResiliencePropertyKey other && Equals(other); + + /// + public override int GetHashCode() + => StringComparer.Ordinal.GetHashCode(Key); + + /// Determines whether two property keys are equal. + public static bool operator ==(ResiliencePropertyKey left, ResiliencePropertyKey right) + => left.Equals(right); + + /// Determines whether two property keys are not equal. + public static bool operator !=(ResiliencePropertyKey left, ResiliencePropertyKey right) + => !left.Equals(right); + + /// + public override string ToString() => $"ResiliencePropertyKey<{typeof(TValue).Name}>({Key})"; +} diff --git a/src/Resilion/Resilion.csproj b/src/Resilion/Resilion.csproj new file mode 100644 index 0000000..f6f4a72 --- /dev/null +++ b/src/Resilion/Resilion.csproj @@ -0,0 +1,17 @@ + + + + Resilion + Resilion + Resilion + Runtime resilience library for .NET. Retry, circuit breaker, timeout, fallback, hedging, pipeline composition. Zero external dependencies. + + + + + + + + + + diff --git a/src/Resilion/ResilionException.cs b/src/Resilion/ResilionException.cs new file mode 100644 index 0000000..6a070cd --- /dev/null +++ b/src/Resilion/ResilionException.cs @@ -0,0 +1,28 @@ +namespace Resilion; + +/// +/// Base class for all exceptions originated by Resilion strategies. +/// Catching this type catches any Resilion-specific failure (timeout, circuit broken, rate limited, etc.) +/// while letting and user exceptions pass through. +/// +public abstract class ResilionException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + protected ResilionException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + protected ResilionException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Resilion/Retry/RetryDelay.cs b/src/Resilion/Retry/RetryDelay.cs new file mode 100644 index 0000000..7b1b60a --- /dev/null +++ b/src/Resilion/Retry/RetryDelay.cs @@ -0,0 +1,153 @@ +namespace Resilion; + +/// +/// Defines how retry delays are computed. Each variant is mutually exclusive by construction, +/// eliminating the confusion of Polly's separate Delay/BackoffType/UseJitter/DelayGenerator properties. +/// +/// +/// +/// Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)) // exponential backoff +/// Delay = RetryDelay.Constant(TimeSpan.FromMilliseconds(200)) // fixed delay +/// Delay = RetryDelay.Linear(TimeSpan.FromSeconds(1), maxDelay: TimeSpan.FromSeconds(10)) +/// Delay = RetryDelay.Custom(ctx => ComputeMyDelay(ctx)) // fully custom +/// +/// +public abstract record RetryDelay +{ + // Sealed hierarchy — users extend via Custom, not by inheriting from RetryDelay. + private RetryDelay() { } + + /// + /// Creates a constant delay (same duration for every retry). + /// + /// The fixed delay between retries. + public static RetryDelay Constant(TimeSpan delay) => new ConstantDelay(delay); + + /// + /// Creates a linearly increasing delay (baseDelay × attemptNumber). + /// + /// The base delay that scales linearly. + /// Optional cap on the delay. Defaults to no cap. + public static RetryDelay Linear(TimeSpan baseDelay, TimeSpan? maxDelay = null) + => new LinearDelay(baseDelay, maxDelay); + + /// + /// Creates an exponentially increasing delay (baseDelay × 2^attemptNumber). + /// + /// The base delay for the first retry. + /// Optional cap on the delay. Defaults to 30 seconds. + public static RetryDelay Exponential(TimeSpan baseDelay, TimeSpan? maxDelay = null) + => new ExponentialDelay(baseDelay, maxDelay ?? TimeSpan.FromSeconds(30)); + + /// + /// Creates a fully custom delay computation. + /// + /// Function that receives the retry context and returns a delay. + public static RetryDelay Custom(Func compute) + => new CustomDelay(compute); + + /// + /// Zero delay — retries execute immediately with no wait. + /// + public static RetryDelay None { get; } = Constant(TimeSpan.Zero); + + /// + /// Computes the delay for the given attempt number. + /// + /// The 1-based retry attempt number (1 = first retry, not the original call). + /// Whether to apply jitter to the computed delay. + /// The delay to wait before the next retry. + internal abstract TimeSpan ComputeDelay(int attemptNumber, bool useJitter); + + /// + /// Applies decorrelated jitter to a delay value. + /// Uses the AWS recommended algorithm: sleep = min(cap, random_between(base, prev * 3)). + /// + private static TimeSpan ApplyJitter(TimeSpan delay, TimeSpan? maxDelay) + { + if (delay <= TimeSpan.Zero) + { + return TimeSpan.Zero; + } + + var ms = delay.TotalMilliseconds; + // ±25% jitter for simplicity and effectiveness + var jitterMs = ms * (0.75 + (Random.Shared.NextDouble() * 0.5)); + var result = TimeSpan.FromMilliseconds(jitterMs); + + if (maxDelay.HasValue && result > maxDelay.Value) + { + result = maxDelay.Value; + } + + return result; + } + + private static TimeSpan Clamp(TimeSpan delay, TimeSpan? maxDelay) + => maxDelay.HasValue && delay > maxDelay.Value ? maxDelay.Value : delay; + + // ── Variants ───────────────────────────────────────────────── + + private sealed record ConstantDelay(TimeSpan Delay) : RetryDelay + { + internal override TimeSpan ComputeDelay(int attemptNumber, bool useJitter) + => useJitter ? ApplyJitter(Delay, null) : Delay; + } + + private sealed record LinearDelay(TimeSpan BaseDelay, TimeSpan? MaxDelay) : RetryDelay + { + internal override TimeSpan ComputeDelay(int attemptNumber, bool useJitter) + { + // Prevent overflow: clamp total milliseconds before constructing TimeSpan + var baseMs = BaseDelay.TotalMilliseconds; + var maxMs = MaxDelay?.TotalMilliseconds ?? double.MaxValue; + var delayMs = Math.Min(baseMs * attemptNumber, maxMs); + + // Guard against overflow when jitter multiplier (up to 1.25x) is applied. + // Match the pattern in ExponentialDelay to handle edge cases consistently. + if (double.IsInfinity(delayMs) || double.IsNaN(delayMs) || delayMs > TimeSpan.MaxValue.TotalMilliseconds) + { + delayMs = TimeSpan.MaxValue.TotalMilliseconds; + } + + var delay = TimeSpan.FromMilliseconds(delayMs); + return useJitter ? ApplyJitter(delay, MaxDelay) : delay; + } + } + + private sealed record ExponentialDelay(TimeSpan BaseDelay, TimeSpan? MaxDelay) : RetryDelay + { + internal override TimeSpan ComputeDelay(int attemptNumber, bool useJitter) + { + // delay = baseDelay * 2^(attemptNumber-1) + var multiplier = Math.Pow(2, attemptNumber - 1); + var delayMs = BaseDelay.TotalMilliseconds * multiplier; + + // Clamp before TimeSpan.FromMilliseconds to avoid OverflowException + // when attemptNumber is large (e.g., int.MaxValue retries). + if (MaxDelay.HasValue) + { + delayMs = Math.Min(delayMs, MaxDelay.Value.TotalMilliseconds); + } + else if (double.IsInfinity(delayMs) || double.IsNaN(delayMs) || delayMs > TimeSpan.MaxValue.TotalMilliseconds) + { + delayMs = TimeSpan.MaxValue.TotalMilliseconds; + } + + var delay = TimeSpan.FromMilliseconds(delayMs); + return useJitter ? ApplyJitter(delay, MaxDelay) : delay; + } + } + + private sealed record CustomDelay(Func Compute) : RetryDelay + { + internal override TimeSpan ComputeDelay(int attemptNumber, bool useJitter) + => Compute(new RetryDelayContext(attemptNumber)); + } +} + +/// +/// Context passed to delegates. +/// +/// The 1-based retry attempt number. +public readonly record struct RetryDelayContext(int AttemptNumber); diff --git a/src/Resilion/Retry/RetryExtensions.cs b/src/Resilion/Retry/RetryExtensions.cs new file mode 100644 index 0000000..22cfce4 --- /dev/null +++ b/src/Resilion/Retry/RetryExtensions.cs @@ -0,0 +1,52 @@ +namespace Resilion; + +/// +/// Extension methods for adding the Retry strategy to a pipeline builder. +/// +public static class RetryExtensions +{ + /// + /// Adds an exception-only retry strategy with the specified options. + /// + /// The pipeline builder. + /// The retry options. When null, uses default options (3 retries, exponential backoff with jitter). + /// The builder for chaining. + public static PipelineBuilder AddRetry(this PipelineBuilder builder, RetryStrategyOptions? options = null) + { + options ??= new RetryStrategyOptions(); + options.Validate(); + return builder.AddStrategy(new RetryStrategy(options, builder.TimeProvider), Internal.StrategyType.Retry); + } + + /// + /// Adds a result-aware retry strategy to a typed pipeline. + /// + /// The result type. + /// The typed pipeline builder. + /// The typed retry options. + /// The builder for chaining. + public static PipelineBuilder AddRetry( + this PipelineBuilder builder, + RetryStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new RetryStrategy(options, builder.TimeProvider), Internal.StrategyType.Retry); + } + + /// + /// Adds an exception-only retry strategy to a typed pipeline. + /// + /// The result type. + /// The typed pipeline builder. + /// The retry options. When null, uses default options. + /// The builder for chaining. + public static PipelineBuilder AddRetry( + this PipelineBuilder builder, + RetryStrategyOptions? options = null) + { + options ??= new RetryStrategyOptions(); + options.Validate(); + return builder.AddStrategy(new RetryStrategy(options, builder.TimeProvider), Internal.StrategyType.Retry); + } +} diff --git a/src/Resilion/Retry/RetryStrategy.cs b/src/Resilion/Retry/RetryStrategy.cs new file mode 100644 index 0000000..70169c1 --- /dev/null +++ b/src/Resilion/Retry/RetryStrategy.cs @@ -0,0 +1,242 @@ +namespace Resilion; + +/// +/// Non-generic retry strategy — retries on exceptions only. +/// +internal sealed class RetryStrategy : Strategy +{ + private readonly RetryStrategyOptions _options; + private readonly TimeProvider _timeProvider; + + internal RetryStrategy(RetryStrategyOptions options, TimeProvider timeProvider) + { + _options = options; + _timeProvider = timeProvider; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (_options.MaxRetryAttempts == 0) + { + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + Outcome outcome = default; + + for (var attempt = 0; attempt <= _options.MaxRetryAttempts; attempt++) + { + // Check cancellation before each attempt. + context.CancellationToken.ThrowIfCancellationRequested(); + + outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + + // Success — return immediately. + if (outcome.IsSuccess) + { + return outcome; + } + + // Check if this exception should trigger a retry. + if (!_options.ShouldHandleException(outcome.Exception!)) + { + return outcome; + } + + // Last attempt — don't wait, just return the failure. + if (attempt == _options.MaxRetryAttempts) + { + break; + } + + // Compute delay. + var retryNumber = attempt + 1; + var delay = _options.Delay.ComputeDelay(retryNumber, _options.UseJitter); + + ResilionTelemetry.RetryAttempts.Add(1); + + // Fire OnRetry callback. + if (_options.OnRetry is { } handler && handler.HasHandler) + { + await handler.InvokeAsync( + new RetryAttemptEvent(retryNumber, delay, outcome.Exception!, context)) + .ConfigureAwait(false); + } + + // Wait for the delay (if any), respecting cancellation. + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, _timeProvider, context.CancellationToken) + .ConfigureAwait(context.ContinueOnCapturedContext); + } + } + + return outcome; + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + if (_options.MaxRetryAttempts == 0) + { + return callback(context); + } + + Outcome outcome = default; + + for (var attempt = 0; attempt <= _options.MaxRetryAttempts; attempt++) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + outcome = callback(context); + + if (outcome.IsSuccess) + { + return outcome; + } + + if (!_options.ShouldHandleException(outcome.Exception!)) + { + return outcome; + } + + if (attempt == _options.MaxRetryAttempts) + { + break; + } + + var retryNumber = attempt + 1; + var delay = _options.Delay.ComputeDelay(retryNumber, _options.UseJitter); + + ResilionTelemetry.RetryAttempts.Add(1); + + if (_options.OnRetry is { } handler && handler.HasHandler) + { + handler.Invoke(new RetryAttemptEvent(retryNumber, delay, outcome.Exception!, context)); + } + + if (delay > TimeSpan.Zero) + { + // Sync path: use WaitHandle for cancellation-aware sleep. + context.CancellationToken.WaitHandle.WaitOne(delay); + context.CancellationToken.ThrowIfCancellationRequested(); + } + } + + return outcome; + } +} + +/// +/// Generic retry strategy — retries on exceptions and/or result values. +/// +internal sealed class RetryStrategy : Strategy +{ + private readonly RetryStrategyOptions _options; + private readonly TimeProvider _timeProvider; + + internal RetryStrategy(RetryStrategyOptions options, TimeProvider timeProvider) + { + _options = options; + _timeProvider = timeProvider; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (_options.MaxRetryAttempts == 0) + { + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + Outcome outcome = default; + + for (var attempt = 0; attempt <= _options.MaxRetryAttempts; attempt++) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + + // Check predicate against the full outcome (exception OR result). + if (!_options.ShouldHandleOutcome(outcome)) + { + return outcome; + } + + if (attempt == _options.MaxRetryAttempts) + { + break; + } + + var retryNumber = attempt + 1; + var delay = _options.Delay.ComputeDelay(retryNumber, _options.UseJitter); + + ResilionTelemetry.RetryAttempts.Add(1); + + if (_options.OnRetry is { } handler && handler.HasHandler) + { + await handler.InvokeAsync( + new RetryAttemptEvent(retryNumber, delay, outcome, context)) + .ConfigureAwait(false); + } + + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, _timeProvider, context.CancellationToken) + .ConfigureAwait(context.ContinueOnCapturedContext); + } + } + + return outcome; + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + if (_options.MaxRetryAttempts == 0) + { + return callback(context); + } + + Outcome outcome = default; + + for (var attempt = 0; attempt <= _options.MaxRetryAttempts; attempt++) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + outcome = callback(context); + + if (!_options.ShouldHandleOutcome(outcome)) + { + return outcome; + } + + if (attempt == _options.MaxRetryAttempts) + { + break; + } + + var retryNumber = attempt + 1; + var delay = _options.Delay.ComputeDelay(retryNumber, _options.UseJitter); + + ResilionTelemetry.RetryAttempts.Add(1); + + if (_options.OnRetry is { } handler && handler.HasHandler) + { + handler.Invoke(new RetryAttemptEvent(retryNumber, delay, outcome, context)); + } + + if (delay > TimeSpan.Zero) + { + context.CancellationToken.WaitHandle.WaitOne(delay); + context.CancellationToken.ThrowIfCancellationRequested(); + } + } + + return outcome; + } +} diff --git a/src/Resilion/Retry/RetryStrategyOptions.cs b/src/Resilion/Retry/RetryStrategyOptions.cs new file mode 100644 index 0000000..6b68692 --- /dev/null +++ b/src/Resilion/Retry/RetryStrategyOptions.cs @@ -0,0 +1,137 @@ +namespace Resilion; + +/// +/// Options for the Retry resilience strategy (exception-only predicates). +/// +public sealed record RetryStrategyOptions +{ + /// + /// Gets the maximum number of retry attempts (not counting the initial execution). + /// Defaults to 3. Set to 0 for no retries. + /// + public int MaxRetryAttempts { get; init; } = 3; + + /// + /// Gets the delay strategy between retries. Defaults to exponential backoff starting at 1 second + /// with a 30-second cap. + /// + public RetryDelay Delay { get; init; } = RetryDelay.Exponential(TimeSpan.FromSeconds(1)); + + /// + /// Gets a value indicating whether jitter should be applied to retry delays. + /// Defaults to true (decorrelated jitter). + /// + public bool UseJitter { get; init; } = true; + + /// + /// Gets the predicate that determines which exceptions trigger a retry. + /// Defaults to all exceptions except . + /// + public Func? ShouldHandle { get; init; } + + /// + /// Gets an optional event handler fired before each retry wait. + /// + public ResilienceEventHandler? OnRetry { get; init; } + + internal void Validate() + { + if (MaxRetryAttempts < 0) + { + throw new ArgumentOutOfRangeException(nameof(MaxRetryAttempts), MaxRetryAttempts, + "MaxRetryAttempts must be >= 0."); + } + } + + internal bool ShouldHandleException(Exception exception) + { + // OperationCanceledException is never retried by default. + if (exception is OperationCanceledException) + { + return ShouldHandle?.Invoke(exception) ?? false; + } + + return ShouldHandle?.Invoke(exception) ?? true; + } +} + +/// +/// Options for the Retry resilience strategy with result-based predicates. +/// +/// The result type to inspect. +public sealed record RetryStrategyOptions +{ + /// + /// Gets the maximum number of retry attempts. Defaults to 3. + /// + public int MaxRetryAttempts { get; init; } = 3; + + /// + /// Gets the delay strategy between retries. + /// + public RetryDelay Delay { get; init; } = RetryDelay.Exponential(TimeSpan.FromSeconds(1)); + + /// + /// Gets a value indicating whether jitter should be applied. + /// + public bool UseJitter { get; init; } = true; + + /// + /// Gets the predicate that determines which outcomes (exceptions or results) trigger a retry. + /// Receives the to enable both exception and result-based decisions. + /// + public Func, bool>? ShouldHandle { get; init; } + + /// + /// Gets an optional event handler fired before each retry wait. + /// + public ResilienceEventHandler>? OnRetry { get; init; } + + internal void Validate() + { + if (MaxRetryAttempts < 0) + { + throw new ArgumentOutOfRangeException(nameof(MaxRetryAttempts), MaxRetryAttempts, + "MaxRetryAttempts must be >= 0."); + } + } + + internal bool ShouldHandleOutcome(Outcome outcome) + { + // If we have a custom predicate, use it. + if (ShouldHandle is not null) + { + return ShouldHandle(outcome); + } + + // Default: handle any exception except OperationCanceledException. + return outcome.Exception is not null and not OperationCanceledException; + } +} + +/// +/// Event arguments for the callback (exception-only). +/// +/// The 1-based retry attempt number. +/// The delay that will be waited before the next attempt. +/// The exception that triggered this retry. +/// The execution context. +public readonly record struct RetryAttemptEvent( + int AttemptNumber, + TimeSpan RetryDelay, + Exception Exception, + ResilienceContext Context); + +/// +/// Event arguments for the callback (outcome-based). +/// +/// The result type. +/// The 1-based retry attempt number. +/// The delay that will be waited before the next attempt. +/// The outcome that triggered this retry. +/// The execution context. +public readonly record struct RetryAttemptEvent( + int AttemptNumber, + TimeSpan RetryDelay, + Outcome Outcome, + ResilienceContext Context); diff --git a/src/Resilion/Strategy.cs b/src/Resilion/Strategy.cs new file mode 100644 index 0000000..399446a --- /dev/null +++ b/src/Resilion/Strategy.cs @@ -0,0 +1,102 @@ +namespace Resilion; + +/// +/// Base class for proactive resilience strategies that work with any result type. +/// Timeout, RateLimiter, and exception-only Retry/CircuitBreaker inherit from this. +/// +/// +/// +/// The key distinction: is non-generic at the class level +/// but generic at the method level. A single Timeout instance can protect calls +/// returning string, int, HttpResponseMessage, or any other type. +/// +/// +/// For strategies that need to inspect or substitute result values (Fallback, Hedging, +/// result-based Retry), use instead. +/// +/// +public abstract class Strategy : IDisposable +{ + /// + /// Executes the resilience logic around the given callback. + /// + /// The result type of the operation. + /// The next strategy or user delegate to invoke. + /// The execution context carrying cancellation and properties. + /// The outcome of the execution. + protected internal abstract ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context); + + /// + /// Executes the resilience logic synchronously around the given callback. + /// + /// The result type of the operation. + /// The next strategy or user delegate to invoke. + /// The execution context carrying cancellation and properties. + /// The outcome of the execution. + protected internal virtual Outcome Execute( + Func> callback, + ResilienceContext context) + { + // Default: delegate to async path synchronously. + // Strategies that support true sync execution should override this method. + return ExecuteAsync( + ctx => new ValueTask>(callback(ctx)), + context).GetAwaiter().GetResult(); + } + + /// + public virtual void Dispose() + { + } +} + +/// +/// Base class for reactive resilience strategies bound to a specific result type . +/// Fallback, Hedging, and result-based Retry/CircuitBreaker inherit from this. +/// +/// The result type this strategy operates on. +/// +/// +/// The key distinction: fixes the result type at the class level. +/// A Strategy<HttpResponseMessage> can inspect status codes and substitute fallback responses. +/// +/// +/// For strategies that don't need to inspect results (Timeout, RateLimiter), use instead. +/// +/// +public abstract class Strategy : IDisposable +{ + /// + /// Executes the resilience logic around the given callback. + /// + /// The next strategy or user delegate to invoke. + /// The execution context carrying cancellation and properties. + /// The outcome of the execution. + protected internal abstract ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context); + + /// + /// Executes the resilience logic synchronously around the given callback. + /// + /// The next strategy or user delegate to invoke. + /// The execution context carrying cancellation and properties. + /// The outcome of the execution. + protected internal virtual Outcome Execute( + Func> callback, + ResilienceContext context) + { + // Default: delegate to async path synchronously. + // Strategies that support true sync execution should override this method. + return ExecuteAsync( + ctx => new ValueTask>(callback(ctx)), + context).GetAwaiter().GetResult(); + } + + /// + public virtual void Dispose() + { + } +} diff --git a/src/Resilion/Telemetry/ResilionTelemetry.cs b/src/Resilion/Telemetry/ResilionTelemetry.cs new file mode 100644 index 0000000..bc26025 --- /dev/null +++ b/src/Resilion/Telemetry/ResilionTelemetry.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Resilion; + +/// +/// Provides built-in telemetry instruments for Resilion strategies. +/// Zero overhead when no listener is attached — instruments only allocate when subscribed. +/// +/// +/// Subscribe with .AddMeter("Resilion") for metrics and .AddSource("Resilion") for traces. +/// +public static class ResilionTelemetry +{ + /// Meter name for all Resilion metrics. + public const string MeterName = "Resilion"; + + /// ActivitySource name for all Resilion traces. + public const string ActivitySourceName = "Resilion"; + + internal static readonly Meter Meter = new(MeterName); + internal static readonly ActivitySource ActivitySource = new(ActivitySourceName); + + internal static readonly Counter StrategyExecutions = Meter.CreateCounter( + "resilion.strategy.executions", + description: "Total strategy executions."); + + internal static readonly Histogram StrategyDuration = Meter.CreateHistogram( + "resilion.strategy.duration", + unit: "s", + description: "Strategy execution duration in seconds."); + + internal static readonly Counter RetryAttempts = Meter.CreateCounter( + "resilion.retry.attempts", + description: "Total retry attempts."); + + internal static readonly Counter CircuitBreakerStateChanges = Meter.CreateCounter( + "resilion.circuit_breaker.state_changes", + description: "Circuit breaker state transitions."); + + internal static readonly Counter TimeoutExpirations = Meter.CreateCounter( + "resilion.timeout.expirations", + description: "Timeout expirations."); + + internal static readonly Counter FallbackActivations = Meter.CreateCounter( + "resilion.fallback.activations", + description: "Fallback activations."); + + internal static readonly Counter HedgingAttempts = Meter.CreateCounter( + "resilion.hedging.attempts", + description: "Hedging attempts launched."); + + internal static readonly Counter RateLimiterRejections = Meter.CreateCounter( + "resilion.rate_limiter.rejections", + description: "Rate limiter rejections."); +} diff --git a/src/Resilion/Timeout/TimeoutExtensions.cs b/src/Resilion/Timeout/TimeoutExtensions.cs new file mode 100644 index 0000000..4640780 --- /dev/null +++ b/src/Resilion/Timeout/TimeoutExtensions.cs @@ -0,0 +1,53 @@ +namespace Resilion; + +/// +/// Extension methods for adding the Timeout strategy to a pipeline builder. +/// +public static class TimeoutExtensions +{ + /// + /// Adds a timeout strategy with the specified duration. + /// + /// The pipeline builder. + /// The timeout duration. + /// The builder for chaining. + public static PipelineBuilder AddTimeout(this PipelineBuilder builder, TimeSpan timeout) + => builder.AddTimeout(new TimeoutStrategyOptions { Timeout = timeout }); + + /// + /// Adds a timeout strategy with the specified options. + /// + /// The pipeline builder. + /// The timeout options. + /// The builder for chaining. + public static PipelineBuilder AddTimeout(this PipelineBuilder builder, TimeoutStrategyOptions? options = null) + { + options ??= new TimeoutStrategyOptions(); + options.Validate(); + return builder.AddStrategy(new TimeoutStrategy(options, builder.TimeProvider), Internal.StrategyType.Timeout); + } + + /// + /// Adds a timeout strategy with the specified duration to a typed pipeline. + /// + /// The result type of the pipeline. + /// The typed pipeline builder. + /// The timeout duration. + /// The builder for chaining. + public static PipelineBuilder AddTimeout(this PipelineBuilder builder, TimeSpan timeout) + => builder.AddTimeout(new TimeoutStrategyOptions { Timeout = timeout }); + + /// + /// Adds a timeout strategy with the specified options to a typed pipeline. + /// + /// The result type of the pipeline. + /// The typed pipeline builder. + /// The timeout options. + /// The builder for chaining. + public static PipelineBuilder AddTimeout(this PipelineBuilder builder, TimeoutStrategyOptions? options = null) + { + options ??= new TimeoutStrategyOptions(); + options.Validate(); + return builder.AddStrategy(new TimeoutStrategy(options, builder.TimeProvider), Internal.StrategyType.Timeout); + } +} diff --git a/src/Resilion/Timeout/TimeoutRejectedException.cs b/src/Resilion/Timeout/TimeoutRejectedException.cs new file mode 100644 index 0000000..c38f963 --- /dev/null +++ b/src/Resilion/Timeout/TimeoutRejectedException.cs @@ -0,0 +1,46 @@ +namespace Resilion; + +/// +/// Thrown when an operation exceeds its configured timeout duration. +/// +/// +/// +/// This exception is thrown only when the timeout fires — not when the user's original +/// is canceled. If the user's token is canceled, the original +/// propagates unchanged. +/// +/// +/// The contains the +/// that was thrown by the timed-out operation. +/// +/// +public sealed class TimeoutRejectedException : ResilionException +{ + /// + /// Initializes a new instance of the class. + /// + /// The timeout duration that was configured. + /// The actual elapsed time before timeout was triggered. + /// The underlying cancellation exception. + internal TimeoutRejectedException( + TimeSpan configuredTimeout, + TimeSpan elapsedTime, + Exception innerException) + : base($"The operation did not complete within the configured timeout of {configuredTimeout.TotalSeconds:F1}s " + + $"(elapsed: {elapsedTime.TotalSeconds:F1}s).", + innerException) + { + ConfiguredTimeout = configuredTimeout; + ElapsedTime = elapsedTime; + } + + /// + /// Gets the timeout duration that was configured on the strategy. + /// + public TimeSpan ConfiguredTimeout { get; } + + /// + /// Gets the actual time that elapsed before the timeout was triggered. + /// + public TimeSpan ElapsedTime { get; } +} diff --git a/src/Resilion/Timeout/TimeoutStrategy.cs b/src/Resilion/Timeout/TimeoutStrategy.cs new file mode 100644 index 0000000..647925a --- /dev/null +++ b/src/Resilion/Timeout/TimeoutStrategy.cs @@ -0,0 +1,208 @@ +namespace Resilion; + +/// +/// Resilience strategy that enforces a time limit on operations using cooperative cancellation. +/// +internal sealed class TimeoutStrategy : Strategy +{ + private readonly TimeoutStrategyOptions _options; + private readonly TimeProvider _timeProvider; + + internal TimeoutStrategy(TimeoutStrategyOptions options, TimeProvider timeProvider) + { + _options = options; + _timeProvider = timeProvider; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + var timeout = ResolveTimeout(context); + + if (timeout == System.Threading.Timeout.InfiniteTimeSpan) + { + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + var previousToken = context.CancellationToken; + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(previousToken); + var startTimestamp = _timeProvider.GetTimestamp(); + ITimer? timer = null; + + try + { + timer = _timeProvider.CreateTimer( + static state => + { + try + { + ((CancellationTokenSource)state!).Cancel(); + } + catch + { + // Suppress exceptions from timer callback to prevent process termination. + // CancellationTokenSource.Cancel() can throw if user cancellation callbacks throw. + // We can't log here safely on a thread pool timer thread, but the timeout has + // already been triggered via cancellation request, so suppressing is acceptable. + } + }, + linkedCts, + timeout, + System.Threading.Timeout.InfiniteTimeSpan); + + context.CancellationToken = linkedCts.Token; + + try + { + var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + + if (outcome.Exception is OperationCanceledException oce + && WasCancelledByTimeout(linkedCts, previousToken)) + { + var elapsed = _timeProvider.GetElapsedTime(startTimestamp); + return await HandleTimeout(context, timeout, elapsed, oce).ConfigureAwait(false); + } + + return outcome; + } + catch (OperationCanceledException oce) when (WasCancelledByTimeout(linkedCts, previousToken)) + { + var elapsed = _timeProvider.GetElapsedTime(startTimestamp); + return await HandleTimeout(context, timeout, elapsed, oce).ConfigureAwait(false); + } + } + finally + { + context.CancellationToken = previousToken; + + if (timer is not null) + { + await timer.DisposeAsync().ConfigureAwait(false); + } + + linkedCts.Dispose(); + } + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + var timeout = ResolveTimeout(context); + + if (timeout == System.Threading.Timeout.InfiniteTimeSpan) + { + return callback(context); + } + + var previousToken = context.CancellationToken; + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(previousToken); + var startTimestamp = _timeProvider.GetTimestamp(); + ITimer? timer = null; + + try + { + timer = _timeProvider.CreateTimer( + static state => + { + try + { + ((CancellationTokenSource)state!).Cancel(); + } + catch + { + // Suppress exceptions from timer callback to prevent process termination. + // CancellationTokenSource.Cancel() can throw if user cancellation callbacks throw. + // We can't log here safely on a thread pool timer thread, but the timeout has + // already been triggered via cancellation request, so suppressing is acceptable. + } + }, + linkedCts, + timeout, + System.Threading.Timeout.InfiniteTimeSpan); + + context.CancellationToken = linkedCts.Token; + var outcome = callback(context); + + if (outcome.Exception is OperationCanceledException oce + && WasCancelledByTimeout(linkedCts, previousToken)) + { + var elapsed = _timeProvider.GetElapsedTime(startTimestamp); + HandleTimeoutSync(context, timeout, elapsed); + return Outcome.FromException( + new TimeoutRejectedException(timeout, elapsed, oce)); + } + + return outcome; + } + catch (OperationCanceledException oce) when (WasCancelledByTimeout(linkedCts, previousToken)) + { + var elapsed = _timeProvider.GetElapsedTime(startTimestamp); + HandleTimeoutSync(context, timeout, elapsed); + return Outcome.FromException( + new TimeoutRejectedException(timeout, elapsed, oce)); + } + finally + { + context.CancellationToken = previousToken; + timer?.Dispose(); + linkedCts.Dispose(); + } + } + + private TimeSpan ResolveTimeout(ResilienceContext context) + { + if (_options.TimeoutGenerator is not null) + { + return _options.TimeoutGenerator(new TimeoutGeneratorArgs(context)); + } + + return _options.Timeout; + } + + /// + /// Determines whether the cancellation was caused by our timeout rather than the user's token. + /// In rare cases where user cancellation races with timeout, this may misclassify. + /// + private static bool WasCancelledByTimeout( + CancellationTokenSource linkedCts, + CancellationToken userToken) + { + // Check both atomically as possible: if linked CTS fired AND user token didn't. + return linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested; + } + + /// + /// Handles timeout on the async path. Preserves the original OCE as inner exception. + /// + private async ValueTask> HandleTimeout( + ResilienceContext context, + TimeSpan timeout, + TimeSpan elapsed, + Exception originalException) + { + ResilionTelemetry.TimeoutExpirations.Add(1); + + if (_options.OnTimeout is { } handler && handler.HasHandler) + { + await handler.InvokeAsync(new OnTimeoutArgs(context, timeout, elapsed)).ConfigureAwait(false); + } + + return Outcome.FromException( + new TimeoutRejectedException(timeout, elapsed, originalException)); + } + + private void HandleTimeoutSync( + ResilienceContext context, + TimeSpan timeout, + TimeSpan elapsed) + { + ResilionTelemetry.TimeoutExpirations.Add(1); + + if (_options.OnTimeout is { } handler && handler.HasHandler) + { + handler.Invoke(new OnTimeoutArgs(context, timeout, elapsed)); + } + } +} diff --git a/src/Resilion/Timeout/TimeoutStrategyOptions.cs b/src/Resilion/Timeout/TimeoutStrategyOptions.cs new file mode 100644 index 0000000..d76eda9 --- /dev/null +++ b/src/Resilion/Timeout/TimeoutStrategyOptions.cs @@ -0,0 +1,67 @@ +namespace Resilion; + +/// +/// Options for the Timeout resilience strategy. +/// +/// +/// +/// Timeout relies on cooperative cancellation. The operation must observe the +/// passed through the execution context. If the operation +/// ignores the token, the timeout has no effect — the strategy cannot forcibly abort it. +/// +/// +/// To apply a per-attempt timeout inside retry, place the Timeout strategy after +/// the Retry strategy in the pipeline. To apply a total timeout across all retries, +/// place it before the Retry strategy. +/// +/// +public sealed record TimeoutStrategyOptions +{ + /// + /// Gets the timeout duration. Defaults to 30 seconds. + /// + /// + /// Set to to disable the timeout (passthrough). + /// Set to to immediately timeout (useful for testing). + /// + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Gets an optional delegate that computes the timeout duration dynamically per execution. + /// When set, is ignored. + /// + public Func? TimeoutGenerator { get; init; } + + /// + /// Gets an optional event handler that fires when a timeout occurs. + /// + public ResilienceEventHandler? OnTimeout { get; init; } + + internal void Validate() + { + if (TimeoutGenerator is null && Timeout < TimeSpan.Zero && Timeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException( + nameof(Timeout), + Timeout, + "Timeout must be a non-negative TimeSpan, TimeSpan.Zero, or Timeout.InfiniteTimeSpan."); + } + } +} + +/// +/// Arguments passed to the delegate. +/// +/// The execution context for the current operation. +public readonly record struct TimeoutGeneratorArgs(ResilienceContext Context); + +/// +/// Arguments passed to the event handler. +/// +/// The execution context for the timed-out operation. +/// The timeout duration that was applied. +/// The actual elapsed time before timeout was triggered. +public readonly record struct OnTimeoutArgs( + ResilienceContext Context, + TimeSpan Timeout, + TimeSpan ElapsedTime); diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..62fdd25 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,32 @@ + + + + net8.0 + Exe + latest + enable + enable + false + true + true + + $(NoWarn);xUnit1051;CS0162;CS0219 + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/tests/Resilion.Extensions.Tests/ExtensionsTests.cs b/tests/Resilion.Extensions.Tests/ExtensionsTests.cs new file mode 100644 index 0000000..2debf9d --- /dev/null +++ b/tests/Resilion.Extensions.Tests/ExtensionsTests.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.DependencyInjection; +using Resilion.Extensions; +using Xunit; + +namespace Resilion.Extensions.Tests; + +public class ResiliencePipelineRegistryTests +{ + [Fact] + public void RegisterAndGet_ReturnsSamePipeline() + { + using var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("test", b => b.AddRetry()); + + var p1 = registry.GetPipeline("test"); + var p2 = registry.GetPipeline("test"); + + Assert.NotNull(p1); + Assert.Same(p1, p2); + } + + [Fact] + public void GetPipeline_UnregisteredKey_Throws() + { + using var registry = new ResiliencePipelineRegistry(); + + Assert.Throws(() => registry.GetPipeline("missing")); + } + + [Fact] + public void RegisterDuplicateKey_Throws() + { + using var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("dup", b => b.AddTimeout(TimeSpan.FromSeconds(5))); + + Assert.Throws(() => + registry.RegisterPipeline("dup", b => b.AddRetry())); + } + + [Fact] + public void TryGetPipeline_Registered_ReturnsTrue() + { + using var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("exists", b => b.AddRetry()); + + Assert.True(registry.TryGetPipeline("exists", out var pipeline)); + Assert.NotNull(pipeline); + } + + [Fact] + public void TryGetPipeline_NotRegistered_ReturnsFalse() + { + using var registry = new ResiliencePipelineRegistry(); + + Assert.False(registry.TryGetPipeline("missing", out var pipeline)); + Assert.Null(pipeline); + } + + [Fact] + public async Task RegisteredPipeline_ExecutesCorrectly() + { + using var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("retry-3", b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + })); + + var pipeline = registry.GetPipeline("retry-3"); + var callCount = 0; + + var result = await pipeline.ExecuteAsync(ct => + { + callCount++; + if (callCount < 3) + { + throw new InvalidOperationException("transient"); + } + + return new ValueTask("recovered"); + }); + + Assert.Equal("recovered", result); + Assert.Equal(3, callCount); + } + + [Fact] + public async Task TypedPipeline_RegisterAndResolve() + { + using var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("typed", b => b.AddFallback( + new FallbackStrategyOptions { FallbackAction = -1 })); + + var pipeline = registry.GetPipeline("typed"); + var result = await pipeline.ExecuteAsync(ct => + { + throw new Exception("fail"); + return new ValueTask(0); + }); + + Assert.Equal(-1, result); + } + + [Fact] + public void Dispose_DisposesCreatedPipelines() + { + var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("x", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + _ = registry.GetPipeline("x"); // Force creation + + // Dispose should not throw. + registry.Dispose(); + } +} + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddResilion_RegistersRegistry() + { + var services = new ServiceCollection(); + services.AddResilion(); + + var sp = services.BuildServiceProvider(); + var registry = sp.GetService>(); + + Assert.NotNull(registry); + } + + [Fact] + public void AddResilion_RegistersContextPool() + { + var services = new ServiceCollection(); + services.AddResilion(); + + var sp = services.BuildServiceProvider(); + var pool = sp.GetService(); + + Assert.NotNull(pool); + Assert.Same(ResilienceContextPool.Shared, pool); + } + + [Fact] + public async Task AddResiliencePipeline_ResolvesViaRegistry() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("http-retry", b => b + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + }) + .AddTimeout(TimeSpan.FromSeconds(30))); + + var sp = services.BuildServiceProvider(); + + // Apply configurators to the registry (simulating what a hosted service would do). + var registry = ResilionServiceCollectionExtensions.BuildRegistry(sp); + var pipeline = registry.GetPipeline("http-retry"); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + Assert.Equal("ok", result); + } + + [Fact] + public async Task AddResiliencePipeline_Typed_ResolvesViaRegistry() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("fallback-pipeline", b => b + .AddFallback(new FallbackStrategyOptions { FallbackAction = 42 })); + + var sp = services.BuildServiceProvider(); + var registry = ResilionServiceCollectionExtensions.BuildRegistry(sp); + var pipeline = registry.GetPipeline("fallback-pipeline"); + + var result = await pipeline.ExecuteAsync(ct => + { + throw new Exception("fail"); + return new ValueTask(0); + }); + Assert.Equal(42, result); + } + + [Fact] + public void AddResilion_Idempotent() + { + var services = new ServiceCollection(); + services.AddResilion(); + services.AddResilion(); + services.AddResilion(); + + var sp = services.BuildServiceProvider(); + var registries = sp.GetServices>().ToList(); + + Assert.Single(registries); + } +} diff --git a/tests/Resilion.Extensions.Tests/Resilion.Extensions.Tests.csproj b/tests/Resilion.Extensions.Tests/Resilion.Extensions.Tests.csproj new file mode 100644 index 0000000..049ef1c --- /dev/null +++ b/tests/Resilion.Extensions.Tests/Resilion.Extensions.Tests.csproj @@ -0,0 +1,18 @@ + + + + Resilion.Extensions.Tests + Resilion.Extensions.Tests + + + + + + + + + + + + + diff --git a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs new file mode 100644 index 0000000..c44c341 --- /dev/null +++ b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs @@ -0,0 +1,431 @@ +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Tests; + +public class CircuitBreakerStrategyTests +{ + private static CircuitBreakerStrategyOptions LowThresholdOptions( + FakeTimeProvider? fakeTime = null) => + new() + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 4, + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(5), + }; + + // ────────────────────────────────────────────────────────────────── + // Closed state — happy path + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Closed_SuccessfulCalls_StayClosed() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(LowThresholdOptions())); + + for (var i = 0; i < 20; i++) + { + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + } + + [Fact] + public void Sync_Closed_SuccessfulCalls_StayClosed() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(LowThresholdOptions())); + + for (var i = 0; i < 20; i++) + { + var result = pipeline.Execute(ct => 42); + Assert.Equal(42, result); + } + } + + // ────────────────────────────────────────────────────────────────── + // Closed → Open transition + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Closed_ExceedsFailureRatio_TripsToOpen() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(LowThresholdOptions())); + + // Generate enough failures to trip: 4 failures out of 4 calls = 100% > 50% + for (var i = 0; i < 4; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("fail")).AsTask()); + } + + // Next call should be rejected + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(42)).AsTask()); + + Assert.Equal(CircuitState.Open, ex.CircuitState); + Assert.True(ex.RetryAfter > TimeSpan.Zero); + } + + [Fact] + public async Task Closed_BelowMinimumThroughput_DoesNotTrip() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(LowThresholdOptions())); + + // 3 failures — below MinimumThroughput of 4 + for (var i = 0; i < 3; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("fail")).AsTask()); + } + + // Should still be closed — not enough throughput to evaluate ratio + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + [Fact] + public async Task Closed_BelowFailureRatio_DoesNotTrip() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(LowThresholdOptions())); + + // 1 failure + 3 successes = 25% < 50% threshold + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => throw new InvalidOperationException("fail")).AsTask()); + + for (var i = 0; i < 3; i++) + { + await pipeline.ExecuteAsync(ct => new ValueTask(42)); + } + + // Should still be closed + var result = await pipeline.ExecuteAsync(ct => new ValueTask(99)); + Assert.Equal(99, result); + } + + // ────────────────────────────────────────────────────────────────── + // Open → HalfOpen → Closed recovery + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Open_AfterBreakDuration_TransitionsToHalfOpen_ThenClosed() + { + var fakeTime = new FakeTimeProvider(); + var stateChanges = new List<(CircuitState From, CircuitState To)>(); + Action onState = e => + stateChanges.Add((e.PreviousState, e.CurrentState)); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(5), + OnOpened = onState, + OnHalfOpened = onState, + OnClosed = onState, + }); + }); + + // Trip the circuit: 2 failures + for (var i = 0; i < 2; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("fail")).AsTask()); + } + + // Should be open + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + + // Advance past break duration + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + // Next call should succeed (probe in half-open) + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + + // State transitions should include: Closed→Open, Open→HalfOpen, HalfOpen→Closed + Assert.Contains(stateChanges, s => s.From == CircuitState.Closed && s.To == CircuitState.Open); + Assert.Contains(stateChanges, s => s.From == CircuitState.Open && s.To == CircuitState.HalfOpen); + Assert.Contains(stateChanges, s => s.From == CircuitState.HalfOpen && s.To == CircuitState.Closed); + } + + // ────────────────────────────────────────────────────────────────── + // HalfOpen — probe fails → back to Open + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task HalfOpen_ProbeFails_TransitionsBackToOpen() + { + var fakeTime = new FakeTimeProvider(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(5), + }); + }); + + // Trip the circuit + for (var i = 0; i < 2; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("fail")).AsTask()); + } + + // Advance past break duration + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + // Probe fails + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("still failing")).AsTask()); + + // Should be Open again — next call is rejected + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + } + + // ────────────────────────────────────────────────────────────────── + // OperationCanceledException — not counted as failure + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task OCE_NotCountedAsFailure() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + })); + + // 2 cancellations should NOT trip the circuit + for (var i = 0; i < 2; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new OperationCanceledException()).AsTask()); + } + + // Circuit should still be closed + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + // ────────────────────────────────────────────────────────────────── + // Manual control + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ManualControl_Isolate_RejectsAllCalls() + { + var control = new CircuitBreakerManualControl(); + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + ManualControl = control, + })); + + // Should work before isolation + var result = await pipeline.ExecuteAsync(ct => new ValueTask(1)); + Assert.Equal(1, result); + + // Isolate + await control.IsolateAsync(); + + // Should be rejected + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + Assert.Equal(CircuitState.Isolated, ex.CircuitState); + } + + [Fact] + public async Task ManualControl_Reset_AllowsCalls() + { + var control = new CircuitBreakerManualControl(); + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + ManualControl = control, + })); + + await control.IsolateAsync(); + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + + await control.ResetAsync(); + + // Should work again + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + [Fact] + public async Task ManualControl_WithoutStrategy_ThrowsInvalidOperation() + { + var control = new CircuitBreakerManualControl(); + + await Assert.ThrowsAsync(() => control.IsolateAsync()); + await Assert.ThrowsAsync(() => control.ResetAsync()); + } + + // ────────────────────────────────────────────────────────────────── + // Custom ShouldHandle predicate + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ShouldHandle_OnlyCountsMatchingExceptions() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + ShouldHandle = ex => ex is TimeoutException, // Only count TimeoutException + })); + + // InvalidOperationException should NOT count as failure + for (var i = 0; i < 4; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("not counted")).AsTask()); + } + + // Circuit should still be closed + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + // ────────────────────────────────────────────────────────────────── + // Options validation + // ────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(-0.1)] + [InlineData(1.1)] + public void InvalidFailureRatio_ThrowsAtBuildTime(double ratio) + { + Assert.Throws(() => + Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = ratio, + }))); + } + + [Fact] + public void ZeroMinimumThroughput_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + MinimumThroughput = 0, + }))); + } + + // ────────────────────────────────────────────────────────────────── + // Concurrent access (basic thread safety) + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ConcurrentAccess_NoExceptionsOrCorruption() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.8, + MinimumThroughput = 100, + SamplingDuration = TimeSpan.FromSeconds(60), + BreakDuration = TimeSpan.FromSeconds(1), + })); + + var successCount = 0; + var failureCount = 0; + var rejectionCount = 0; + + var tasks = Enumerable.Range(0, 500).Select(async i => + { + try + { + await pipeline.ExecuteAsync(ct => + { + if (i % 5 == 0) // 20% failure rate — below 80% threshold + { + throw new InvalidOperationException("fail"); + } + + return new ValueTask(i); + }); + + Interlocked.Increment(ref successCount); + } + catch (InvalidOperationException) + { + Interlocked.Increment(ref failureCount); + } + catch (CircuitBrokenException) + { + Interlocked.Increment(ref rejectionCount); + } + }); + + await Task.WhenAll(tasks); + + // With 20% failure rate and 80% threshold, the circuit should NOT trip. + Assert.Equal(0, rejectionCount); + Assert.True(successCount > 0); + Assert.True(failureCount > 0); + Assert.Equal(500, successCount + failureCount); + } + + // ────────────────────────────────────────────────────────────────── + // Typed pipeline — result-based circuit breaker + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task TypedPipeline_TripsOnBadResults() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker( + new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(5), + ShouldHandle = outcome => + outcome.TryGetResult(out var val) && val == -1, // -1 is a "failure" + })); + + // 2 "bad" results should trip + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + + // Should be tripped + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(42)).AsTask()); + } + + // ────────────────────────────────────────────────────────────────── + // Exception propagation — exceptions still propagate even when counted + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Exception_StillPropagates_WhenCountedAsFailure() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(LowThresholdOptions())); + + // The exception should propagate, AND be counted as a failure + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("should propagate")).AsTask()); + + Assert.Equal("should propagate", ex.Message); + } +} diff --git a/tests/Resilion.Tests/Fallback/FallbackStrategyTests.cs b/tests/Resilion.Tests/Fallback/FallbackStrategyTests.cs new file mode 100644 index 0000000..50ff14c --- /dev/null +++ b/tests/Resilion.Tests/Fallback/FallbackStrategyTests.cs @@ -0,0 +1,269 @@ +using Xunit; + +namespace Resilion.Tests; + +public class FallbackStrategyTests +{ + // ────────────────────────────────────────────────────────────────── + // Happy path — no fallback triggered + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_Success_NoFallback() + { + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "default", + })); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + Assert.Equal("ok", result); + } + + [Fact] + public void Sync_Success_NoFallback() + { + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "default", + })); + + var result = pipeline.Execute(ct => "ok"); + Assert.Equal("ok", result); + } + + // ────────────────────────────────────────────────────────────────── + // Constant value fallback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_ConstantFallback_ReturnedOnException() + { + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "fallback-value", + })); + + var result = await pipeline.ExecuteAsync(ct => + { + throw new InvalidOperationException("fail"); + return new ValueTask("unreachable"); + }); + + Assert.Equal("fallback-value", result); + } + + [Fact] + public void Sync_ConstantFallback_ReturnedOnException() + { + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "fallback-value", + })); + + var result = pipeline.Execute(ct => + { + throw new InvalidOperationException("fail"); + return "unreachable"; + }); + + Assert.Equal("fallback-value", result); + } + + // ────────────────────────────────────────────────────────────────── + // Sync factory fallback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_SyncFactory_ComputesFallback() + { + Func, string> factory = + ctx => $"fallback-for-{ctx.Exception?.GetType().Name}"; + + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = factory, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + throw new TimeoutException("timed out"); + return new ValueTask("unreachable"); + }); + + Assert.Equal("fallback-for-TimeoutException", result); + } + + // ────────────────────────────────────────────────────────────────── + // Async factory fallback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_AsyncFactory_ComputesFallback() + { + Func, ValueTask> factory = + async ctx => + { + await Task.Yield(); + return "async-fallback"; + }; + + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = factory, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + throw new InvalidOperationException("fail"); + return new ValueTask("unreachable"); + }); + + Assert.Equal("async-fallback", result); + } + + // ────────────────────────────────────────────────────────────────── + // Result-based fallback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_FallbackOnBadResult() + { + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = 0, + ShouldHandle = outcome => + outcome.TryGetResult(out var val) && val < 0, + })); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + Assert.Equal(0, result); + + result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + // ────────────────────────────────────────────────────────────────── + // OperationCanceledException — not handled by default + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OCE_NotHandledByDefault() + { + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "fallback", + })); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + throw new OperationCanceledException(); + return new ValueTask("unreachable"); + }).AsTask()); + } + + // ────────────────────────────────────────────────────────────────── + // OnFallback callback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OnFallback_IsFired() + { + OnFallbackEvent? capturedEvent = null; + Action> onFallback = e => capturedEvent = e; + + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = "replacement", + OnFallback = onFallback, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + throw new InvalidOperationException("fail"); + return new ValueTask("unreachable"); + }); + + Assert.Equal("replacement", result); + Assert.NotNull(capturedEvent); + Assert.Equal("replacement", capturedEvent.Value.FallbackResult); + Assert.IsType(capturedEvent.Value.Outcome.Exception); + } + + // ────────────────────────────────────────────────────────────────── + // Composition — Fallback wrapping Retry + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task FallbackWrappingRetry_FallbackCatchesExhaustedRetries() + { + var callCount = 0; + + var pipeline = Pipeline.Create(b => b + .AddFallback(new FallbackStrategyOptions + { + FallbackAction = "exhausted-fallback", + }) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + callCount++; + throw new InvalidOperationException("always fail"); + return new ValueTask("unreachable"); + }); + + Assert.Equal("exhausted-fallback", result); + Assert.Equal(3, callCount); + } + + // ────────────────────────────────────────────────────────────────── + // Options validation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void MissingFallbackAction_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions()))); + } + + // ────────────────────────────────────────────────────────────────── + // Fallback factory that throws — exception propagates + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_FallbackFactoryThrows_ExceptionPropagates() + { + Func, string> factory = + _ => throw new NotImplementedException("factory broke"); + + var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = factory, + })); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + throw new InvalidOperationException("trigger"); + return new ValueTask("unreachable"); + }).AsTask()); + } +} diff --git a/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs new file mode 100644 index 0000000..45d8eb1 --- /dev/null +++ b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs @@ -0,0 +1,358 @@ +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Tests; + +public class HedgingStrategyTests +{ + // ────────────────────────────────────────────────────────────────── + // Single attempt — no hedging + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task MaxHedgedAttempts1_NoHedging() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions { MaxHedgedAttempts = 1 })); + + var result = await pipeline.ExecuteAsync(ct => + { + Interlocked.Increment(ref callCount); + return new ValueTask("ok"); + }); + + Assert.Equal("ok", result); + Assert.Equal(1, callCount); + } + + // ────────────────────────────────────────────────────────────────── + // Primary succeeds — no hedging needed + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_PrimarySucceeds_ReturnsImmediately() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.FromSeconds(2), + })); + + var result = await pipeline.ExecuteAsync(ct => + { + Interlocked.Increment(ref callCount); + return new ValueTask("fast"); + }); + + Assert.Equal("fast", result); + // Primary succeeds immediately, hedged attempts may or may not launch + // depending on timing, but the result should always be "fast". + } + + // ────────────────────────────────────────────────────────────────── + // Parallel mode (delay = 0) — all attempts launch simultaneously + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ParallelMode_AllAttemptsLaunch() + { + var callCount = 0; + var barrier = new TaskCompletionSource(); + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.Zero, // Parallel mode + ShouldHandle = outcome => outcome.Exception is not null, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + var attempt = Interlocked.Increment(ref callCount); + // All attempts return the same result immediately. + return new ValueTask($"attempt-{attempt}"); + }); + + // At least one attempt should have completed. + Assert.StartsWith("attempt-", result); + } + + // ────────────────────────────────────────────────────────────────── + // Sequential mode (InfiniteTimeSpan) — waits for failure before next + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SequentialMode_TriesOneAtATime() + { + var callCount = 0; + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + var attempt = Interlocked.Increment(ref callCount); + if (attempt < 3) + { + throw new InvalidOperationException($"fail-{attempt}"); + } + + return new ValueTask("success"); + }); + + Assert.Equal("success", result); + Assert.Equal(3, callCount); + } + + // ────────────────────────────────────────────────────────────────── + // All attempts fail + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task AllAttemptsFail_ThrowsLastException() + { + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.Zero, + })); + + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + throw new InvalidOperationException("always fail"); + return new ValueTask("unreachable"); + }).AsTask()); + + Assert.Equal("always fail", ex.Message); + } + + // ────────────────────────────────────────────────────────────────── + // Custom ActionGenerator + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ActionGenerator_CustomActionsPerAttempt() + { + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + ActionGenerator = ctx => ctx.AttemptNumber switch + { + 0 => ct => throw new InvalidOperationException("primary fails"), + 1 => ct => throw new InvalidOperationException("secondary fails"), + 2 => ct => new ValueTask("tertiary wins"), + _ => null, + }, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + // This shouldn't be called because ActionGenerator provides all actions. + throw new InvalidOperationException("should not reach"); + return new ValueTask("unreachable"); + }); + + Assert.Equal("tertiary wins", result); + } + + // ────────────────────────────────────────────────────────────────── + // OnHedging callback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task OnHedging_FiredForEachHedgedAttempt() + { + var hedgingEvents = new List(); + Action> onHedging = e => + hedgingEvents.Add(e.AttemptNumber); + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.Zero, + OnHedging = onHedging, + })); + + await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + + // OnHedging should have been called for attempts 1 and 2 (not 0, the primary). + Assert.Contains(1, hedgingEvents); + Assert.Contains(2, hedgingEvents); + } + + // ────────────────────────────────────────────────────────────────── + // Sync execution — sequential fallback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void Sync_ExecutesSequentially() + { + var callCount = 0; + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.FromSeconds(1), // Ignored in sync — always sequential + })); + + var result = pipeline.Execute(ct => + { + callCount++; + if (callCount < 3) + { + throw new InvalidOperationException("fail"); + } + + return "success"; + }); + + Assert.Equal("success", result); + Assert.Equal(3, callCount); + } + + // ────────────────────────────────────────────────────────────────── + // User cancellation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task UserCancellation_PropagatesOCE() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.Zero, + })); + + await Assert.ThrowsAnyAsync(() => + pipeline.ExecuteAsync(ct => + { + ct.ThrowIfCancellationRequested(); + return new ValueTask("unreachable"); + }, cts.Token).AsTask()); + } + + // ────────────────────────────────────────────────────────────────── + // Result-based hedging + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ResultBasedHedging_RetriesOnBadResult() + { + var callCount = 0; + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + ShouldHandle = outcome => + outcome.TryGetResult(out var val) && val < 0, + })); + + var result = await pipeline.ExecuteAsync(ct => + { + var attempt = Interlocked.Increment(ref callCount); + return new ValueTask(attempt < 3 ? -1 : 42); + }); + + Assert.Equal(42, result); + } + + // ────────────────────────────────────────────────────────────────── + // Options validation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void ZeroMaxAttempts_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions { MaxHedgedAttempts = 0 }))); + } + + [Fact] + public void NegativeDelay_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions { HedgingDelay = TimeSpan.FromSeconds(-1) }))); + } + + // ────────────────────────────────────────────────────────────────── + // Properties propagation to hedged attempts + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Properties_PropagatedToHedgedAttempts() + { + var propertyKey = new ResiliencePropertyKey("test-key"); + string? capturedValue = null; + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + })); + + // Use ExecuteOutcomeAsync so we can set properties on the context. + var context = ResilienceContextPool.Shared.Rent(); + context.Properties.Set(propertyKey, "propagated-value"); + + try + { + var state = new HedgingPropertyTestState { CallCount = 0, OriginalContext = context, PropertyKey = propertyKey, CapturedValue = null }; + var outcome = await pipeline.ExecuteOutcomeAsync( + static (state, ctx) => + { + var attempt = Interlocked.Increment(ref state.CallCount); + if (attempt == 1) + { + return new ValueTask>( + Outcome.FromException(new InvalidOperationException("fail"))); + } + + // Second attempt — check if properties were propagated from the current attempt's context. + ctx.Properties.TryGetValue(state.PropertyKey, out var val); + state.CapturedValue = val; + return new ValueTask>(Outcome.FromResult("ok")); + }, + state, + context); + + // The captured value should reflect what was set on the original context. + // The hedging strategy creates a per-attempt context copy with properties copied from original. + Assert.Equal("propagated-value", state.CapturedValue); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + private class HedgingPropertyTestState + { + public int CallCount; + public ResilienceContext OriginalContext = null!; + public ResiliencePropertyKey PropertyKey; + public string? CapturedValue; + } +} diff --git a/tests/Resilion.Tests/OrderingValidatorTests.cs b/tests/Resilion.Tests/OrderingValidatorTests.cs new file mode 100644 index 0000000..226b404 --- /dev/null +++ b/tests/Resilion.Tests/OrderingValidatorTests.cs @@ -0,0 +1,234 @@ +using Resilion.Internal; +using Xunit; + +namespace Resilion.Tests; + +public class OrderingValidatorTests +{ + // ────────────────────────────────────────────────────────────────── + // No warnings for correct ordering + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void CanonicalOrder_NoWarnings() + { + var strategies = new List + { + StrategyType.RateLimiter, + StrategyType.Timeout, // total + StrategyType.Retry, + StrategyType.CircuitBreaker, + StrategyType.Timeout, // per-attempt + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.Empty(warnings); + } + + [Fact] + public void SingleStrategy_NoWarnings() + { + var warnings = OrderingValidator.Validate([StrategyType.Retry]); + Assert.Empty(warnings); + } + + // ────────────────────────────────────────────────────────────────── + // CircuitBreaker outside Retry + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void CircuitBreakerBeforeRetry_Warns() + { + var strategies = new List + { + StrategyType.CircuitBreaker, // pos 0 + StrategyType.Retry, // pos 1 + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.Single(warnings); + Assert.Contains("CircuitBreaker is outside Retry", warnings[0]); + } + + [Fact] + public void CircuitBreakerAfterRetry_NoWarning() + { + var strategies = new List + { + StrategyType.Retry, + StrategyType.CircuitBreaker, + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(warnings, w => w.Contains("CircuitBreaker is outside Retry")); + } + + // ────────────────────────────────────────────────────────────────── + // Fallback not outermost + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void FallbackNotFirst_Warns() + { + var strategies = new List + { + StrategyType.Retry, + StrategyType.Fallback, // pos 1, not 0 + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.Contains(warnings, w => w.Contains("Fallback is at position")); + } + + [Fact] + public void FallbackFirst_NoWarning() + { + var strategies = new List + { + StrategyType.Fallback, + StrategyType.Retry, + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(warnings, w => w.Contains("Fallback is at position")); + } + + // ────────────────────────────────────────────────────────────────── + // Hedging + Retry together + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void HedgingAndRetry_Warns() + { + var strategies = new List + { + StrategyType.Hedging, + StrategyType.Retry, + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.Contains(warnings, w => w.Contains("Both Hedging and Retry")); + } + + // ────────────────────────────────────────────────────────────────── + // Too many timeouts + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void ThreeTimeouts_Warns() + { + var strategies = new List + { + StrategyType.Timeout, + StrategyType.Timeout, + StrategyType.Timeout, + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.Contains(warnings, w => w.Contains("More than 2 Timeout")); + } + + [Fact] + public void TwoTimeouts_NoWarning() + { + var strategies = new List + { + StrategyType.Timeout, + StrategyType.Retry, + StrategyType.Timeout, + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(warnings, w => w.Contains("More than 2 Timeout")); + } + + // ────────────────────────────────────────────────────────────────── + // Retry outermost with no timeout + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void RetryOutermostNoTimeout_Warns() + { + var strategies = new List + { + StrategyType.Retry, + StrategyType.CircuitBreaker, + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.Contains(warnings, w => w.Contains("Retry is the outermost strategy with no Timeout")); + } + + [Fact] + public void RetryWithTimeoutPresent_NoWarning() + { + var strategies = new List + { + StrategyType.Timeout, + StrategyType.Retry, + }; + + var warnings = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(warnings, w => w.Contains("Retry is the outermost")); + } + + // ────────────────────────────────────────────────────────────────── + // SuppressOrderingWarnings on builder + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void SuppressWarnings_NoWarningsEmitted() + { + var captured = new List(); + + var pipeline = Pipeline.Create(b => + { + b.SuppressOrderingWarnings = true; + b.OnValidationWarning = w => captured.Add(w); + b.AddCircuitBreaker(); + b.AddRetry(); + }); + + Assert.Empty(captured); + } + + // ────────────────────────────────────────────────────────────────── + // OnValidationWarning callback receives warnings + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void OnValidationWarning_ReceivesWarnings() + { + var captured = new List(); + + var pipeline = Pipeline.Create(b => + { + b.OnValidationWarning = w => captured.Add(w); + b.AddCircuitBreaker(); + b.AddRetry(); + }); + + Assert.NotEmpty(captured); + Assert.Contains(captured, w => w.Contains("CircuitBreaker is outside Retry")); + } + + // ────────────────────────────────────────────────────────────────── + // Typed pipeline also validates + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void TypedPipeline_AlsoValidates() + { + var captured = new List(); + + var pipeline = Pipeline.Create(b => + { + b.OnValidationWarning = w => captured.Add(w); + b.AddCircuitBreaker(); + b.AddRetry(); + }); + + Assert.NotEmpty(captured); + Assert.Contains(captured, w => w.Contains("CircuitBreaker is outside Retry")); + } +} diff --git a/tests/Resilion.Tests/OutcomeTests.cs b/tests/Resilion.Tests/OutcomeTests.cs new file mode 100644 index 0000000..89d6c65 --- /dev/null +++ b/tests/Resilion.Tests/OutcomeTests.cs @@ -0,0 +1,191 @@ +using Xunit; + +namespace Resilion.Tests; + +public class OutcomeTests +{ + [Fact] + public void FromResult_CreatesSuccessfulOutcome() + { + var outcome = Outcome.FromResult("hello"); + + Assert.True(outcome.IsSuccess); + Assert.False(outcome.IsFailure); + Assert.Equal("hello", outcome.Result); + Assert.Null(outcome.Exception); + } + + [Fact] + public void FromException_CreatesFailedOutcome() + { + var ex = new InvalidOperationException("test"); + var outcome = Outcome.FromException(ex); + + Assert.False(outcome.IsSuccess); + Assert.True(outcome.IsFailure); + Assert.Same(ex, outcome.Exception); + } + + [Fact] + public void Result_ThrowsOnFailedOutcome() + { + var ex = new InvalidOperationException("test"); + var outcome = Outcome.FromException(ex); + + var thrown = Assert.Throws(() => outcome.Result); + Assert.Contains("Cannot access Result", thrown.Message); + } + + [Fact] + public void TryGetResult_ReturnsTrueOnSuccess() + { + var outcome = Outcome.FromResult(42); + + Assert.True(outcome.TryGetResult(out var result)); + Assert.Equal(42, result); + } + + [Fact] + public void TryGetResult_ReturnsFalseOnFailure() + { + var outcome = Outcome.FromException(new Exception("fail")); + + Assert.False(outcome.TryGetResult(out var result)); + Assert.Equal(default, result); + } + + [Fact] + public void GetResultOrDefault_ReturnsResultOnSuccess() + { + var outcome = Outcome.FromResult("value"); + + Assert.Equal("value", outcome.GetResultOrDefault("fallback")); + } + + [Fact] + public void GetResultOrDefault_ReturnsDefaultOnFailure() + { + var outcome = Outcome.FromException(new Exception()); + + Assert.Equal("fallback", outcome.GetResultOrDefault("fallback")); + } + + [Fact] + public void ThrowIfFailed_ReturnsResultOnSuccess() + { + var outcome = Outcome.FromResult(99); + + Assert.Equal(99, outcome.ThrowIfFailed()); + } + + [Fact] + public void ThrowIfFailed_PreservesStackTrace() + { + Exception captured; + try + { + throw new InvalidOperationException("original"); + } + catch (Exception ex) + { + captured = ex; + } + + var outcome = Outcome.FromException(captured); + + var thrown = Assert.Throws(() => outcome.ThrowIfFailed()); + Assert.Equal("original", thrown.Message); + // The stack trace should contain the original throw site + Assert.Contains("ThrowIfFailed_PreservesStackTrace", thrown.StackTrace!); + } + + [Fact] + public void Match_CallsOnSuccessForSuccessfulOutcome() + { + var outcome = Outcome.FromResult("hello"); + + var result = outcome.Match( + onSuccess: s => s.Length, + onFailure: _ => -1); + + Assert.Equal(5, result); + } + + [Fact] + public void Match_CallsOnFailureForFailedOutcome() + { + var outcome = Outcome.FromException(new InvalidOperationException("fail")); + + var result = outcome.Match( + onSuccess: s => s.Length, + onFailure: ex => ex.Message.Length); + + Assert.Equal(4, result); + } + + [Fact] + public void ImplicitConversion_CreatesSuccessfulOutcome() + { + Outcome outcome = "implicit"; + + Assert.True(outcome.IsSuccess); + Assert.Equal("implicit", outcome.Result); + } + + [Fact] + public void FromException_ThrowsOnNull() + { + Assert.Throws(() => Outcome.FromException(null!)); + } + + [Fact] + public void Equality_SuccessfulOutcomes() + { + var a = Outcome.FromResult(42); + var b = Outcome.FromResult(42); + var c = Outcome.FromResult(99); + + Assert.Equal(a, b); + Assert.NotEqual(a, c); + Assert.True(a == b); + Assert.True(a != c); + } + + [Fact] + public void Equality_FailedOutcomes_SameException() + { + var ex = new Exception("test"); + var a = Outcome.FromException(ex); + var b = Outcome.FromException(ex); + + Assert.Equal(a, b); + } + + [Fact] + public void Equality_SuccessNotEqualToFailure() + { + var success = Outcome.FromResult(0); + var failure = Outcome.FromException(new Exception()); + + Assert.NotEqual(success, failure); + } + + [Fact] + public void ToString_ShowsSuccessInfo() + { + var outcome = Outcome.FromResult("test"); + + Assert.Contains("Success", outcome.ToString()); + Assert.Contains("test", outcome.ToString()); + } + + [Fact] + public void ToString_ShowsFailureInfo() + { + var outcome = Outcome.FromException(new InvalidOperationException("oops")); + + Assert.Contains("Failure", outcome.ToString()); + Assert.Contains("InvalidOperationException", outcome.ToString()); + Assert.Contains("oops", outcome.ToString()); + } +} diff --git a/tests/Resilion.Tests/PipelineTests.cs b/tests/Resilion.Tests/PipelineTests.cs new file mode 100644 index 0000000..52f5e6c --- /dev/null +++ b/tests/Resilion.Tests/PipelineTests.cs @@ -0,0 +1,418 @@ +using Xunit; + +namespace Resilion.Tests; + +public class PipelineTests +{ + // ────────────────────────────────────────────────────────────────── + // Empty pipeline + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Empty_ExecuteAsync_PassesThrough() + { + var result = await Pipeline.Empty.ExecuteAsync(static ct => new ValueTask("hello")); + Assert.Equal("hello", result); + } + + [Fact] + public void Empty_Execute_PassesThrough() + { + var result = Pipeline.Empty.Execute(static ct => "hello"); + Assert.Equal("hello", result); + } + + [Fact] + public async Task Empty_VoidExecuteAsync_Completes() + { + var executed = false; + await Pipeline.Empty.ExecuteAsync(ct => + { + executed = true; + return ValueTask.CompletedTask; + }); + Assert.True(executed); + } + + [Fact] + public void Empty_VoidExecute_Completes() + { + var executed = false; + Pipeline.Empty.Execute(ct => { executed = true; }); + Assert.True(executed); + } + + // ────────────────────────────────────────────────────────────────── + // Strategy chain execution + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SingleStrategy_WrapsExecution() + { + var log = new List(); + + var pipeline = Pipeline.Create(b => b.AddStrategy(new LogStrategy(log, "S1"))); + + var result = await pipeline.ExecuteAsync(static ct => new ValueTask("result")); + + Assert.Equal("result", result); + Assert.Equal(["S1:before", "S1:after"], log); + } + + [Fact] + public async Task MultipleStrategies_ExecuteOuterToInner() + { + var log = new List(); + + var pipeline = Pipeline.Create(b => b + .AddStrategy(new LogStrategy(log, "Outer")) + .AddStrategy(new LogStrategy(log, "Inner"))); + + var result = await pipeline.ExecuteAsync(ct => + { + log.Add("callback"); + return new ValueTask("result"); + }); + + Assert.Equal("result", result); + Assert.Equal(["Outer:before", "Inner:before", "callback", "Inner:after", "Outer:after"], log); + } + + [Fact] + public void MultipleStrategies_ExecuteOuterToInner_Sync() + { + var log = new List(); + + var pipeline = Pipeline.Create(b => b + .AddStrategy(new LogStrategy(log, "Outer")) + .AddStrategy(new LogStrategy(log, "Inner"))); + + var result = pipeline.Execute(ct => + { + log.Add("callback"); + return "result"; + }); + + Assert.Equal("result", result); + Assert.Equal(["Outer:before", "Inner:before", "callback", "Inner:after", "Outer:after"], log); + } + + // ────────────────────────────────────────────────────────────────── + // State parameter (closure-free execution) + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task StateParameter_AvoidsClosure() + { + var pipeline = Pipeline.Empty; + + var result = await pipeline.ExecuteAsync( + static (state, ct) => new ValueTask(state.a + state.b), + (a: 3, b: 7)); + + Assert.Equal(10, result); + } + + [Fact] + public void StateParameter_Sync_AvoidsClosure() + { + var pipeline = Pipeline.Empty; + + var result = pipeline.Execute( + static (state, ct) => state.a * state.b, + (a: 6, b: 7)); + + Assert.Equal(42, result); + } + + // ────────────────────────────────────────────────────────────────── + // Exception propagation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Exception_PropagatesFromCallback() + { + var pipeline = Pipeline.Create(b => b.AddStrategy(new NoOpStrategy())); + + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => throw new InvalidOperationException("test")).AsTask()); + + Assert.Equal("test", ex.Message); + } + + [Fact] + public void Exception_PropagatesFromCallback_Sync() + { + var pipeline = Pipeline.Create(b => b.AddStrategy(new NoOpStrategy())); + + var ex = Assert.Throws(() => + pipeline.Execute(ct => throw new InvalidOperationException("test"))); + + Assert.Equal("test", ex.Message); + } + + // ────────────────────────────────────────────────────────────────── + // Cancellation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task CancellationToken_FlowsToCallback() + { + var pipeline = Pipeline.Empty; + using var cts = new CancellationTokenSource(); + + CancellationToken received = default; + await pipeline.ExecuteAsync(ct => + { + received = ct; + return new ValueTask(0); + }, cts.Token); + + Assert.Equal(cts.Token, received); + } + + [Fact] + public async Task CancelledToken_PropagatesOperationCanceledException() + { + var pipeline = Pipeline.Create(b => b.AddStrategy(new NoOpStrategy())); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + ct.ThrowIfCancellationRequested(); + return new ValueTask(0); + }, cts.Token).AsTask()); + } + + // ────────────────────────────────────────────────────────────────── + // Pipeline composition via AddPipeline + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task AddPipeline_FlattensStrategies() + { + var log = new List(); + + var inner = Pipeline.Create(b => b.AddStrategy(new LogStrategy(log, "Inner"))); + var pipeline = Pipeline.Create(b => b + .AddStrategy(new LogStrategy(log, "Outer")) + .AddPipeline(inner)); + + var result = await pipeline.ExecuteAsync(ct => + { + log.Add("callback"); + return new ValueTask("done"); + }); + + Assert.Equal("done", result); + Assert.Equal(["Outer:before", "Inner:before", "callback", "Inner:after", "Outer:after"], log); + } + + // ────────────────────────────────────────────────────────────────── + // ExecuteOutcomeAsync (no-throw path) + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ExecuteOutcomeAsync_ReturnsSuccessOutcome() + { + var pipeline = Pipeline.Empty; + var context = ResilienceContextPool.Shared.Rent(); + try + { + var outcome = await pipeline.ExecuteOutcomeAsync( + static (state, ctx) => new ValueTask>(Outcome.FromResult("ok")), + "state", + context); + + Assert.True(outcome.IsSuccess); + Assert.Equal("ok", outcome.Result); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + [Fact] + public async Task ExecuteOutcomeAsync_ReturnsFailureOutcome_WithoutThrowing() + { + var pipeline = Pipeline.Empty; + var context = ResilienceContextPool.Shared.Rent(); + try + { + var outcome = await pipeline.ExecuteOutcomeAsync( + static (state, ctx) => new ValueTask>( + Outcome.FromException(new InvalidOperationException("fail"))), + "state", + context); + + Assert.True(outcome.IsFailure); + Assert.IsType(outcome.Exception); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + // ────────────────────────────────────────────────────────────────── + // Typed pipeline + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task TypedPipeline_ExecutesWithTypedStrategy() + { + var pipeline = Pipeline.Create(b => b + .AddStrategy(new UpperCaseStrategy())); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("hello")); + + Assert.Equal("HELLO", result); + } + + [Fact] + public void TypedPipeline_ExecutesSync() + { + var pipeline = Pipeline.Create(b => b + .AddStrategy(new UpperCaseStrategy())); + + var result = pipeline.Execute(ct => "world"); + + Assert.Equal("WORLD", result); + } + + [Fact] + public async Task TypedPipeline_DelegateStrategy_Works() + { + var pipeline = Pipeline.Create(b => b + .AddStrategy("double-it", async (ctx, next) => + { + var outcome = await next(ctx); + if (outcome.TryGetResult(out var value)) + { + return Outcome.FromResult(value * 2); + } + + return outcome; + })); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask(21)); + + Assert.Equal(42, result); + } + + [Fact] + public async Task TypedPipeline_MixesNonGenericAndTypedStrategies() + { + var log = new List(); + + var pipeline = Pipeline.Create(b => b + .AddStrategy(new LogStrategy(log, "NonGeneric")) + .AddStrategy(new UpperCaseStrategy())); + + var result = await pipeline.ExecuteAsync(ct => + { + log.Add("callback"); + return new ValueTask("hello"); + }); + + Assert.Equal("HELLO", result); + Assert.Equal(["NonGeneric:before", "callback", "NonGeneric:after"], log); + } + + // ────────────────────────────────────────────────────────────────── + // Pipeline.Create factory + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_ReturnsEmptyWhenNoStrategies() + { + var pipeline = Pipeline.Create(b => { }); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + // ────────────────────────────────────────────────────────────────── + // Test helpers + // ────────────────────────────────────────────────────────────────── + + /// Logs before/after execution for verifying strategy ordering. + private sealed class LogStrategy : Strategy + { + private readonly List _log; + private readonly string _name; + + public LogStrategy(List log, string name) + { + _log = log; + _name = name; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + _log.Add($"{_name}:before"); + var outcome = await callback(context).ConfigureAwait(false); + _log.Add($"{_name}:after"); + return outcome; + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + _log.Add($"{_name}:before"); + var outcome = callback(context); + _log.Add($"{_name}:after"); + return outcome; + } + } + + /// A no-op strategy that passes through to the next component. + private sealed class NoOpStrategy : Strategy + { + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + => callback(context); + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + => callback(context); + } + + /// A typed strategy that uppercases string results. + private sealed class UpperCaseStrategy : Strategy + { + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + var outcome = await callback(context).ConfigureAwait(false); + if (outcome.TryGetResult(out var value)) + { + return Outcome.FromResult(value.ToUpperInvariant()); + } + + return outcome; + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + var outcome = callback(context); + if (outcome.TryGetResult(out var value)) + { + return Outcome.FromResult(value.ToUpperInvariant()); + } + + return outcome; + } + } +} diff --git a/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs b/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs new file mode 100644 index 0000000..347f963 --- /dev/null +++ b/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs @@ -0,0 +1,319 @@ +using System.Threading.RateLimiting; +using Resilion.RateLimiting; +using Xunit; + +namespace Resilion.Tests; + +public class RateLimiterStrategyTests +{ + // ────────────────────────────────────────────────────────────────── + // Happy path — within limit + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_WithinLimit_ExecutesNormally() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + Assert.Equal("ok", result); + } + + [Fact] + public void Sync_WithinLimit_ExecutesNormally() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + var result = pipeline.Execute(ct => "ok"); + Assert.Equal("ok", result); + } + + // ────────────────────────────────────────────────────────────────── + // Exceeds limit — rejected + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_ExceedsLimit_ThrowsRateLimitRejectedException() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + // Acquire the only permit manually so pipeline execution is rejected. + var lease = limiter.AttemptAcquire(); + Assert.True(lease.IsAcquired); + + try + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask("should not reach")).AsTask()); + } + finally + { + lease.Dispose(); + } + } + + [Fact] + public void Sync_ExceedsLimit_ThrowsRateLimitRejectedException() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + var lease = limiter.AttemptAcquire(); + Assert.True(lease.IsAcquired); + + try + { + Assert.Throws(() => + pipeline.Execute(ct => "should not reach")); + } + finally + { + lease.Dispose(); + } + } + + // ────────────────────────────────────────────────────────────────── + // Lease is released after execution (success and failure) + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_LeaseReleasedAfterSuccess() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + // First call should succeed and release the lease + await pipeline.ExecuteAsync(ct => new ValueTask(1)); + + // Second call should also succeed (lease was released) + var result = await pipeline.ExecuteAsync(ct => new ValueTask(2)); + Assert.Equal(2, result); + } + + [Fact] + public async Task Async_LeaseReleasedAfterException() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + // First call throws but should release the lease + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => throw new InvalidOperationException("fail")).AsTask()); + + // Second call should succeed (lease was released) + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + // ────────────────────────────────────────────────────────────────── + // OnRejected callback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OnRejected_IsFired() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 0, + }); + + OnRateLimitRejectedEvent? capturedEvent = null; + Action onRejected = e => capturedEvent = e; + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + OnRejected = onRejected, + })); + + var lease = limiter.AttemptAcquire(); + try + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + + Assert.NotNull(capturedEvent); + } + finally + { + lease.Dispose(); + } + } + + // ────────────────────────────────────────────────────────────────── + // Token bucket — RetryAfter metadata + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_TokenBucket_ProvidesRetryAfter() + { + using var limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions + { + TokenLimit = 1, + QueueLimit = 0, + ReplenishmentPeriod = TimeSpan.FromSeconds(10), + TokensPerPeriod = 1, + AutoReplenishment = false, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + // Exhaust the single token + await pipeline.ExecuteAsync(ct => new ValueTask(1)); + + // Next call should be rejected + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(2)).AsTask()); + + // TokenBucketRateLimiter provides RetryAfter metadata + Assert.NotNull(ex.RetryAfter); + Assert.True(ex.RetryAfter.Value > TimeSpan.Zero); + } + + // ────────────────────────────────────────────────────────────────── + // Typed pipeline + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task TypedPipeline_RateLimiter_Works() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("typed-ok")); + Assert.Equal("typed-ok", result); + } + + // ────────────────────────────────────────────────────────────────── + // Options validation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void MissingRateLimiter_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions()))); + } + + // ────────────────────────────────────────────────────────────────── + // Concurrent access + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ConcurrentAccess_ConcurrencyLimiter_EnforcesLimit() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 5, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + var concurrentCount = 0; + var maxConcurrent = 0; + + var tasks = Enumerable.Range(0, 20).Select(async _ => + { + try + { + await pipeline.ExecuteAsync(async ct => + { + var current = Interlocked.Increment(ref concurrentCount); + InterlockedMax(ref maxConcurrent, current); + await Task.Delay(50, ct); + Interlocked.Decrement(ref concurrentCount); + return 0; + }); + } + catch (RateLimitRejectedException) + { + // Expected — some will be rejected + } + }); + + await Task.WhenAll(tasks); + + // Concurrency should never exceed the permit limit + Assert.True(maxConcurrent <= 5, $"Max concurrent was {maxConcurrent}, expected <= 5"); + } + + private static void InterlockedMax(ref int location, int value) + { + int current; + do + { + current = location; + if (value <= current) + { + return; + } + } while (Interlocked.CompareExchange(ref location, value, current) != current); + } +} diff --git a/tests/Resilion.Tests/ResilienceContextTests.cs b/tests/Resilion.Tests/ResilienceContextTests.cs new file mode 100644 index 0000000..e9d0bf6 --- /dev/null +++ b/tests/Resilion.Tests/ResilienceContextTests.cs @@ -0,0 +1,114 @@ +using Xunit; + +namespace Resilion.Tests; + +public class ResilienceContextTests +{ + [Fact] + public void Pool_RentAndReturn_ReusesInstances() + { + var pool = new ResilienceContextPool(); + using var cts = new CancellationTokenSource(); + + var ctx1 = pool.Rent(cts.Token); + Assert.Equal(cts.Token, ctx1.CancellationToken); + + pool.Return(ctx1); + + var ctx2 = pool.Rent(); + // Should get the same pooled instance back + Assert.Same(ctx1, ctx2); + // Should be reset + Assert.Equal(CancellationToken.None, ctx2.CancellationToken); + Assert.Null(ctx2.OperationKey); + Assert.False(ctx2.ContinueOnCapturedContext); + + pool.Return(ctx2); + } + + [Fact] + public void Pool_Return_ResetsContext() + { + var pool = new ResilienceContextPool(); + using var cts = new CancellationTokenSource(); + + var ctx = pool.Rent(cts.Token); + ctx.OperationKey = "test-op"; + ctx.ContinueOnCapturedContext = true; + ctx.Properties.Set(new ResiliencePropertyKey("key"), "value"); + + pool.Return(ctx); + + // Re-rent and verify everything is reset + var ctx2 = pool.Rent(); + Assert.Same(ctx, ctx2); + Assert.Null(ctx2.OperationKey); + Assert.False(ctx2.ContinueOnCapturedContext); + Assert.Equal(0, ctx2.Properties.Count); + + pool.Return(ctx2); + } + + [Fact] + public void Pool_SharedInstance_IsNotNull() + { + Assert.NotNull(ResilienceContextPool.Shared); + } + + [Fact] + public void Properties_SetAndGet() + { + var ctx = ResilienceContextPool.Shared.Rent(); + try + { + var key = new ResiliencePropertyKey("retryCount"); + ctx.Properties.Set(key, 3); + + Assert.True(ctx.Properties.TryGetValue(key, out var value)); + Assert.Equal(3, value); + Assert.Equal(3, ctx.Properties.GetValue(key)); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Fact] + public void Properties_MissingKey_ReturnsDefault() + { + var ctx = ResilienceContextPool.Shared.Rent(); + try + { + var key = new ResiliencePropertyKey("missing"); + + Assert.False(ctx.Properties.TryGetValue(key, out var value)); + Assert.Null(value); + Assert.Equal("fallback", ctx.Properties.GetValue(key, "fallback")); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Fact] + public void Properties_Remove() + { + var ctx = ResilienceContextPool.Shared.Rent(); + try + { + var key = new ResiliencePropertyKey("temp"); + ctx.Properties.Set(key, 42); + Assert.Equal(1, ctx.Properties.Count); + + Assert.True(ctx.Properties.Remove(key)); + Assert.Equal(0, ctx.Properties.Count); + Assert.False(ctx.Properties.Remove(key)); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } +} diff --git a/tests/Resilion.Tests/ResilienceEventHandlerTests.cs b/tests/Resilion.Tests/ResilienceEventHandlerTests.cs new file mode 100644 index 0000000..06cf406 --- /dev/null +++ b/tests/Resilion.Tests/ResilienceEventHandlerTests.cs @@ -0,0 +1,67 @@ +using Xunit; + +namespace Resilion.Tests; + +public class ResilienceEventHandlerTests +{ + private readonly record struct TestEvent(string Message); + + [Fact] + public async Task SyncHandler_InvokedViaInvokeAsync() + { + string? captured = null; + Action action = e => { captured = e.Message; }; + ResilienceEventHandler handler = action; + + Assert.True(handler.HasHandler); + await handler.InvokeAsync(new TestEvent("hello")); + + Assert.Equal("hello", captured); + } + + [Fact] + public async Task AsyncHandler_InvokedViaInvokeAsync() + { + string? captured = null; + Func func = async e => + { + await Task.Yield(); + captured = e.Message; + }; + ResilienceEventHandler handler = func; + + Assert.True(handler.HasHandler); + await handler.InvokeAsync(new TestEvent("async")); + + Assert.Equal("async", captured); + } + + [Fact] + public void SyncHandler_InvokedViaInvoke() + { + string? captured = null; + Action action = e => { captured = e.Message; }; + ResilienceEventHandler handler = action; + + handler.Invoke(new TestEvent("sync")); + + Assert.Equal("sync", captured); + } + + [Fact] + public void DefaultHandler_HasNoHandler() + { + ResilienceEventHandler handler = default; + + Assert.False(handler.HasHandler); + } + + [Fact] + public async Task DefaultHandler_InvokeAsync_DoesNotThrow() + { + ResilienceEventHandler handler = default; + + // Should not throw + await handler.InvokeAsync(new TestEvent("ignored")); + } +} diff --git a/tests/Resilion.Tests/Resilion.Tests.csproj b/tests/Resilion.Tests/Resilion.Tests.csproj new file mode 100644 index 0000000..d87f1f0 --- /dev/null +++ b/tests/Resilion.Tests/Resilion.Tests.csproj @@ -0,0 +1,17 @@ + + + + Resilion.Tests + Resilion.Tests + + + + + + + + + + + + diff --git a/tests/Resilion.Tests/Retry/RetryStrategyTests.cs b/tests/Resilion.Tests/Retry/RetryStrategyTests.cs new file mode 100644 index 0000000..7c6b468 --- /dev/null +++ b/tests/Resilion.Tests/Retry/RetryStrategyTests.cs @@ -0,0 +1,419 @@ +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Tests; + +public class RetryStrategyTests +{ + // ────────────────────────────────────────────────────────────────── + // Happy path — no retries needed + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_Success_NoRetries() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry()); + + var result = await pipeline.ExecuteAsync(ct => + { + callCount++; + return new ValueTask("ok"); + }); + + Assert.Equal("ok", result); + Assert.Equal(1, callCount); + } + + [Fact] + public void Sync_Success_NoRetries() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry()); + + var result = pipeline.Execute(ct => + { + callCount++; + return "ok"; + }); + + Assert.Equal("ok", result); + Assert.Equal(1, callCount); + } + + // ────────────────────────────────────────────────────────────────── + // Retries on exception + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_RetriesOnException_ThenSucceeds() + { + var callCount = 0; + var fakeTime = new FakeTimeProvider(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, // No delay for test speed + }); + }); + + var result = await pipeline.ExecuteAsync(ct => + { + callCount++; + if (callCount < 3) + { + throw new InvalidOperationException("transient"); + } + + return new ValueTask("recovered"); + }); + + Assert.Equal("recovered", result); + Assert.Equal(3, callCount); // 1 initial + 2 retries before success + } + + [Fact] + public async Task Async_ExhaustsRetries_ThrowsLastException() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + })); + + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + callCount++; + throw new InvalidOperationException($"attempt-{callCount}"); + }).AsTask()); + + Assert.Equal(3, callCount); // 1 initial + 2 retries + Assert.Equal("attempt-3", ex.Message); // Last exception propagates + } + + // ────────────────────────────────────────────────────────────────── + // MaxRetryAttempts = 0 — no retries + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_ZeroRetries_ExecutesOnce() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 0, + })); + + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + callCount++; + throw new InvalidOperationException("fail"); + }).AsTask()); + + Assert.Equal(1, callCount); + } + + // ────────────────────────────────────────────────────────────────── + // ShouldHandle predicate + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_UnhandledException_DoesNotRetry() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, + ShouldHandle = ex => ex is HttpRequestException, // Only retry HttpRequestException + })); + + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + callCount++; + throw new InvalidOperationException("not retryable"); + }).AsTask()); + + Assert.Equal(1, callCount); // Did NOT retry + } + + [Fact] + public async Task Async_OperationCanceledException_NeverRetriedByDefault() + { + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, + })); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + callCount++; + throw new OperationCanceledException(); + }).AsTask()); + + Assert.Equal(1, callCount); // Did NOT retry OCE + } + + // ────────────────────────────────────────────────────────────────── + // OnRetry callback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OnRetry_FiredForEachRetry() + { + var retryEvents = new List(); + Action onRetry = e => retryEvents.Add(e); + + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, + OnRetry = onRetry, + })); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + { + callCount++; + throw new InvalidOperationException($"fail-{callCount}"); + }).AsTask()); + + Assert.Equal(3, retryEvents.Count); + Assert.Equal(1, retryEvents[0].AttemptNumber); + Assert.Equal(2, retryEvents[1].AttemptNumber); + Assert.Equal(3, retryEvents[2].AttemptNumber); + } + + // ────────────────────────────────────────────────────────────────── + // Cancellation before retry + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_PreCancelledToken_DoesNotRetry() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var callCount = 0; + + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 5, + Delay = RetryDelay.None, + })); + + await Assert.ThrowsAnyAsync(() => + pipeline.ExecuteAsync(ct => + { + callCount++; + ct.ThrowIfCancellationRequested(); + throw new InvalidOperationException("fail"); + }, cts.Token).AsTask()); + + // With a pre-cancelled token, the retry loop checks cancellation before each attempt. + Assert.True(callCount <= 1); + } + + // ────────────────────────────────────────────────────────────────── + // RetryDelay variants + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void ConstantDelay_ReturnsSameValue() + { + var delay = RetryDelay.Constant(TimeSpan.FromMilliseconds(100)); + + var d1 = delay.ComputeDelay(1, useJitter: false); + var d2 = delay.ComputeDelay(2, useJitter: false); + var d3 = delay.ComputeDelay(3, useJitter: false); + + Assert.Equal(TimeSpan.FromMilliseconds(100), d1); + Assert.Equal(TimeSpan.FromMilliseconds(100), d2); + Assert.Equal(TimeSpan.FromMilliseconds(100), d3); + } + + [Fact] + public void LinearDelay_ScalesLinearly() + { + var delay = RetryDelay.Linear(TimeSpan.FromSeconds(1)); + + var d1 = delay.ComputeDelay(1, useJitter: false); + var d2 = delay.ComputeDelay(2, useJitter: false); + var d3 = delay.ComputeDelay(3, useJitter: false); + + Assert.Equal(TimeSpan.FromSeconds(1), d1); + Assert.Equal(TimeSpan.FromSeconds(2), d2); + Assert.Equal(TimeSpan.FromSeconds(3), d3); + } + + [Fact] + public void ExponentialDelay_ScalesExponentially() + { + var delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1), maxDelay: TimeSpan.FromSeconds(60)); + + var d1 = delay.ComputeDelay(1, useJitter: false); + var d2 = delay.ComputeDelay(2, useJitter: false); + var d3 = delay.ComputeDelay(3, useJitter: false); + var d4 = delay.ComputeDelay(4, useJitter: false); + + Assert.Equal(TimeSpan.FromSeconds(1), d1); // 1 * 2^0 = 1 + Assert.Equal(TimeSpan.FromSeconds(2), d2); // 1 * 2^1 = 2 + Assert.Equal(TimeSpan.FromSeconds(4), d3); // 1 * 2^2 = 4 + Assert.Equal(TimeSpan.FromSeconds(8), d4); // 1 * 2^3 = 8 + } + + [Fact] + public void ExponentialDelay_ClampedByMaxDelay() + { + var delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1), maxDelay: TimeSpan.FromSeconds(5)); + + var d4 = delay.ComputeDelay(4, useJitter: false); // 8s clamped to 5s + + Assert.Equal(TimeSpan.FromSeconds(5), d4); + } + + [Fact] + public void Jitter_ProducesVariation() + { + var delay = RetryDelay.Constant(TimeSpan.FromSeconds(1)); + + // With jitter, values should vary. Run multiple times. + var delays = Enumerable.Range(1, 100) + .Select(_ => delay.ComputeDelay(1, useJitter: true)) + .ToList(); + + // All within ±25% of 1 second (750ms to 1250ms) + Assert.All(delays, d => + { + Assert.InRange(d.TotalMilliseconds, 750, 1250); + }); + + // Not all the same (jitter should produce variation) + Assert.True(delays.Distinct().Count() > 1, "Jitter should produce variation"); + } + + [Fact] + public void CustomDelay_UsesProvidedFunction() + { + var delay = RetryDelay.Custom(ctx => TimeSpan.FromSeconds(ctx.AttemptNumber * 5)); + + Assert.Equal(TimeSpan.FromSeconds(5), delay.ComputeDelay(1, useJitter: false)); + Assert.Equal(TimeSpan.FromSeconds(10), delay.ComputeDelay(2, useJitter: false)); + } + + [Fact] + public void NoneDelay_IsZero() + { + Assert.Equal(TimeSpan.Zero, RetryDelay.None.ComputeDelay(1, useJitter: false)); + } + + // ────────────────────────────────────────────────────────────────── + // Typed pipeline — result-based retry + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task TypedPipeline_RetriesOnBadResult() + { + var callCount = 0; + + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.None, + ShouldHandle = outcome => + outcome.TryGetResult(out var val) && val < 0, // Retry negative results + })); + + var result = await pipeline.ExecuteAsync(ct => + { + callCount++; + return new ValueTask(callCount < 3 ? -1 : 42); + }); + + Assert.Equal(42, result); + Assert.Equal(3, callCount); + } + + [Fact] + public async Task TypedPipeline_ReturnsBadResult_WhenRetriesExhausted() + { + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + ShouldHandle = outcome => + outcome.TryGetResult(out var val) && val == -1, + })); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + + // After exhausting retries, the last result is returned (not an exception) + Assert.Equal(-1, result); + } + + // ────────────────────────────────────────────────────────────────── + // Options validation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void NegativeMaxRetryAttempts_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = -1 }))); + } + + // ────────────────────────────────────────────────────────────────── + // Composition with Timeout (sync path, avoids FakeTimeProvider coordination issues) + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task RetryWithTimeout_RetriesTimeoutRejectedException() + { + var callCount = 0; + + // Retry wrapping a pipeline that throws TimeoutRejectedException + var pipeline = Pipeline.Create(b => + { + b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + ShouldHandle = ex => ex is TimeoutRejectedException, + }); + }); + + var result = await pipeline.ExecuteAsync(ct => + { + callCount++; + if (callCount <= 2) + { + throw new TimeoutRejectedException( + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1.1), + new OperationCanceledException()); + } + + return new ValueTask("success"); + }); + + Assert.Equal("success", result); + Assert.Equal(3, callCount); + } +} + +// Needed for the ShouldHandle test +file class HttpRequestException : Exception +{ + public HttpRequestException(string message) : base(message) { } +} diff --git a/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs b/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs new file mode 100644 index 0000000..9c15171 --- /dev/null +++ b/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs @@ -0,0 +1,294 @@ +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Tests; + +public class TimeoutStrategyTests +{ + // ────────────────────────────────────────────────────────────────── + // Happy path — operation completes within timeout + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OperationCompletesWithinTimeout_ReturnsResult() + { + var pipeline = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(10))); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + + Assert.Equal("ok", result); + } + + [Fact] + public void Sync_OperationCompletesWithinTimeout_ReturnsResult() + { + var pipeline = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(10))); + + var result = pipeline.Execute(ct => "ok"); + + Assert.Equal("ok", result); + } + + // ────────────────────────────────────────────────────────────────── + // Timeout fires — operation exceeds timeout + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OperationExceedsTimeout_ThrowsTimeoutRejectedException() + { + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + var task = pipeline.ExecuteAsync(async ct => + { + // Simulate a long operation that observes cancellation + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return "should not reach here"; + }); + + // Advance past the timeout + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + var ex = await Assert.ThrowsAsync(() => task.AsTask()); + Assert.Equal(TimeSpan.FromSeconds(5), ex.ConfiguredTimeout); + Assert.IsAssignableFrom(ex.InnerException); + } + + [Fact] + public async Task Async_TimeoutZero_ImmediatelyTimesOut() + { + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.Zero); + }); + + // TimeSpan.Zero fires immediately from the timer. + // We need to give the timer a chance to fire. + var task = pipeline.ExecuteAsync(async ct => + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(TimeSpan.FromSeconds(1), fakeTime, ct); + return "should not reach"; + }); + + // Advance even a tiny bit so the timer fires. + fakeTime.Advance(TimeSpan.FromMilliseconds(1)); + + await Assert.ThrowsAsync(() => task.AsTask()); + } + + // ────────────────────────────────────────────────────────────────── + // Infinite timeout — passthrough + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_InfiniteTimeout_PassesThrough() + { + var pipeline = Pipeline.Create(b => + b.AddTimeout(System.Threading.Timeout.InfiniteTimeSpan)); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("pass")); + + Assert.Equal("pass", result); + } + + // ────────────────────────────────────────────────────────────────── + // User cancellation — propagates OperationCanceledException, NOT TimeoutRejectedException + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_UserCancellation_PropagatesOCE_NotTimeoutRejected() + { + var fakeTime = new FakeTimeProvider(); + using var userCts = new CancellationTokenSource(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(30)); + }); + + var task = pipeline.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return "should not reach"; + }, userCts.Token); + + // User cancels — NOT a timeout + userCts.Cancel(); + + // Should throw OperationCanceledException, NOT TimeoutRejectedException + var ex = await Assert.ThrowsAnyAsync(() => task.AsTask()); + Assert.IsNotType(ex); + } + + // ────────────────────────────────────────────────────────────────── + // CancellationToken flows to callback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_TimeoutToken_FlowsToCallback() + { + var fakeTime = new FakeTimeProvider(); + CancellationToken receivedToken = default; + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + await pipeline.ExecuteAsync(ct => + { + receivedToken = ct; + return new ValueTask(42); + }); + + // The callback should have received a linked token (different from CancellationToken.None) + // We can't easily assert it's linked, but we know the pipeline replaced it. + // The important thing is the test completes without timeout. + } + + // ────────────────────────────────────────────────────────────────── + // OnTimeout callback + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OnTimeout_IsFired() + { + var fakeTime = new FakeTimeProvider(); + OnTimeoutArgs? capturedArgs = null; + + Action onTimeout = args => { capturedArgs = args; }; + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(new TimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(5), + OnTimeout = onTimeout, + }); + }); + + var task = pipeline.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return "nope"; + }); + + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + await Assert.ThrowsAsync(() => task.AsTask()); + + Assert.NotNull(capturedArgs); + Assert.Equal(TimeSpan.FromSeconds(5), capturedArgs.Value.Timeout); + } + + // ────────────────────────────────────────────────────────────────── + // TimeoutGenerator — dynamic timeout + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_TimeoutGenerator_OverridesStaticTimeout() + { + var fakeTime = new FakeTimeProvider(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(new TimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(100), // Should be ignored + TimeoutGenerator = _ => TimeSpan.FromSeconds(2), + }); + }); + + var task = pipeline.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return "nope"; + }); + + fakeTime.Advance(TimeSpan.FromSeconds(3)); + + var ex = await Assert.ThrowsAsync(() => task.AsTask()); + Assert.Equal(TimeSpan.FromSeconds(2), ex.ConfiguredTimeout); + } + + // ────────────────────────────────────────────────────────────────── + // Typed pipeline + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task TypedPipeline_Timeout_Works() + { + var pipeline = Pipeline.Create(b => + b.AddTimeout(TimeSpan.FromSeconds(10))); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("typed-ok")); + + Assert.Equal("typed-ok", result); + } + + // ────────────────────────────────────────────────────────────────── + // Exception from callback (non-cancellation) propagates normally + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_NonCancellationException_PropagatesNormally() + { + var pipeline = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(10))); + + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("business error")).AsTask()); + + Assert.Equal("business error", ex.Message); + } + + // ────────────────────────────────────────────────────────────────── + // Options validation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void NegativeTimeout_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(-5)))); + } + + // ────────────────────────────────────────────────────────────────── + // Default options + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task DefaultOptions_Uses30SecondTimeout() + { + var fakeTime = new FakeTimeProvider(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(); + }); + + var task = pipeline.ExecuteAsync(async ct => + { + await Task.Delay(TimeSpan.FromMinutes(5), fakeTime, ct); + return "nope"; + }); + + // Advance past the 30s default + fakeTime.Advance(TimeSpan.FromSeconds(31)); + + var ex = await Assert.ThrowsAsync(() => task.AsTask()); + Assert.Equal(TimeSpan.FromSeconds(30), ex.ConfiguredTimeout); + } +}