From 3bd64f21a314973c5927505df26712af7a6be296 Mon Sep 17 00:00:00 2001 From: Ali-Tegna <31090457+FreakyAli@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:21:31 +0530 Subject: [PATCH 1/5] feat: Resilion.Chaos and delegate-based event handlers --- .github/workflows/release-nuget.yml | 2 +- CHANGELOG.md | 62 ++++ README.md | 55 ++-- Resilion.sln | 82 ++++-- docs/chaos.md | 195 +++++++++++++ docs/comparison-with-polly.md | 38 ++- docs/configuration.md | 10 +- docs/future-plans.md | 265 ++++++++++-------- docs/installation.md | 7 +- docs/migration-from-polly.md | 29 +- docs/troubleshooting.md | 24 +- docs/verification.md | 16 +- samples/Resilion.Samples/Program.cs | 25 +- .../Samples/HedgingActionGeneratorSample.cs | 5 +- .../Samples/RateLimiterSample.cs | 5 +- src/Resilion.Chaos/ChaosBehaviorExtensions.cs | 33 +++ src/Resilion.Chaos/ChaosBehaviorStrategy.cs | 56 ++++ .../ChaosBehaviorStrategyOptions.cs | 37 +++ src/Resilion.Chaos/ChaosFaultExtensions.cs | 66 +++++ src/Resilion.Chaos/ChaosFaultStrategy.cs | 82 ++++++ .../ChaosFaultStrategyOptions.cs | 46 +++ src/Resilion.Chaos/ChaosLatencyExtensions.cs | 62 ++++ src/Resilion.Chaos/ChaosLatencyStrategy.cs | 79 ++++++ .../ChaosLatencyStrategyOptions.cs | 41 +++ src/Resilion.Chaos/ChaosOutcomeExtensions.cs | 46 +++ src/Resilion.Chaos/ChaosOutcomeStrategy.cs | 68 +++++ .../ChaosOutcomeStrategyOptions.cs | 43 +++ src/Resilion.Chaos/ChaosStrategyOptions.cs | 119 ++++++++ src/Resilion.Chaos/PublicAPI.Shipped.txt | 1 + src/Resilion.Chaos/PublicAPI.Unshipped.txt | 85 ++++++ src/Resilion.Chaos/Resilion.Chaos.csproj | 18 ++ .../PublicAPI.Unshipped.txt | 4 +- .../RateLimiterStrategy.cs | 8 +- .../CircuitBreakerStateMachine.cs | 24 +- src/Resilion/Fallback/FallbackStrategy.cs | 8 +- src/Resilion/Hedging/HedgingStrategy.cs | 8 +- src/Resilion/Internal/OrderingValidator.cs | 23 ++ src/Resilion/Internal/StrategyType.cs | 4 + src/Resilion/PublicAPI.Shipped.txt | 4 - src/Resilion/PublicAPI.Unshipped.txt | 1 + src/Resilion/ResilienceEventHandler.cs | 146 +++++----- src/Resilion/Resilion.csproj | 1 + src/Resilion/Retry/RetryStrategy.cs | 16 +- src/Resilion/Telemetry/ResilionTelemetry.cs | 14 + src/Resilion/Timeout/TimeoutStrategy.cs | 8 +- .../ChaosBehaviorStrategyTests.cs | 146 ++++++++++ .../ChaosFaultStrategyTests.cs | 205 ++++++++++++++ .../ChaosLatencyStrategyTests.cs | 175 ++++++++++++ .../ChaosOrderingTests.cs | 47 ++++ .../ChaosOutcomeStrategyTests.cs | 149 ++++++++++ .../ChaosTelemetryTests.cs | 192 +++++++++++++ .../Resilion.Chaos.Tests.csproj | 13 + .../TypedStrategyBindingTests.cs | 7 +- .../CircuitBreakerStrategyTests.cs | 5 +- .../CircuitBreakerTypedStrategyTests.cs | 18 +- .../Fallback/FallbackStrategyTests.cs | 6 +- .../Hedging/HedgingStrategyTests.cs | 5 +- .../Integration/CompositionTests.cs | 6 +- .../Resilion.Tests/OrderingValidatorTests.cs | 86 ++++++ .../RateLimiter/RateLimiterStrategyTests.cs | 6 +- .../ResilienceEventHandlerTests.cs | 236 ++++++++++++++-- .../Retry/RetryStrategyTests.cs | 12 +- .../Timeout/TimeoutStrategyTests.cs | 2 +- 63 files changed, 2894 insertions(+), 393 deletions(-) create mode 100644 docs/chaos.md create mode 100644 src/Resilion.Chaos/ChaosBehaviorExtensions.cs create mode 100644 src/Resilion.Chaos/ChaosBehaviorStrategy.cs create mode 100644 src/Resilion.Chaos/ChaosBehaviorStrategyOptions.cs create mode 100644 src/Resilion.Chaos/ChaosFaultExtensions.cs create mode 100644 src/Resilion.Chaos/ChaosFaultStrategy.cs create mode 100644 src/Resilion.Chaos/ChaosFaultStrategyOptions.cs create mode 100644 src/Resilion.Chaos/ChaosLatencyExtensions.cs create mode 100644 src/Resilion.Chaos/ChaosLatencyStrategy.cs create mode 100644 src/Resilion.Chaos/ChaosLatencyStrategyOptions.cs create mode 100644 src/Resilion.Chaos/ChaosOutcomeExtensions.cs create mode 100644 src/Resilion.Chaos/ChaosOutcomeStrategy.cs create mode 100644 src/Resilion.Chaos/ChaosOutcomeStrategyOptions.cs create mode 100644 src/Resilion.Chaos/ChaosStrategyOptions.cs create mode 100644 src/Resilion.Chaos/PublicAPI.Shipped.txt create mode 100644 src/Resilion.Chaos/PublicAPI.Unshipped.txt create mode 100644 src/Resilion.Chaos/Resilion.Chaos.csproj create mode 100644 tests/Resilion.Chaos.Tests/ChaosBehaviorStrategyTests.cs create mode 100644 tests/Resilion.Chaos.Tests/ChaosFaultStrategyTests.cs create mode 100644 tests/Resilion.Chaos.Tests/ChaosLatencyStrategyTests.cs create mode 100644 tests/Resilion.Chaos.Tests/ChaosOrderingTests.cs create mode 100644 tests/Resilion.Chaos.Tests/ChaosOutcomeStrategyTests.cs create mode 100644 tests/Resilion.Chaos.Tests/ChaosTelemetryTests.cs create mode 100644 tests/Resilion.Chaos.Tests/Resilion.Chaos.Tests.csproj diff --git a/.github/workflows/release-nuget.yml b/.github/workflows/release-nuget.yml index e90504d..dbde719 100644 --- a/.github/workflows/release-nuget.yml +++ b/.github/workflows/release-nuget.yml @@ -58,7 +58,7 @@ jobs: # Data-driven so adding a package is a one-line change here and nowhere else. # The previous form hardcoded three pack commands AND a `-ne 3` assertion below, so a # fourth package would have broken every release until both were found and edited. - PROJECTS=(Resilion Resilion.Extensions Resilion.RateLimiting Resilion.Http) + PROJECTS=(Resilion Resilion.Extensions Resilion.RateLimiting Resilion.Http Resilion.Chaos) for p in "${PROJECTS[@]}"; do dotnet pack "src/$p/$p.csproj" -c Release --no-build \ -p:Version="${VERSION}" -p:ContinuousIntegrationBuild=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a80e9..4834627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `AddResiliencePipeline` overload with `TryAddKeyedSingleton`, so a duplicate call does not add a second descriptor. **Incompatible with reload by construction** (a keyed singleton resolves once for the container's lifetime), which is documented on both sides and pinned by a test +- **`Resilion.Chaos` — fault, outcome, latency and behavior injection.** Verify that your + resilience configuration actually does something, by making the failures happen on purpose. + `AddChaosFault`, `AddChaosOutcome`, `AddChaosLatency` and `AddChaosBehavior` on both builders. + Zero third-party dependencies — it references `Resilion` and nothing else. + See [docs/chaos.md](docs/chaos.md) + - **Chaos must be innermost, and the pipeline warns when it isn't.** A chaos strategy outside a + retry injects its fault before the retry can observe it, so the retry never sees a failure and + the experiment passes while proving nothing. That is the worst possible failure for a chaos + run — silent and positive — so it is diagnosed rather than left to be discovered. A warning + rather than an error, because stacking several chaos strategies at the end is legitimate + - **`Randomizer` is the supported test seam**, not polish: injection is a coin flip, so without + it every assertion about chaos would be statistical. `() => 0.0` always injects, `() => 1.0` + never does, and the randomizer is consulted on every decision with no short-circuit so those + hold at any configured rate + - `AddChaosLatency` takes `builder.TimeProvider`, so `FakeTimeProvider` drives injected delays. + The synchronous path waits on `CancellationToken.WaitHandle` and cannot be driven by a fake + clock — the same limitation as synchronous retry delays, documented on both + - **Generators always take precedence over their static counterparts**, decided in one shared + place so the four strategies cannot drift apart. A bad static rate throws at `Add*` time where + the stack trace points at your configuration; a generator returning an out-of-range value is + clamped, because it runs mid-execution where throwing would be indistinguishable from an + injected fault + - **`AddChaosOutcome` is typed-only on purpose.** A `Strategy` cannot run in a pipeline + of a different result type, so an untyped overload would be a trap with no correct use + - Injections increment `resilion.chaos.injections` on the existing `Resilion` meter — one + `.AddMeter("Resilion")` still covers everything — tagged with the new public + `ResilionTelemetry.ChaosStrategyTag`. The counter counts injections, not decisions: a + `FaultGenerator` returning `null` skips injection and does not increment + - **The defaults inject.** `Enabled` is `true` and `InjectionRate` is `0.001`. The docs lead with + `EnabledGenerator = _ => !env.IsProduction()` rather than mentioning it in passing + - `Resilion.Extensions.Configuration` — five bindable DTOs (`RetryStrategyConfiguration`, `RetryDelayConfiguration`, `TimeoutStrategyConfiguration`, `CircuitBreakerStrategyConfiguration`, `HedgingStrategyConfiguration`) that project into the real options records with `ToOptions()`. @@ -115,6 +146,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 never observes a reload. Both rules are now written down where they are looked for ### Changed +- **`ResilienceEventHandler` is now a delegate returning `ValueTask`, not a wrapper + struct.** The obvious way to write a callback now compiles: + + ```csharp + OnRetry = e => { logger.LogWarning("Retry {Attempt}", e.AttemptNumber); return default; } + OnRetry = LogRetry; // method groups too + OnRetry = async e => await TrackAsync(e); // and async + ``` + + The struct held either an `Action` or a `Func` behind implicit + conversions, which let a synchronous handler skip the `return default`. But a struct is not a + delegate type, and C# will not convert a lambda to one, so **every** callback had to be written + `new Action(e => ...)` — a bare lambda failed with `CS1660` and a method + group with `CS0428`. The README's own example, and the type's own XML doc example, were both + uncompilable + - **The justification for the wrapper did not survive measurement.** It was documented as + avoiding `ValueTask` wrapping for synchronous callbacks. A synchronous handler returning + `default` allocates **0 B/op**, exactly as an `Action` does, because `ValueTask` is a + struct; only a handler that genuinely suspends allocates, and the wrapper paid that too. It + was buying syntax, not speed, and charging a compiler error for it + - **The synchronous path got faster under a `SynchronizationContext`.** It used to push async + handlers onto the thread pool via `Task.Run` to avoid the classic deadlock. It now clears the + context for the duration of the call instead, so the continuation resumes on the pool while + the handler itself still starts on the calling thread — which means a **synchronous** handler + no longer hops threads at all in WPF, WinForms or legacy ASP.NET. Both behaviours are pinned + by tests that fail if the context is not cleared or if the old `Task.Run` returns + - This removes `ResilienceEventHandler.HasHandler` and its two implicit conversion + operators from the public surface. The 17 callback properties keep their spelling — + `ResilienceEventHandler?` — but the `?` now means a nullable reference rather than + `Nullable` over a struct, so `.HasValue` and `.Value` no longer apply. Pattern matching + (`is { } handler`) works unchanged either way - `Resilion.Extensions` no longer references `Microsoft.Extensions.Logging.Abstractions`. It had been declared since the package was created and used by nothing — no `ILogger` in any source file — so consumers restored it for nothing and the dependency implied a logging integration diff --git a/README.md b/README.md index ea95855..e4a25c0 100644 --- a/README.md +++ b/README.md @@ -62,17 +62,20 @@ Every built-in strategy has a **real synchronous implementation** on its own exe on WinForms/WPF UI threads, ASP.NET Framework, and anywhere blocking on an async call risks deadlock. -Four limits, so you can tell whether this covers your case: - -- An **async** fallback factory (`FallbackActionAsync`) or an **async** event handler - (`OnRetry`, `OnTimeout`, …) is still async work being run from a sync call. When a - `SynchronizationContext` is present, Resilion hops to the thread pool to run it — that hop is - what avoids the deadlock, but it is sync-over-async. Supply *synchronous* handlers - (`Action`, which is the default shape) and this never happens. +Five limits, so you can tell whether this covers your case: + +- An **async** fallback factory (`FallbackActionAsync`) is still async work run from a sync call. + When a `SynchronizationContext` is present, Resilion hops to the thread pool to run it — that + hop is what avoids the deadlock, but it is sync-over-async. +- An **async** event handler (`OnRetry`, `OnTimeout`, …) is also async work run from a sync call, + but it does **not** hop threads: the `SynchronizationContext` is cleared for the duration of the + call, so the handler starts on the calling thread and only its continuations go to the pool. A + handler that returns `default` therefore costs nothing extra here. - **Sync hedging is sequential-only.** Parallel and latency modes need real concurrency, so `Execute` throws for them rather than silently degrading. -- **Sync retry delays are not driven by `TimeProvider`**, so they can't be advanced by - `FakeTimeProvider` in tests ([future-plans #9](docs/future-plans.md)). +- **Sync delays are not driven by `TimeProvider`** — retry backoff and injected chaos latency + alike — so they can't be advanced by `FakeTimeProvider` in tests + ([future-plans #9](docs/future-plans.md)). - A **custom** strategy must override `Execute`; the base implementation throws under a `SynchronizationContext` rather than risking a deadlock on your behalf. @@ -100,31 +103,21 @@ var pipeline = Pipeline.Create(b => b.AddRetry( ``` ### Simple Callbacks -Assign callbacks directly. No `ValueTask` wrapping for synchronous logging: - -```csharp -Action onRetry = context => - logger.LogWarning("Retry attempt {Attempt}", context.AttemptNumber); - -new RetryStrategyOptions { OnRetry = onRetry }; -``` - -The handler's type has to be stated. `ResilienceEventHandler` converts implicitly from -`Action` and `Func`, but it is not itself a delegate type, so C# will not -convert a bare lambda (`CS1660`) or a method group (`CS0428`) to it. A local, an inline cast, or -`new Action(...)` all work: +Assign a lambda, a method group, or an async handler — one property takes all three: ```csharp new RetryStrategyOptions { - OnRetry = new Action(context => - logger.LogWarning("Retry attempt {Attempt}", context.AttemptNumber)), + // Synchronous: return default. Allocates nothing — a completed ValueTask is a struct. + OnRetry = e => { logger.LogWarning("Retry attempt {Attempt}", e.AttemptNumber); return default; }, }; + +new RetryStrategyOptions { OnRetry = LogRetry }; // method group +new RetryStrategyOptions { OnRetry = async e => await Track(e) }; // async ``` -An async handler is the same shape with `Func`. The saving over always-async -callbacks is real — a synchronous handler runs inline, with no `ValueTask` wrapping — but it costs -you the one-liner. +A synchronous handler runs inline on the calling thread, with no thread hop even when a +`SynchronizationContext` is installed. ### Composable Pipelines Combine pre-built pipelines into larger ones: @@ -167,6 +160,11 @@ dotnet add package Resilion.RateLimiting dotnet add package Resilion.Http ``` +### Chaos Engineering +```bash +dotnet add package Resilion.Chaos +``` + ## Packages & Dependencies | Package | Purpose | Dependencies | @@ -175,6 +173,7 @@ dotnet add package Resilion.Http | **Resilion.Extensions** | DI registration, pipeline registry, reload, `IConfiguration` binding | Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Options | | **Resilion.RateLimiting** | Rate limiting strategy with multiple algorithms | System.Threading.RateLimiting | | **Resilion.Http** | `HttpClient` / `IHttpClientFactory` resilience handlers | Microsoft.Extensions.Http, Resilion.Extensions, Resilion.RateLimiting | +| **Resilion.Chaos** | Fault, outcome, latency and behavior injection for resilience testing | None | ## Strategies @@ -445,10 +444,12 @@ src/ Resilion.Extensions/ DI, pipeline registry, reload, configuration binding Resilion.RateLimiting/ Rate limiting strategy Resilion.Http/ HttpClient integration (DelegatingHandler) + Resilion.Chaos/ Chaos engineering strategies (zero dependencies) tests/ Resilion.Tests/ Core strategy and pipeline tests Resilion.Extensions.Tests/ DI and telemetry tests Resilion.Http.Tests/ HttpClient handler tests + Resilion.Chaos.Tests/ Chaos strategy tests benchmarks/ Resilion.Benchmarks/ Performance benchmarks (BenchmarkDotNet) samples/ diff --git a/Resilion.sln b/Resilion.sln index 7f538e7..e8d30fa 100644 --- a/Resilion.sln +++ b/Resilion.sln @@ -29,6 +29,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Benchmarks", "benchmarks\Resilion.Benchmarks\Resilion.Benchmarks.csproj", "{7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Chaos", "src\Resilion.Chaos\Resilion.Chaos.csproj", "{815EED69-C1FE-49BF-B1F0-359468B16213}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Chaos.Tests", "tests\Resilion.Chaos.Tests\Resilion.Chaos.Tests.csproj", "{03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -75,6 +79,18 @@ Global {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 + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x64.Build.0 = Debug|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x86.Build.0 = Debug|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|Any CPU.Build.0 = Release|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|x64.ActiveCfg = Release|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|x64.Build.0 = Release|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|x86.ActiveCfg = Release|Any CPU + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.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 @@ -99,6 +115,18 @@ Global {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 + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x64.ActiveCfg = Debug|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x64.Build.0 = Debug|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x86.ActiveCfg = Debug|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x86.Build.0 = Debug|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|Any CPU.Build.0 = Release|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|x64.ActiveCfg = Release|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|x64.Build.0 = Release|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|x86.ActiveCfg = Release|Any CPU + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.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 @@ -123,30 +151,30 @@ Global {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 - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x64.ActiveCfg = Debug|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x64.Build.0 = Debug|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x86.ActiveCfg = Debug|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Debug|x86.Build.0 = Debug|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|Any CPU.Build.0 = Release|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|x64.ActiveCfg = Release|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|x64.Build.0 = Release|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|x86.ActiveCfg = Release|Any CPU - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}.Release|x86.Build.0 = Release|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x64.ActiveCfg = Debug|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x64.Build.0 = Debug|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x86.ActiveCfg = Debug|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Debug|x86.Build.0 = Debug|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|Any CPU.Build.0 = Release|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|x64.ActiveCfg = Release|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|x64.Build.0 = Release|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|x86.ActiveCfg = Release|Any CPU - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}.Release|x86.Build.0 = Release|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Debug|Any CPU.Build.0 = Debug|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Debug|x64.ActiveCfg = Debug|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Debug|x64.Build.0 = Debug|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Debug|x86.ActiveCfg = Debug|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Debug|x86.Build.0 = Debug|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Release|Any CPU.ActiveCfg = Release|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Release|Any CPU.Build.0 = Release|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Release|x64.ActiveCfg = Release|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Release|x64.Build.0 = Release|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Release|x86.ActiveCfg = Release|Any CPU + {815EED69-C1FE-49BF-B1F0-359468B16213}.Release|x86.Build.0 = Release|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Debug|x64.ActiveCfg = Debug|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Debug|x64.Build.0 = Debug|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Debug|x86.ActiveCfg = Debug|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Debug|x86.Build.0 = Debug|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Release|Any CPU.Build.0 = Release|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Release|x64.ActiveCfg = Release|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Release|x64.Build.0 = Release|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Release|x86.ActiveCfg = Release|Any CPU + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -155,11 +183,13 @@ Global {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} + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35} = {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} + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46} = {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} - {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {815EED69-C1FE-49BF-B1F0-359468B16213} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {03D1CF1D-78E9-4A0F-8D9E-7F7B39C9A7D3} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/docs/chaos.md b/docs/chaos.md new file mode 100644 index 0000000..68123eb --- /dev/null +++ b/docs/chaos.md @@ -0,0 +1,195 @@ +# Chaos engineering + +Deliberately inject faults, results, latency and custom behaviour into a pipeline so you can find +out whether your resilience configuration actually works. Lives in the `Resilion.Chaos` package. + +```text +dotnet add package Resilion.Chaos +``` + +Zero third-party dependencies — it references `Resilion` and nothing else. + +## Read this first: the production guard + +**The defaults inject.** `Enabled` is `true` and `InjectionRate` is `0.001`, so a chaos strategy +added to a pipeline starts injecting on roughly one call in a thousand the moment it is built. +That is the right default for a package you add on purpose, and the wrong thing to discover by +accident. + +Gate it on the environment, every time: + +```csharp +using Resilion; +using Resilion.Chaos; + +var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddChaosFault(new ChaosFaultStrategyOptions + { + EnabledGenerator = _ => !env.IsProduction(), // <- the guard + InjectionRate = 0.05, + FaultGenerator = _ => new HttpRequestException("chaos: simulated transport failure"), + })); +``` + +`EnabledGenerator` is evaluated per call, so it also works with a feature flag that flips at +runtime. + +## The four strategies + +| Strategy | Injects | Use it to test | +|---|---|---| +| `AddChaosFault` | an exception | retry and circuit breaker exception predicates | +| `AddChaosOutcome` | a result *or* a failure | result-based predicates — a 503, an empty payload | +| `AddChaosLatency` | a delay | timeouts, and hedging that should overtake a slow call | +| `AddChaosBehavior` | anything you write | killing a dependency, filling a disk, dropping a connection | + +`AddChaosOutcome` is typed-only — it has no `PipelineBuilder` overload. A `Strategy` added +to a pipeline executing a different result type cannot run, so an untyped overload would be a trap +with no correct use. The other three are result-agnostic and work on both builders. + +## Chaos goes innermost + +**Add chaos last.** A chaos strategy placed outside a retry injects its fault before the retry can +see it, so the retry never observes a failure: + +```csharp +// Wrong — the fault is thrown before Retry is reached, so Retry never sees it and the +// experiment passes while proving nothing. +Pipeline.Create(b => b + .AddChaosFault(options) + .AddRetry(retryOptions)); + +// Right — Retry wraps the chaos, sees the injected fault, and retries. +Pipeline.Create(b => b + .AddRetry(retryOptions) + .AddChaosFault(options)); +``` + +Getting this wrong is worse than a normal misconfiguration because it fails *silently and +positively*: the run completes, nothing errors, and you conclude your resilience works. + +Resilion emits an ordering **warning** when a chaos strategy is not innermost. It is a warning +rather than an error because stacking several chaos strategies at the end is legitimate, and a +deliberate mid-pipeline injection between an outer and an inner pipeline is conceivable. + +## Testing chaos: the `Randomizer` seam + +Injection is a coin flip, which would make every test statistical. `Randomizer` replaces the source +of randomness so it isn't: + +```csharp +Randomizer = () => 0.0 // always inject +Randomizer = () => 1.0 // never inject +``` + +It is consulted on **every** decision with no short-circuit, so those two values hold at any +configured rate. This is the supported way to test a chaos configuration, and it is the same idea +as `PipelineBuilder.TimeProvider` for time — without it, chaos strategies would be as untestable as +a strategy that reads `TimeProvider.System` directly. + +`AddChaosLatency` takes `builder.TimeProvider`, so `FakeTimeProvider` drives injected delays: + +```csharp +var time = new FakeTimeProvider(); +var pipeline = Pipeline.Create(b => +{ + b.TimeProvider = time; + b.AddChaosLatency(1.0, TimeSpan.FromSeconds(30)); +}); + +var task = pipeline.ExecuteAsync(ct => CallAsync(ct)).AsTask(); +time.Advance(TimeSpan.FromSeconds(30)); +await task; +``` + +## Shared options + +Every chaos strategy inherits these: + +| Option | Default | Meaning | +|---|---|---| +| `InjectionRate` | `0.001` | probability of injecting, `[0, 1]` | +| `InjectionRateGenerator` | `null` | per-call rate; **overrides** `InjectionRate` | +| `Enabled` | `true` | whether the strategy is active at all | +| `EnabledGenerator` | `null` | per-call check; **overrides** `Enabled` | +| `Randomizer` | `Random.Shared` | source of the injection decision | + +**A generator always wins over its static counterpart**, uniformly across all four strategies — the +decision is made in one place so they cannot drift apart. + +Out-of-range values are handled differently depending on when they are known. A bad static +`InjectionRate` **throws at `Add*` time**, where the stack trace points at your configuration. A +generator returning something out of range is **clamped**, because it runs mid-execution where an +exception would be indistinguishable from an injected fault. + +## Prefer `FaultGenerator` over `Fault` + +```csharp +// Preferred +FaultGenerator = _ => new HttpRequestException("chaos"), + +// Works, but degrades +Fault = new HttpRequestException("chaos"), +``` + +`Outcome.FromException` captures with `ExceptionDispatchInfo`, so rethrowing one shared instance +appends to its stack trace on every injection and the traces get progressively less readable. + +A A `FaultGenerator` returning `null` skips injection for that call, which lets a generator filter on +the context. The injection counter is not incremented in that case — it counts injections, not +decisions. + +## Custom behaviour + +```csharp +b.AddChaosBehavior(new ChaosBehaviorStrategyOptions +{ + EnabledGenerator = _ => !env.IsProduction(), + InjectionRate = 0.01, + Behavior = _ => { proxy.DropConnections(); return default; }, +}); +``` + +`Behavior` is a `ResilienceEventHandler` — a delegate returning `ValueTask`, +so a synchronous payload returns `default` and runs inline, and an `async` payload is awaited. + +An exception from `Behavior` is **not** caught — it propagates into the pipeline, which is usually +the point. + +## Telemetry + +Injections increment `resilion.chaos.injections` on the existing `Resilion` meter, so a single +`.AddMeter("Resilion")` picks them up alongside everything else. + +| Tag | Value | +|---|---| +| `pipeline.name` | the pipeline's name | +| `operation.key` | the execution's operation key | +| `chaos.strategy` | `fault`, `outcome`, `latency` or `behavior` | + +The tag key is available as `ResilionTelemetry.ChaosStrategyTag`. See +[telemetry.md](telemetry.md). + +## Synchronous execution + +All four strategies implement real synchronous paths. One caveat: injected latency on the sync path +waits on `CancellationToken.WaitHandle`, which has no `TimeProvider` equivalent, so a +`FakeTimeProvider` cannot drive it — the same limitation as synchronous retry delays. Test sync +latency with a short real delay, or test the async path. + +## Known limitations + +- **Injection rates cannot come from configuration.** The options are set in code; binding them + from `appsettings.json` is tracked as [#68](future-plans.md). +- **No chaos hook on `AddStandardResilienceHandler`.** The standard HTTP pipeline is fixed, so + injecting into it means composing the pipeline yourself with `AddResilienceHandler`. Tracked as + [#69](future-plans.md). +- **Synchronous injected latency is not fake-clock testable**, as described above. Tracked as + [#9](future-plans.md). + +## See also + +- [pipelines.md](pipelines.md) — strategy ordering in general +- [testing.md](testing.md) — `FakeTimeProvider` and telemetry capture +- [telemetry.md](telemetry.md) — the full instrument list diff --git a/docs/comparison-with-polly.md b/docs/comparison-with-polly.md index b805fb4..03bce01 100644 --- a/docs/comparison-with-polly.md +++ b/docs/comparison-with-polly.md @@ -20,11 +20,13 @@ can't safely call `.GetAwaiter().GetResult()` on an async path without risking d Stated precisely, because this is the reason some people choose Resilion and they deserve the exact shape of it: the *strategies* are synchronous, but async work you hand them is still async. -An async fallback factory or an async event handler is run via a thread-pool hop when a -`SynchronizationContext` is present — deadlock-safe, but sync-over-async. Sync hedging is -sequential-only and throws for parallel/latency modes. Sync retry delays bypass `TimeProvider`, so -they aren't fake-clock testable. Custom strategies must override `Execute` themselves. See the -"Sync and Async" section of the [README](../README.md) for the list. +An async fallback factory is run via a thread-pool hop when a `SynchronizationContext` is present +— deadlock-safe, but sync-over-async. An async event handler avoids the hop: the context is +cleared for the duration of the call instead, so the handler starts on the calling thread. Sync +hedging is sequential-only and throws for parallel/latency modes. Sync delays — retry backoff and +injected chaos latency — bypass `TimeProvider`, so they aren't fake-clock testable. Custom +strategies must override `Execute` themselves. See the "Sync and Async" section of the +[README](../README.md) for the list. **`RetryDelay` as a discriminated union.** Polly's retry options have `Delay`, `BackoffType`, `UseJitter`, and `DelayGenerator` as independent properties — nothing stops you from setting @@ -32,18 +34,15 @@ they aren't fake-clock testable. Custom strategies must override `Execute` thems `RetryDelay.Constant/Linear/Exponential/Custom` are mutually exclusive by construction. There's exactly one way to configure the delay strategy, and the compiler enforces it. -**Sync callbacks don't pay for `ValueTask`.** Polly's callbacks (`OnRetry`, `OnOpened`, ...) always -return `ValueTask`, even for a callback that just increments a counter or writes a log line — the -90% case. Resilion's `ResilienceEventHandler` accepts a plain `Action` *or* an async -`Func` via implicit conversion, so the common synchronous case runs inline with -no wrapping. - -Honest caveat on the ergonomics, as opposed to the cost: because `ResilienceEventHandler` is -a wrapper rather than a delegate type, you must name the delegate — `new Action(ctx => ...)`, -an inline cast, or a typed local. A bare lambda or a method group will not convert. Polly's -plain-delegate properties take a lambda directly, so Polly wins on brevity here while Resilion wins -on not allocating a `ValueTask` for a log line. Tracked for a future version — see -[future-plans.md](future-plans.md). +**Callbacks take a lambda, a method group, or an async handler.** One property, three shapes: +`OnRetry = e => { log(e); return default; }`, `OnRetry = LogRetry`, or +`OnRetry = async e => await TrackAsync(e)`. This is the same shape Polly uses, and Resilion used +to differ: `ResilienceEventHandler` was a wrapper struct accepting an `Action`, so a +synchronous handler could skip the `return default`. It was dropped before 1.0 because a struct is +not a delegate type, so C# refused to convert a bare lambda to it at all — every callback had to be +written `new Action(e => ...)`. The wrapper was also documented as avoiding +`ValueTask` wrapping; measured, both shapes allocate 0 B/op for a synchronous handler, so it was +charging a compiler error for syntax rather than buying speed. **Simpler API surface.** One static factory (`Pipeline.Create`), one builder per pipeline kind, options classes that read the same way every time. No mandatory `PredicateBuilder` (there @@ -62,9 +61,6 @@ routing, works with synchronous `HttpClient.Send`, and binds its standard option `IConfiguration`. Resilion does none of those yet; `Retry-After` is the one most likely to matter, since both libraries retry 429 by default and Resilion does so on blind exponential backoff. -**Chaos engineering.** Polly ships Simmy — fault, outcome, latency, and behavior injection for -resilience testing in non-production environments. Resilion has no equivalent. - **A dedicated testing package.** Polly has patterns and (community) packages for asserting on pipeline behavior in tests. Resilion doesn't have a `Resilion.Testing` package yet — you write tests the way this repo's own test suite does (see [docs/testing.md](testing.md)), which works @@ -85,6 +81,7 @@ These were listed here as reasons to choose Polly and no longer are: | Dynamic reload via `IOptionsMonitor` | `AddResiliencePipeline` rebuilds on options change | | A way to observe circuit state | `CircuitBreakerStateProvider` | | `IConfiguration` binding for strategy options | `Resilion.Extensions.Configuration` DTOs with `ToOptions()` | +| Chaos engineering (Polly's Simmy) | `Resilion.Chaos` — fault, outcome, latency and behavior injection | ## Roadmap to close the gap @@ -93,7 +90,6 @@ Every item above that Resilion doesn't have yet is tracked with a concrete desig | Feature | Priority | Target | future-plans.md item | |---------|----------|--------|----------------------| -| `Resilion.Chaos` — chaos engineering | Medium | unscheduled | #40 | | Telemetry enrichment | Lower | unscheduled | #45 | **None of these ship in 1.0.** 1.0 is scoped to making what the library already claims actually diff --git a/docs/configuration.md b/docs/configuration.md index 5bcde1a..6c45a96 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,14 +98,14 @@ services.AddResiliencePipeline("api", (b, c) => b.AddRetry(c.ToOptions() with { ShouldHandle = ex => ex is HttpRequestException, - OnRetry = new Action(e => - logger.LogWarning("Retry {Attempt} after {Delay}", e.AttemptNumber, e.RetryDelay)), + OnRetry = e => + { + logger.LogWarning("Retry {Attempt} after {Delay}", e.AttemptNumber, e.RetryDelay); + return default; + }, })); ``` -> The `new Action(...)` wrapper is required — a bare lambda does not compile -> against `ResilienceEventHandler`. See [troubleshooting.md](troubleshooting.md). - `ToOptions()` leaves every delegate `null`, so each strategy's own default predicate applies untouched when you set none. diff --git a/docs/future-plans.md b/docs/future-plans.md index 612f136..153a01a 100644 --- a/docs/future-plans.md +++ b/docs/future-plans.md @@ -35,19 +35,20 @@ entry without a row here — is a bug in this file; see CONTRIBUTING.md § Futur | # | Item | Priority | Type | Target | Effort | Status | |---|------|----------|------|--------|--------|--------| -| 62 | Event handler properties reject a bare lambda | P1 | Improvement | post-1.0 | Medium | Open | | 63 | Outcome-aware retry delay (blocks Retry-After) | P1 | Feature | post-1.0 | Medium | Open | | 4 | Per-call delegate allocation in pipeline chain | P1 | Improvement | post-1.0 | High | Open | | 64 | Composable event handlers | P2 | Improvement | unscheduled | Medium | Open | | 65 | Registry factory with IServiceProvider | P2 | Improvement | unscheduled | Low | Open | | 66 | Synchronous ExecuteOutcome | P3 | Feature | unscheduled | Medium | Open | | 27 | Retry typed/untyped strategy duplication (4 loops) | P1 | Improvement | post-1.0 | Medium | Open | -| 30 | FallbackAction/ResilienceEventHandler Task.Run missing token | P1 | Fix | post-1.0 | Low | Open | -| 9 | Sync retry delay bypasses TimeProvider (untestable) | P1 | Fix | post-1.0 | Low | Open | +| 70 | FallbackAction Task.Run missing CancellationToken | P1 | Fix | post-1.0 | Low | Open | +| 71 | AddResiliencePipeline emits IL2091 under Native AOT | P2 | Improvement | post-1.0 | Low | Open | +| 9 | Synchronous delays bypass TimeProvider (untestable) | P1 | Fix | post-1.0 | Low | Open | | 54 | Evaluate multi-targeting net9.0 for System.Threading.Lock | P1 | Improvement | post-1.0 | Medium | Open | | 45 | Telemetry enrichment (MeteringEnricher, listeners) | P2 | Feature | unscheduled | High | Open | +| 68 | Chaos strategy options not bindable from IConfiguration | P2 | Feature | unscheduled | Low | Open | +| 69 | No chaos injection on the standard HTTP handler | P3 | Feature | unscheduled | Medium | Open | | 67 | Registry caches a failed pipeline build until invalidated | P2 | Improvement | unscheduled | Low | Open | -| 40 | Resilion.Chaos — chaos engineering | P2 | Feature | unscheduled | High | Open | | 11 | Hedging: O(N²) WhenAny continuation registration (needs #54) | P2 | Improvement | unscheduled | Medium | Open | | 5 | CancellationTokenSource pooling for Timeout | P2 | Improvement | unscheduled | Medium | Open | | 48 | Benchmark CI on PRs | P3 | Infrastructure | unscheduled | Low | Open | @@ -63,54 +64,6 @@ already claims untrue, or produces a silently wrong answer. ## P1 — Next (post-1.0) -### 62. Event Handler Properties Reject a Bare Lambda - -**Status:** Open -**Type:** Improvement -**Target:** post-1.0 -**Verified:** 2026-10-23 — compiled `new RetryStrategyOptions { OnRetry = ctx => ... }` against the -built `Resilion.dll`: `error CS1660`. A method group gives `error CS0428`. A typed local, an inline -cast, and `new Action(...)` all compile. - -**Why** - -`ResilienceEventHandler` is a wrapper struct with implicit conversions from -`Action` and `Func`. Implicit conversion does not help the compiler -convert a *lambda*, because a lambda needs a delegate target type and this is not one. So the -obvious syntax does not compile: - -```csharp -// CS1660 -new RetryStrategyOptions { OnRetry = ctx => logger.LogWarning("retry {N}", ctx.AttemptNumber) }; - -// CS0428 -new RetryStrategyOptions { OnRetry = LogRetry }; -``` - -and the caller must write `new Action(ctx => ...)`, an inline cast, or a typed -local instead. - -This matters more than a normal papercut because sync callback ergonomics is one of the five -reasons `comparison-with-polly.md` gives for choosing this library, and Polly's plain-delegate -properties *do* take a lambda. The README's own example was uncompilable from 1.0 until it was -corrected — nobody had run it. - -**Possible fixes** - -1. **Add `Action`-typed sibling properties** (`OnRetryAction` and friends) alongside the - existing ones, with the options record reconciling them. Purely additive, works on a frozen - surface, but doubles the callback surface and creates two ways to say one thing — which is - exactly what `RetryDelay`'s closed union exists to avoid. -2. **Make the options properties plain `Action`** and move the sync-or-async union behind a - separate `...Async` property per callback. Cleanest to use, but a breaking change to every - options record, so 2.0 only. -3. **Document it and accept it.** Current state. The implicit conversions still earn their keep — - a synchronous handler runs inline with no `ValueTask` allocation — the cost is brevity, not - performance. - -Decide between (1) and (2) before 2.0 rather than drifting into (3) permanently. - ---- ### 27. Retry Typed/Non-Generic Strategy Duplication @@ -156,47 +109,101 @@ and get that approved separately. --- -### 30. FallbackAction/ResilienceEventHandler `Task.Run` Missing `CancellationToken` + +### 71. `AddResiliencePipeline` Emits Trim Warnings Under Native AOT + +**Status:** Open +**Type:** Improvement +**Target:** post-1.0 +**Verified:** 2026-10-25 — `dotnet publish samples/Resilion.Samples/Resilion.Samples.csproj -c Release +-r osx-arm64 -p:PublishAot=true` → two `IL2091` warnings, at +`ResilionServiceCollectionExtensions.cs:367` and `:402`. + +**Why** + +`MonitoredPipelineConfigurator` and `MonitoredTypedPipelineConfigurator` +pass their `TOptions` to `IOptionsMonitor`, which annotates its type parameter with +`DynamicallyAccessedMemberTypes.PublicParameterlessConstructor`. Ours carries no matching +annotation, so the trimmer cannot prove the constructor survives. + +**This does not fail CI**, because `IL2091` is not in the `/warnaserror` list `test.yml` uses +(`IL2026,IL2070,IL2072,IL3050,IL3053`), and the AOT publish succeeds. It is also consistent with +`Resilion.Extensions` declaring `IsAotCompatible=false` — the package does not claim to be +trim-safe. But an application that AOT-publishes while using the reload overloads sees two +warnings pointing into this library, which is a poor first impression. + +Introduced with the reload overloads in #41 (commit `c4437b4`), not by anything later. + +**Fix** + +Annotate the two type parameters: + +```csharp +internal sealed class MonitoredPipelineConfigurator< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> +``` + +and propagate the annotation up through the four public `AddResiliencePipeline` overloads. + +**Why it was not done before 1.0:** the annotation on a public generic parameter propagates the +requirement to callers, so a caller with its own generic wrapper would start seeing the warning at +its call site. That is source-compatible and can be added in any later version without breaking +anyone, so it did not need to beat the API freeze. Doing it reflexively minutes before the tag was +the worse option. + +**Effort:** Low. + +--- + +### 70. `FallbackAction` `Task.Run` Missing `CancellationToken` **Status:** Open **Type:** Fix **Target:** post-1.0 -**Verified:** 2026-09-30 — `grep -n 'Task.Run' src/Resilion/Fallback/FallbackAction.cs src/Resilion/ResilienceEventHandler.cs` -→ `FallbackAction.cs:64`, `ResilienceEventHandler.cs:93`, neither passing a token. +**Verified:** 2026-10-24 — `grep -rn 'Task.Run' src/Resilion/Fallback/FallbackAction.cs +src/Resilion/ResilienceEventHandler.cs` → one call site, `FallbackAction.cs:64`, passing no token. +The only hit in `ResilienceEventHandler.cs` is prose in a doc comment. **Why** -Both `FallbackAction.Execute` and `ResilienceEventHandler.Invoke` call -`Task.Run(() => …).GetAwaiter().GetResult()` for sync-over-async without passing a -`CancellationToken`. +This is the surviving half of retired #30. `FallbackAction.Execute` calls +`Task.Run(() => factory(context).AsTask()).GetAwaiter().GetResult()` for sync-over-async without +passing a `CancellationToken`. The other half — the same pattern in +`ResilienceEventHandler.Invoke` — disappeared when that type became a delegate (#62): its +replacement clears the `SynchronizationContext` and runs the handler on the calling thread instead +of dispatching to the pool, so there is no `Task.Run` left to pass a token to. **Be accurate about the benefit.** `Task.Run(f, ct)` only declines to *schedule* work when `ct` is -already cancelled. It cannot interrupt a running handler, and the surrounding -`.GetAwaiter().GetResult()` blocks the calling thread regardless. So this is correct hygiene, not a -cancellation fix — which is why it is not a blocker. +already cancelled. It cannot interrupt a running factory, and the surrounding +`.GetAwaiter().GetResult()` blocks the calling thread regardless. Correct hygiene, not a +cancellation fix. **Fix** -Cheap and API-free. `ResilienceEventHandler.Invoke` is internal, so it can take a -`CancellationToken cancellationToken = default` parameter with zero public-API impact, threaded from -`context.CancellationToken` at each internal call site. `FallbackContext` already carries -`Context.CancellationToken`, so `FallbackAction.Execute` needs no signature change at all. +`FallbackContext` already carries `Context.CancellationToken`, so `Execute` needs no +signature change — pass it to `Task.Run`. **Public API: none.** + +Worth considering at the same time: whether `FallbackAction` should adopt #62's approach and clear +the `SynchronizationContext` rather than dispatching to the pool, which would remove the thread hop +for a synchronously-completing fallback factory exactly as it did for handlers. That is the larger +and more useful change of the two. -**Tests must force a `SynchronizationContext`** to reach the `Task.Run` branch — the handler -short-circuits the thread hop entirely when `SynchronizationContext.Current is null`, which is the -common case and the default under xunit. +**A test must force a `SynchronizationContext`** to reach the `Task.Run` branch at all — it is +skipped when `SynchronizationContext.Current is null`, which is the default under xunit. See +`ResilienceEventHandlerTests`' `DeadEndSynchronizationContext` for the pattern. **Effort:** Low. --- -### 9. Sync Retry Delay Bypasses `TimeProvider` (Untestable) +### 9. Synchronous Delays Bypass `TimeProvider` (Untestable) **Status:** Open **Type:** Fix **Target:** post-1.0 -**Verified:** 2026-09-30 — `grep -n 'WaitHandle' src/Resilion/Retry/RetryStrategy.cs` -→ `:170` and `:295`, both `context.CancellationToken.WaitHandle.WaitOne(delay)`. +**Verified:** 2026-10-23 — `grep -rn 'WaitHandle.WaitOne' src/ --include='*.cs' | grep -v /obj/` +→ three sites: `src/Resilion/Retry/RetryStrategy.cs:143`, `:272`, and +`src/Resilion.Chaos/ChaosLatencyStrategy.cs:56`. **Why** @@ -207,17 +214,26 @@ default (`CanBeCanceled == false`) token the runtime uses a static never-signall allocation at all. Across a retry loop the cost amortises to near zero, and the obvious alternatives are worse: `Thread.Sleep` loses cancellation responsiveness, spin-polling burns CPU. -**The real defect** is that `RetryStrategy.cs:170` and `:295` wait via `WaitHandle.WaitOne(delay)`, -**bypassing `_timeProvider` entirely**. `builder.TimeProvider` is this repo's single time-injection -seam — it is how every other timing test uses `FakeTimeProvider`. So **synchronous retry delays cannot -be driven by a fake clock, and no sync retry-timing test can be deterministic.** +**The real defect** is that every synchronous delay in the library waits via +`WaitHandle.WaitOne(delay)`, **bypassing the configured `TimeProvider` entirely**. +`builder.TimeProvider` is this repo's single time-injection seam — it is how every other timing test +uses `FakeTimeProvider`. So **no synchronous delay can be driven by a fake clock, and no sync +timing test can be deterministic.** + +It was previously a retry-only problem. `Resilion.Chaos`'s latency strategy has the same shape and +therefore inherits it: `ChaosLatencyStrategy.ExecuteAsync` delays through `_timeProvider` and is +fully `FakeTimeProvider`-driven, while `Execute` cannot be. `ChaosLatencyStrategyTests` works +around it with a short real delay and a comment pointing here. + +**Each new synchronous delay added to the library adds a site**, so the fix gets slightly more +valuable each time rather than less. **Fix** Needs a sync-waitable abstraction over `TimeProvider` — something that can block the calling thread for a provider-controlled duration while remaining cancellation-responsive. That is a design change, not a patch, which is why this is not a 1.0 item. Do it when someone actually needs deterministic -sync-retry timing coverage. +sync timing coverage, or when a third site appears. **Effort:** Low to write, medium to design well. @@ -507,59 +523,79 @@ exception's stack trace points at the first resolve, which may be long past. (1) with a short backoff is the only one that improves on today without a new hazard, and it is not obviously worth the machinery. Decide only if someone hits it. -### 40. Resilion.Chaos — Chaos Engineering + +### 68. Chaos Strategy Options Are Not Bindable from `IConfiguration` **Status:** Open **Type:** Feature **Target:** unscheduled -**Verified:** 2026-09-30 — no `src/Resilion.Chaos` directory; `grep -n 'Chaos' src/Resilion/Internal/StrategyType.cs` -→ 0 hits. +**Verified:** 2026-10-23 — `ls src/Resilion.Extensions/Configuration/` → five DTOs covering retry, +retry delay, timeout, circuit breaker and hedging. No chaos DTO exists. **Why** -Polly v8.3+ includes Simmy for chaos engineering — fault, outcome, latency and behavior injection. -Table stakes for production resilience testing, and the biggest real feature gap after -`Resilion.Http`. +`Resilion.Extensions.Configuration` makes strategy options bindable from `appsettings.json`, and +chaos is arguably the strategy that most wants it. Its two primary knobs are exactly the kind of +thing an operator tunes per environment without a deploy: -**Design** +```json +{ "Chaos": { "Fault": { "Enabled": true, "InjectionRate": 0.05 } } } +``` + +Today both have to be written in code, so turning injection up during a game day — or off during an +incident — means a rebuild. Combined with #41's reload, a bindable DTO would make the injection +rate adjustable at runtime, which is the natural way to run a chaos experiment. + +The shape follows the existing DTOs exactly: mutable POCOs with parameterless constructors and a +`ToOptions()` projection, with the delegate members (`EnabledGenerator`, `Randomizer`, +`FaultGenerator`, `OutcomeGenerator`, `LatencyGenerator`, `Behavior`) left off and layered on +afterwards with a record `with` expression. + +**One design question to settle first.** The production guard documented in `chaos.md` is +`EnabledGenerator = _ => !env.IsProduction()`, a delegate — so it cannot come from configuration, +and a bindable `Enabled = true` sitting in a config file is a loaded gun pointed at production. A +chaos DTO should probably **not** expose `Enabled` at all, leaving the kill switch in code and +making only the rate configurable. Decide that before writing the type. -New package `Resilion.Chaos` with four strategies: `AddChaosFault`, `AddChaosOutcome`, -`AddChaosLatency`, `AddChaosBehavior`. Shared options on a public abstract record base: -`InjectionRate`, `InjectionRateGenerator`, `Enabled`, `EnabledGenerator` — **generators take -precedence over static values**, resolved in one shared internal helper so the four strategies cannot -diverge. Zero `PackageReference`s (one `ProjectReference` to `Resilion`), preserving the -zero-dependency story. +Blocked on nothing; it is additive. `ChaosBehaviorStrategyOptions` is the one that cannot be +usefully bound, since `Behavior` is required and is a delegate. -**`Randomizer` (`Func?`) is not optional polish.** Without it, chaos strategies are as -untestable as a strategy that ignores `builder.TimeProvider` — it is the injection-decision analogue -of the time seam. Tests set `() => 0.0` (always inject) or `() => 1.0` (never). Document it as the -supported test seam. +--- + +### 69. No Chaos Injection on the Standard HTTP Handler -**Needs `InternalsVisibleTo`** for three things: `Internal.StrategyType.Chaos` (for the tagging -`AddStrategy` overloads), a new internal chaos counter, and `ResilienceEventHandler.Invoke` (to run -the behavior payload). Same pattern as `Resilion.RateLimiting`. +**Status:** Open +**Type:** Feature +**Target:** unscheduled +**Verified:** 2026-10-23 — `grep -n 'Chaos' src/Resilion.Http/*.cs src/Resilion.Http/Internal/*.cs` +→ no matches. `HttpStandardResilienceOptions` exposes `RateLimiter`, `TotalRequestTimeout`, +`Retry`, `CircuitBreaker`, `AttemptTimeout` and `RequestReplay`, with no chaos hook. + +**Why** -**Chaos must be innermost, and the validator should say so.** Chaos outside Retry means retry never -sees the injected fault, so the chaos run *silently tests nothing*. Add `Chaos` to `StrategyType` -(**appended**, to keep existing ordinals stable) and one **warning** in `OrderingValidator` when a -chaos strategy is not in the innermost block. Warning rather than error, because stacking several -chaos strategies innermost is legitimate and a deliberate mid-pipeline injection is conceivable. +`AddStandardResilienceHandler` builds a fixed five-strategy pipeline, so there is no way to inject +chaos into it. That is the pipeline most users will actually run, and therefore the one most worth +testing — "does my standard handler really retry a 503?" is answerable only by making a 503 happen. -Other mechanics worth recording up front: `AddChaosOutcome` is **typed-only** on purpose, because a -typed strategy added to an untyped pipeline is silently skipped by `TypedStrategyComponent`; -`ChaosLatencyStrategy` must take `builder.TimeProvider`; all four must override the sync `Execute` -with real implementations rather than inheriting the base that throws under a -`SynchronizationContext`; and `FaultGenerator` should be documented as preferable to a static `Fault`, -because `Outcome.FromException` captures with `ExceptionDispatchInfo` and rethrowing one instance -repeatedly accumulates stack traces. +**There is a real workaround, which is why this is unscheduled rather than P1:** +`AddResilienceHandler(key, builder => …)` takes a pipeline you compose yourself, so you can +reproduce the standard shape and add chaos innermost. The cost is that you now maintain a copy of +the standard pipeline's defaults, which will drift from ours. -**Production safety:** with defaults of `Enabled = true` and a non-zero `InjectionRate`, lead the docs -with `EnabledGenerator = _ => !env.IsProduction()`. +**Options** -**Release note:** as with #39, `release-nuget.yml`'s hardcoded package count must be data-driven -first. +1. **A `Chaos` property on `HttpStandardResilienceOptions`**, appended innermost when set. Smallest + surface, but it puts a `Resilion.Chaos` type on a `Resilion.Http` options class, which would + make `Resilion.Http` depend on `Resilion.Chaos` — a chaos dependency for every HTTP consumer. + Probably disqualifying on its own. +2. **A separate `AddStandardResilienceHandlerWithChaos` in a `Resilion.Http.Chaos` package**, which + depends on both and keeps the base package clean. More packaging for a testing-only feature. +3. **Expose the standard pipeline's configuration as a reusable builder action**, so a caller can + compose it with their own additions without copying the defaults. Solves the drift problem + generally, not just for chaos, and adds no cross-package dependency. -**Effort:** High. +(3) looks best and is the only one that helps anyone not doing chaos. Do not start any of them +without deciding which. --- @@ -764,6 +800,9 @@ Numbers that have left the active list. **Never reuse a number.** | 59 | Hedging drops PipelineName on per-attempt contexts | Fixed. See CHANGELOG `[Unreleased]`. | | 60 | Typed-strategy result mismatch silently skips the strategy | Fixed — now throws `InvalidOperationException`. | | 44 | `IConfiguration` binding for strategy options | Done — five bindable DTOs in `Resilion.Extensions.Configuration`. See CHANGELOG `[Unreleased]` and [configuration.md](configuration.md). | +| 40 | `Resilion.Chaos` — chaos engineering | Done — fault, outcome, latency and behavior injection, plus a not-innermost ordering warning. See CHANGELOG `[Unreleased]` and [chaos.md](chaos.md). | +| 62 | Event handler properties reject a bare lambda | Fixed — `ResilienceEventHandler` is now a delegate returning `ValueTask`, so lambdas and method groups convert. See CHANGELOG `[Unreleased]`. | +| 30 | FallbackAction/ResilienceEventHandler `Task.Run` missing token | Half closed by #62, which removed the `ResilienceEventHandler` call site entirely. Remainder filed as #70. | | 61 | README's synchronous-execution claims are broader than the truth | Fixed — claim scoped, four exceptions documented. | | 39 | Resilion.Http — HttpClient integration | Implemented. See CHANGELOG `[Unreleased]`. Residual gaps filed as #63–#66. | | 10 | Resilion.Testing package | Deferred; the two core gaps behind it (`CircuitBreakerStateProvider`, public `Pipeline.Name`) shipped instead. See CHANGELOG `[Unreleased]`. | diff --git a/docs/installation.md b/docs/installation.md index 1089721..3d00b51 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -22,6 +22,7 @@ This gives you retry, circuit breaker, timeout, fallback, and hedging — zero e | `Resilion.RateLimiting` | When you need rate limiting (wraps `System.Threading.RateLimiting`) | | `Resilion.Http` | When you call HTTP APIs through `IHttpClientFactory` or `HttpClient` | | `Resilion.Extensions` | When you use DI (`IServiceCollection`) for dependency injection | +| `Resilion.Chaos` | When you want to verify your resilience works by injecting faults, latency or bad results | > **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. @@ -37,8 +38,10 @@ Resilion (zero dependencies) ├── Resilion.Extensions + Microsoft.Extensions.DependencyInjection.Abstractions │ + Microsoft.Extensions.Options │ - └── Resilion.Http + Microsoft.Extensions.Http - + Resilion.Extensions, Resilion.RateLimiting + ├── Resilion.Http + Microsoft.Extensions.Http + │ + Resilion.Extensions, Resilion.RateLimiting + │ + └── Resilion.Chaos (no further dependencies) ``` Every satellite package references `Resilion` — NuGet pulls it in transitively, so installing one diff --git a/docs/migration-from-polly.md b/docs/migration-from-polly.md index 5a88859..462d65e 100644 --- a/docs/migration-from-polly.md +++ b/docs/migration-from-polly.md @@ -12,7 +12,7 @@ fastest path if you already know Polly. | `new ResiliencePipelineBuilder().Add...().Build()` | `Pipeline.Create(b => b.Add...)` | Static factory instead of `new` + fluent `Build()`. | | `RetryStrategyOptions` (Delay + BackoffType + UseJitter + DelayGenerator) | `RetryStrategyOptions` (`Delay = RetryDelay.___`) | `RetryDelay` is a discriminated union (`Constant`/`Linear`/`Exponential`/`Custom`) — mutually exclusive by construction, no silent overrides between properties. | | `CircuitBreakerStrategyOptions.FailureRatio` | `CircuitBreakerStrategyOptions.FailureRatioThreshold` | Same concept, renamed for clarity. | -| `Func` callbacks (`OnRetry`, `OnOpened`, ...) | `ResilienceEventHandler` | Converts implicitly from `Action` *or* `Func`, so a synchronous callback (logging, counters) runs inline with no `ValueTask` allocated. The delegate must be named, though: it is a wrapper struct, not a delegate type, so a bare lambda is `CS1660` and a method group is `CS0428`. Polly's plain-delegate properties take a lambda directly. See [future-plans.md](future-plans.md) #62. | +| `Func` callbacks (`OnRetry`, `OnOpened`, ...) | `ResilienceEventHandler` | Same shape — a delegate returning `ValueTask` — so a Polly callback ports across unchanged. Lambdas, method groups and `async` lambdas all convert. | | `FallbackAction` (always a delegate) | `FallbackAction` | Implicit conversion from a constant value, a sync factory, *or* an async factory — pick whichever fits, the compiler resolves it. | | `ResiliencePipelineRegistry` | `ResiliencePipelineRegistry` (same name) | Resilion additionally exposes `IPipelineProvider` — a read-only view for consumers that only retrieve pipelines, never register them. | | `Microsoft.Extensions.Http.Resilience` | `Resilion.Http` | `AddStandardResilienceHandler()`, `AddStandardHedgingHandler()` and `AddResilienceHandler(key, ...)` on `IHttpClientBuilder`, under the same names. See [HTTP](#http-migrating-from-microsoftextensionshttpresilience) below. | @@ -136,30 +136,19 @@ var pipeline = Pipeline.Create(b => b.AddFallback( ## Key differences to internalize - **`RetryDelay` discriminated union vs. Polly's `BackoffType` enum + separate properties.** In Polly, `Delay`, `BackoffType`, `UseJitter`, and `DelayGenerator` are independent properties that can be set inconsistently (e.g. `DelayGenerator` set alongside `BackoffType`, with unclear precedence). In Resilion, `RetryDelay.Constant/Linear/Exponential/Custom` are mutually exclusive by construction — there's exactly one way to configure the delay strategy. -- **Callbacks accept a sync `Action`, but you must name the delegate.** `ResilienceEventHandler`'s implicit conversions mean a synchronous callback (the 90% case) doesn't pay for `ValueTask` wrapping, and async callbacks still work when genuinely needed. The cost is brevity: because the handler is a wrapper struct rather than a delegate type, implicit conversion can't give a lambda a target type, so `OnRetry = e => ...` fails with `CS1660` and `OnRetry = LogRetry` with `CS0428`. Write a typed local, an inline cast, or `new Action(...)`: +- **Callbacks are the same shape as Polly's.** `ResilienceEventHandler` is a delegate + returning `ValueTask`, so a Polly callback ports across unchanged: ```csharp - // Polly — a lambda goes straight in - var polly = new ResiliencePipelineBuilder() - .AddRetry(new Polly.Retry.RetryStrategyOptions - { - OnRetry = args => { logger.LogWarning("retry {Attempt}", args.AttemptNumber); return default; }, - }) - .Build(); - ``` + // Polly + OnRetry = args => { logger.LogWarning("retry {Attempt}", args.AttemptNumber); return default; } - ```csharp - // Resilion — the delegate type has to be named somewhere - var onRetry = new Action(e => logger.LogWarning("retry {Attempt}", e.AttemptNumber)); - - var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = 3, - OnRetry = onRetry, - })); + // Resilion — identical + OnRetry = e => { logger.LogWarning("retry {Attempt}", e.AttemptNumber); return default; } ``` - Polly wins on brevity here; Resilion wins on not allocating a `ValueTask` for a log line. Tracked as [future-plans.md](future-plans.md) #62. + + Both libraries behave identically here since 1.0. - **`FallbackAction` implicit conversion** — a constant, a sync factory (`Func, T>`), or an async factory (`Func, ValueTask>`) all convert implicitly. Pick whichever shape your fallback logic naturally has. - **True sync execution, not sync-over-async.** Resilion's `Execute()` path uses real synchronous primitives (`Thread.Sleep`, `WaitHandle`) rather than blocking on an async path. This matters for ASP.NET Framework, WinForms/WPF, and any code that can't safely use `.GetAwaiter().GetResult()`. - **`Pipeline.Create()` static factory** vs. `new ResiliencePipelineBuilder().Build()` — a small ergonomic difference, but it's the idiomatic entry point throughout Resilion's docs and samples. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d7664f4..f835fe5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -108,24 +108,28 @@ Same applies to `AddHedging`. --- -## ResilienceEventHandler won't accept my lambda +## My callback doesn't compile -**Problem**: `CS1660: Cannot convert lambda expression to type 'ResilienceEventHandler?'` +**Problem**: `CS0029: Cannot implicitly convert type 'System.Action' to 'ResilienceEventHandler'` -**Cause**: C# can't chain two implicit conversions (lambda → `Action` → `ResilienceEventHandler`). +**Cause**: you are on pre-1.0 code, or carrying a pre-1.0 example. `ResilienceEventHandler` +used to be a wrapper struct with implicit conversions from `Action`; since 1.0 it is a +delegate returning `ValueTask`. -**Fix**: Assign to a typed variable first: +**Fix**: drop the wrapper and return `default` from a synchronous handler. ```csharp -// Won't compile directly in options initializer -OnRetry = (RetryAttemptEvent e) => logger.Log(e.AttemptNumber) // CS1660 +// Before 1.0 — the wrapper was mandatory, because a lambda could not convert to a struct (CS1660). +OnRetry = new Action(e => logger.Log(e.AttemptNumber)) -// Works — explicit Action -Action onRetry = e => logger.Log(e.AttemptNumber); -// Then: OnRetry = onRetry +// 1.0 onwards. +OnRetry = e => { logger.Log(e.AttemptNumber); return default; } +OnRetry = LogRetry; // method groups work too +OnRetry = async e => await TrackAsync(e); // as does async ``` ---- +Returning `default` costs nothing: `ValueTask` is a struct, so a synchronous handler allocates +exactly as much as an `Action` would, which is zero. ## Hedging sync path throws for parallel/latency mode diff --git a/docs/verification.md b/docs/verification.md index 7af4c44..cc91f55 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -137,11 +137,17 @@ wc -l src/Resilion/Retry/RetryStrategy.cs # expected ## Claims made in the README and docs ```bash -# Is the synchronous-execution claim qualified? (future-plans #61) -grep -n 'Task.Run' src/Resilion/Fallback/FallbackAction.cs src/Resilion/ResilienceEventHandler.cs -grep -n 'WaitHandle' src/Resilion/Retry/RetryStrategy.cs -# Expected today: FallbackAction.cs:64 and ResilienceEventHandler.cs:93 (sync-over-async under a -# SynchronizationContext); RetryStrategy.cs:170 and :295 (sync delay bypassing TimeProvider). +# Is the synchronous-execution claim qualified? (retired #61; see #70 and #9) +grep -rn 'Task.Run' src/ --include='*.cs' | grep -v /obj/ +grep -rn 'WaitHandle.WaitOne' src/ --include='*.cs' | grep -v /obj/ +# Expected 2026-10-24 — three Task.Run sites: +# FallbackAction.cs:64 sync-over-async with no token, tracked as #70 +# HedgingStrategy.cs:255,332 deliberate, escaping the SynchronizationContext for hedged +# attempts; not a defect and not tracked +# Three WaitHandle.WaitOne sites, all bypassing TimeProvider and tracked as #9: +# RetryStrategy.cs:143, :272 and ChaosLatencyStrategy.cs:56. +# ResilienceEventHandler no longer appears in either list: it became a delegate in #62 and its +# synchronous path now clears the SynchronizationContext instead of dispatching to the pool. grep -n 'sync-over-async\|No blocking calls\|true sync' README.md docs/comparison-with-polly.md # Every hit must be accompanied by the four documented exceptions, or the claim is overbroad. diff --git a/samples/Resilion.Samples/Program.cs b/samples/Resilion.Samples/Program.cs index 2a54bba..9584dc0 100644 --- a/samples/Resilion.Samples/Program.cs +++ b/samples/Resilion.Samples/Program.cs @@ -12,8 +12,11 @@ Console.WriteLine("1. Basic Retry"); Console.WriteLine("──────────────"); -Action onRetry = e => +ResilienceEventHandler onRetry = e => +{ Console.WriteLine($" Retry #{e.AttemptNumber}, waiting {e.RetryDelay.TotalMilliseconds:F0}ms"); + return default; +}; var retryPipeline = Pipeline.Create(b => b .AddRetry(new RetryStrategyOptions @@ -46,8 +49,11 @@ Console.WriteLine("2. Timeout"); Console.WriteLine("──────────"); -Action onTimeout = e => +ResilienceEventHandler onTimeout = e => +{ Console.WriteLine($" Timed out after {e.ElapsedTime.TotalMilliseconds:F0}ms"); + return default; +}; var timeoutPipeline = Pipeline.Create(b => b .AddTimeout(new TimeoutStrategyOptions @@ -78,10 +84,16 @@ await timeoutPipeline.ExecuteAsync(async ct => Console.WriteLine("3. Circuit Breaker"); Console.WriteLine("──────────────────"); -Action onOpened = e => +ResilienceEventHandler onOpened = e => +{ Console.WriteLine($" Circuit OPENED (was {e.PreviousState})"); -Action onClosed = e => + return default; +}; +ResilienceEventHandler onClosed = e => +{ Console.WriteLine($" Circuit CLOSED (was {e.PreviousState})"); + return default; +}; var cbPipeline = Pipeline.Create(b => b .AddCircuitBreaker(new CircuitBreakerStrategyOptions @@ -119,8 +131,11 @@ await cbPipeline.ExecuteAsync(ct => Console.WriteLine("4. Fallback"); Console.WriteLine("───────────"); -Action> onFallback = e => +ResilienceEventHandler> onFallback = e => +{ Console.WriteLine($" Fallback activated: {e.Outcome.Exception?.GetType().Name}"); + return default; +}; var fallbackPipeline = Pipeline.Create(b => b .AddFallback(new FallbackStrategyOptions diff --git a/samples/Resilion.Samples/Samples/HedgingActionGeneratorSample.cs b/samples/Resilion.Samples/Samples/HedgingActionGeneratorSample.cs index 3893dd8..50216fa 100644 --- a/samples/Resilion.Samples/Samples/HedgingActionGeneratorSample.cs +++ b/samples/Resilion.Samples/Samples/HedgingActionGeneratorSample.cs @@ -22,8 +22,11 @@ private static async ValueTask SlowCallAsync(string endpoint, TimeSpan d public static async Task RunAsync() { - Action> onHedging = e => + ResilienceEventHandler> onHedging = e => + { Console.WriteLine($" Launching hedged attempt #{e.AttemptNumber} (failover)"); + return default; + }; var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions { diff --git a/samples/Resilion.Samples/Samples/RateLimiterSample.cs b/samples/Resilion.Samples/Samples/RateLimiterSample.cs index 77a06c4..372a88d 100644 --- a/samples/Resilion.Samples/Samples/RateLimiterSample.cs +++ b/samples/Resilion.Samples/Samples/RateLimiterSample.cs @@ -17,8 +17,11 @@ public static async Task RunAsync() QueueLimit = 0, }); - Action onRejected = e => + ResilienceEventHandler onRejected = e => + { Console.WriteLine(" Rejected: rate limit exceeded"); + return default; + }; var pipeline = Pipeline.Create(b => b .AddRateLimiter(new RateLimiterStrategyOptions diff --git a/src/Resilion.Chaos/ChaosBehaviorExtensions.cs b/src/Resilion.Chaos/ChaosBehaviorExtensions.cs new file mode 100644 index 0000000..fcd64d8 --- /dev/null +++ b/src/Resilion.Chaos/ChaosBehaviorExtensions.cs @@ -0,0 +1,33 @@ +namespace Resilion.Chaos; + +/// +/// Adds the chaos behavior strategy to a pipeline. +/// +public static class ChaosBehaviorExtensions +{ + /// Adds a chaos behavior strategy. + /// The pipeline builder. + /// The behavior options. + /// The builder, for chaining. + public static PipelineBuilder AddChaosBehavior( + this PipelineBuilder builder, + ChaosBehaviorStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new ChaosBehaviorStrategy(options), Resilion.Internal.StrategyType.Chaos); + } + + /// + /// The pipeline's result type. + public static PipelineBuilder AddChaosBehavior( + this PipelineBuilder builder, + ChaosBehaviorStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new ChaosBehaviorStrategy(options), Resilion.Internal.StrategyType.Chaos); + } +} diff --git a/src/Resilion.Chaos/ChaosBehaviorStrategy.cs b/src/Resilion.Chaos/ChaosBehaviorStrategy.cs new file mode 100644 index 0000000..8a15dff --- /dev/null +++ b/src/Resilion.Chaos/ChaosBehaviorStrategy.cs @@ -0,0 +1,56 @@ +namespace Resilion.Chaos; + +/// +/// Runs a configured payload before invoking the rest of the pipeline. +/// +internal sealed class ChaosBehaviorStrategy : Strategy +{ + private const string StrategyName = "behavior"; + + private readonly ChaosBehaviorStrategyOptions _options; + + internal ChaosBehaviorStrategy(ChaosBehaviorStrategyOptions options) => _options = options; + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (ChaosDecision.ShouldInject(_options, context)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + // Not wrapped in try/catch: a behavior that throws is a fault the pipeline should + // see, which is the whole point of injecting it. + await _options.Behavior!(new ChaosGeneratorArgs(context)).ConfigureAwait(false); + + if (_options.OnBehaviorInjected is { } handler) + { + await handler(new OnChaosBehaviorArgs(context)).ConfigureAwait(false); + } + } + + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + if (ChaosDecision.ShouldInject(_options, context)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + // InvokeSync, not Behavior.Invoke: the latter is the delegate's own Invoke, which + // fires the payload and silently discards the returned ValueTask, so an async + // behavior would never be awaited on this path. + _options.Behavior!.InvokeSync(new ChaosGeneratorArgs(context)); + + if (_options.OnBehaviorInjected is { } handler) + { + handler.InvokeSync(new OnChaosBehaviorArgs(context)); + } + } + + return callback(context); + } +} diff --git a/src/Resilion.Chaos/ChaosBehaviorStrategyOptions.cs b/src/Resilion.Chaos/ChaosBehaviorStrategyOptions.cs new file mode 100644 index 0000000..a37207a --- /dev/null +++ b/src/Resilion.Chaos/ChaosBehaviorStrategyOptions.cs @@ -0,0 +1,37 @@ +namespace Resilion.Chaos; + +/// +/// Options for the chaos behavior strategy, which runs an arbitrary payload before calling +/// inward — killing a dependency, filling a disk, dropping a connection. +/// +/// +/// reuses because it already +/// expresses "synchronous or asynchronous, caller's choice" and already handles running a +/// synchronous payload safely under a . The type's name says +/// "event handler", but what it models is a sync-or-async callback, which is exactly what is +/// needed here. A synchronous payload returns default. +/// +public sealed record ChaosBehaviorStrategyOptions : ChaosStrategyOptions +{ + /// Gets the payload to run on injection. Required. + public ResilienceEventHandler? Behavior { get; init; } + + /// Gets a handler invoked after the behavior runs. + public ResilienceEventHandler? OnBehaviorInjected { get; init; } + + internal void Validate() + { + ValidateShared(); + + if (Behavior is null) + { + throw new InvalidOperationException( + "A chaos behavior strategy needs something to run: set Behavior to a handler such " + + "as 'args => { proxy.DropConnections(); return default; }'."); + } + } +} + +/// Arguments for . +/// The execution context. +public readonly record struct OnChaosBehaviorArgs(ResilienceContext Context); diff --git a/src/Resilion.Chaos/ChaosFaultExtensions.cs b/src/Resilion.Chaos/ChaosFaultExtensions.cs new file mode 100644 index 0000000..59535b0 --- /dev/null +++ b/src/Resilion.Chaos/ChaosFaultExtensions.cs @@ -0,0 +1,66 @@ +namespace Resilion.Chaos; + +/// +/// Adds the chaos fault strategy to a pipeline. +/// +public static class ChaosFaultExtensions +{ + /// Adds a chaos fault strategy. + /// The pipeline builder. + /// The fault options. + /// The builder, for chaining. + public static PipelineBuilder AddChaosFault( + this PipelineBuilder builder, + ChaosFaultStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new ChaosFaultStrategy(options), Resilion.Internal.StrategyType.Chaos); + } + + /// Adds a chaos fault strategy with a rate and a fault factory. + /// The pipeline builder. + /// The probability of injecting, from 0.0 to 1.0. + /// Produces the exception to throw, called per injection. + /// The builder, for chaining. + public static PipelineBuilder AddChaosFault( + this PipelineBuilder builder, + double injectionRate, + Func faultFactory) + { + ArgumentNullException.ThrowIfNull(faultFactory); + return builder.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = injectionRate, + FaultGenerator = _ => faultFactory(), + }); + } + + /// + /// The pipeline's result type. + public static PipelineBuilder AddChaosFault( + this PipelineBuilder builder, + ChaosFaultStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new ChaosFaultStrategy(options), Resilion.Internal.StrategyType.Chaos); + } + + /// + /// The pipeline's result type. + public static PipelineBuilder AddChaosFault( + this PipelineBuilder builder, + double injectionRate, + Func faultFactory) + { + ArgumentNullException.ThrowIfNull(faultFactory); + return builder.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = injectionRate, + FaultGenerator = _ => faultFactory(), + }); + } +} diff --git a/src/Resilion.Chaos/ChaosFaultStrategy.cs b/src/Resilion.Chaos/ChaosFaultStrategy.cs new file mode 100644 index 0000000..0ee6ff1 --- /dev/null +++ b/src/Resilion.Chaos/ChaosFaultStrategy.cs @@ -0,0 +1,82 @@ +namespace Resilion.Chaos; + +/// +/// Throws a configured exception instead of invoking the rest of the pipeline. +/// +/// +/// Derives from rather than because a +/// thrown fault is result-agnostic: one instance works for a pipeline of any result type. +/// +internal sealed class ChaosFaultStrategy : Strategy +{ + private const string StrategyName = "fault"; + + private readonly ChaosFaultStrategyOptions _options; + + internal ChaosFaultStrategy(ChaosFaultStrategyOptions options) => _options = options; + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (TryResolveFault(context, out var fault)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + if (_options.OnFaultInjected is { } handler) + { + await handler(new OnChaosFaultArgs(context, fault)).ConfigureAwait(false); + } + + return Outcome.FromException(fault); + } + + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + if (TryResolveFault(context, out var fault)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + if (_options.OnFaultInjected is { } handler) + { + handler.InvokeSync(new OnChaosFaultArgs(context, fault)); + } + + return Outcome.FromException(fault); + } + + return callback(context); + } + + /// + /// Decides whether to inject and, if so, what to throw. A generator returning null means + /// "not this time" and passes through — the injection decision is then spent without effect, + /// which is deliberate: it lets a generator filter on the context. + /// + private bool TryResolveFault(ResilienceContext context, out Exception fault) + { + fault = null!; + + if (!ChaosDecision.ShouldInject(_options, context)) + { + return false; + } + + var resolved = _options.FaultGenerator is { } generator + ? generator(new ChaosGeneratorArgs(context)) + : _options.Fault; + + if (resolved is null) + { + return false; + } + + fault = resolved; + return true; + } +} diff --git a/src/Resilion.Chaos/ChaosFaultStrategyOptions.cs b/src/Resilion.Chaos/ChaosFaultStrategyOptions.cs new file mode 100644 index 0000000..3626f4c --- /dev/null +++ b/src/Resilion.Chaos/ChaosFaultStrategyOptions.cs @@ -0,0 +1,46 @@ +namespace Resilion.Chaos; + +/// +/// Options for the chaos fault strategy, which throws an exception instead of calling inward. +/// +public sealed record ChaosFaultStrategyOptions : ChaosStrategyOptions +{ + /// Gets the exception to inject. + /// + /// + /// Prefer . Outcome<T>.FromException + /// captures the exception with , + /// so rethrowing one shared instance repeatedly appends to its stack trace each time and the + /// traces become progressively less readable. + /// + /// Ignored when is set. + /// + public Exception? Fault { get; init; } + + /// + /// Gets a factory producing the exception to inject, overriding . Returning + /// skips injection for that call. + /// + public Func? FaultGenerator { get; init; } + + /// Gets a handler invoked after a fault is injected. + public ResilienceEventHandler? OnFaultInjected { get; init; } + + internal void Validate() + { + ValidateShared(); + + if (Fault is null && FaultGenerator is null) + { + throw new InvalidOperationException( + "A chaos fault strategy needs something to throw: set Fault or FaultGenerator. " + + "FaultGenerator is preferred — a single reused exception instance accumulates " + + "stack traces across injections."); + } + } +} + +/// Arguments for . +/// The execution context. +/// The exception that was injected. +public readonly record struct OnChaosFaultArgs(ResilienceContext Context, Exception Fault); diff --git a/src/Resilion.Chaos/ChaosLatencyExtensions.cs b/src/Resilion.Chaos/ChaosLatencyExtensions.cs new file mode 100644 index 0000000..ab90d4e --- /dev/null +++ b/src/Resilion.Chaos/ChaosLatencyExtensions.cs @@ -0,0 +1,62 @@ +namespace Resilion.Chaos; + +/// +/// Adds the chaos latency strategy to a pipeline. +/// +public static class ChaosLatencyExtensions +{ + /// Adds a chaos latency strategy. + /// The pipeline builder. + /// The latency options. + /// The builder, for chaining. + public static PipelineBuilder AddChaosLatency( + this PipelineBuilder builder, + ChaosLatencyStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy( + new ChaosLatencyStrategy(options, builder.TimeProvider), Resilion.Internal.StrategyType.Chaos); + } + + /// Adds a chaos latency strategy with a rate and a fixed delay. + /// The pipeline builder. + /// The probability of injecting, from 0.0 to 1.0. + /// The delay to inject. + /// The builder, for chaining. + public static PipelineBuilder AddChaosLatency( + this PipelineBuilder builder, + double injectionRate, + TimeSpan latency) + => builder.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = injectionRate, + Latency = latency, + }); + + /// + /// The pipeline's result type. + public static PipelineBuilder AddChaosLatency( + this PipelineBuilder builder, + ChaosLatencyStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy( + new ChaosLatencyStrategy(options, builder.TimeProvider), Resilion.Internal.StrategyType.Chaos); + } + + /// + /// The pipeline's result type. + public static PipelineBuilder AddChaosLatency( + this PipelineBuilder builder, + double injectionRate, + TimeSpan latency) + => builder.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = injectionRate, + Latency = latency, + }); +} diff --git a/src/Resilion.Chaos/ChaosLatencyStrategy.cs b/src/Resilion.Chaos/ChaosLatencyStrategy.cs new file mode 100644 index 0000000..9cad4fb --- /dev/null +++ b/src/Resilion.Chaos/ChaosLatencyStrategy.cs @@ -0,0 +1,79 @@ +namespace Resilion.Chaos; + +/// +/// Delays before invoking the rest of the pipeline. +/// +internal sealed class ChaosLatencyStrategy : Strategy +{ + private const string StrategyName = "latency"; + + private readonly ChaosLatencyStrategyOptions _options; + private readonly TimeProvider _timeProvider; + + internal ChaosLatencyStrategy(ChaosLatencyStrategyOptions options, TimeProvider timeProvider) + { + _options = options; + _timeProvider = timeProvider; + } + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (TryResolveLatency(context, out var latency)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + if (_options.OnLatencyInjected is { } handler) + { + await handler(new OnChaosLatencyArgs(context, latency)).ConfigureAwait(false); + } + + // Through the builder's TimeProvider, so FakeTimeProvider drives this in tests. + await Task.Delay(latency, _timeProvider, context.CancellationToken).ConfigureAwait(false); + } + + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + if (TryResolveLatency(context, out var latency)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + if (_options.OnLatencyInjected is { } handler) + { + handler.InvokeSync(new OnChaosLatencyArgs(context, latency)); + } + + // Cancellation-aware sleep, matching the synchronous retry delay. This path does NOT + // go through _timeProvider — WaitHandle has no TimeProvider equivalent — so a + // FakeTimeProvider cannot drive it. Same limitation as sync retry delays; see + // future-plans #9. + context.CancellationToken.WaitHandle.WaitOne(latency); + context.CancellationToken.ThrowIfCancellationRequested(); + } + + return callback(context); + } + + private bool TryResolveLatency(ResilienceContext context, out TimeSpan latency) + { + latency = default; + + if (!ChaosDecision.ShouldInject(_options, context)) + { + return false; + } + + var resolved = _options.LatencyGenerator is { } generator + ? generator(new ChaosGeneratorArgs(context)) + : _options.Latency; + + latency = resolved < TimeSpan.Zero ? TimeSpan.Zero : resolved; + return true; + } +} diff --git a/src/Resilion.Chaos/ChaosLatencyStrategyOptions.cs b/src/Resilion.Chaos/ChaosLatencyStrategyOptions.cs new file mode 100644 index 0000000..c65891a --- /dev/null +++ b/src/Resilion.Chaos/ChaosLatencyStrategyOptions.cs @@ -0,0 +1,41 @@ +namespace Resilion.Chaos; + +/// +/// Options for the chaos latency strategy, which delays before calling inward. +/// +/// +/// Latency injection is how you test a timeout. Place it innermost, inside the attempt timeout +/// you want to trip. +/// +public sealed record ChaosLatencyStrategyOptions : ChaosStrategyOptions +{ + /// Gets the delay to inject. Defaults to 30 seconds. + /// Ignored when is set. + public TimeSpan Latency { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Gets a per-call delay, overriding . A negative result is clamped to + /// zero rather than thrown, since this runs mid-execution. + /// + public Func? LatencyGenerator { get; init; } + + /// Gets a handler invoked after a delay is injected. + public ResilienceEventHandler? OnLatencyInjected { get; init; } + + internal void Validate() + { + ValidateShared(); + + if (LatencyGenerator is null && Latency < TimeSpan.Zero) + { + throw new InvalidOperationException( + $"Latency must not be negative, but was {Latency}. " + + "Use LatencyGenerator for a value computed per call."); + } + } +} + +/// Arguments for . +/// The execution context. +/// The delay that was injected. +public readonly record struct OnChaosLatencyArgs(ResilienceContext Context, TimeSpan Latency); diff --git a/src/Resilion.Chaos/ChaosOutcomeExtensions.cs b/src/Resilion.Chaos/ChaosOutcomeExtensions.cs new file mode 100644 index 0000000..1d5b617 --- /dev/null +++ b/src/Resilion.Chaos/ChaosOutcomeExtensions.cs @@ -0,0 +1,46 @@ +namespace Resilion.Chaos; + +/// +/// Adds the chaos outcome strategy to a typed pipeline. +/// +/// +/// There is deliberately no untyped overload. A Strategy<TResult> +/// added to a pipeline executing a different result type cannot run, so an untyped +/// AddChaosOutcome would be a trap with no correct use. +/// +public static class ChaosOutcomeExtensions +{ + /// Adds a chaos outcome strategy. + /// The pipeline's result type. + /// The typed pipeline builder. + /// The outcome options. + /// The builder, for chaining. + public static PipelineBuilder AddChaosOutcome( + this PipelineBuilder builder, + ChaosOutcomeStrategyOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + return builder.AddStrategy(new ChaosOutcomeStrategy(options), Resilion.Internal.StrategyType.Chaos); + } + + /// Adds a chaos outcome strategy with a rate and a result factory. + /// The pipeline's result type. + /// The typed pipeline builder. + /// The probability of injecting, from 0.0 to 1.0. + /// Produces the result to return, called per injection. + /// The builder, for chaining. + public static PipelineBuilder AddChaosOutcome( + this PipelineBuilder builder, + double injectionRate, + Func resultFactory) + { + ArgumentNullException.ThrowIfNull(resultFactory); + return builder.AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + InjectionRate = injectionRate, + OutcomeGenerator = _ => Outcome.FromResult(resultFactory()), + }); + } +} diff --git a/src/Resilion.Chaos/ChaosOutcomeStrategy.cs b/src/Resilion.Chaos/ChaosOutcomeStrategy.cs new file mode 100644 index 0000000..1fde69f --- /dev/null +++ b/src/Resilion.Chaos/ChaosOutcomeStrategy.cs @@ -0,0 +1,68 @@ +namespace Resilion.Chaos; + +/// +/// Substitutes a configured outcome instead of invoking the rest of the pipeline. +/// +internal sealed class ChaosOutcomeStrategy : Strategy +{ + private const string StrategyName = "outcome"; + + private readonly ChaosOutcomeStrategyOptions _options; + + internal ChaosOutcomeStrategy(ChaosOutcomeStrategyOptions options) => _options = options; + + protected internal override async ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + if (TryResolveOutcome(context, out var injected)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + if (_options.OnOutcomeInjected is { } handler) + { + await handler(new OnChaosOutcomeArgs(context, injected)) + .ConfigureAwait(false); + } + + return injected; + } + + return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + } + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) + { + if (TryResolveOutcome(context, out var injected)) + { + ChaosDecision.RecordInjection(StrategyName, context); + + if (_options.OnOutcomeInjected is { } handler) + { + handler.InvokeSync(new OnChaosOutcomeArgs(context, injected)); + } + + return injected; + } + + return callback(context); + } + + private bool TryResolveOutcome(ResilienceContext context, out Outcome outcome) + { + outcome = default; + + if (!ChaosDecision.ShouldInject(_options, context)) + { + return false; + } + + outcome = _options.OutcomeGenerator is { } generator + ? generator(new ChaosGeneratorArgs(context)) + : _options.Outcome!.Value; + + return true; + } +} diff --git a/src/Resilion.Chaos/ChaosOutcomeStrategyOptions.cs b/src/Resilion.Chaos/ChaosOutcomeStrategyOptions.cs new file mode 100644 index 0000000..7522aa3 --- /dev/null +++ b/src/Resilion.Chaos/ChaosOutcomeStrategyOptions.cs @@ -0,0 +1,43 @@ +namespace Resilion.Chaos; + +/// +/// Options for the chaos outcome strategy, which substitutes a result (or a failure) instead of +/// calling inward. +/// +/// The pipeline's result type. +/// +/// This is the strategy for testing result-based predicates: a retry or circuit breaker +/// configured with ShouldHandle on the value only reacts if something produces +/// that value, and a fault injection never will. +/// +public sealed record ChaosOutcomeStrategyOptions : ChaosStrategyOptions +{ + /// Gets the outcome to inject. + /// Ignored when is set. + public Outcome? Outcome { get; init; } + + /// + /// Gets a factory producing the outcome to inject, overriding . + /// + public Func>? OutcomeGenerator { get; init; } + + /// Gets a handler invoked after an outcome is injected. + public ResilienceEventHandler>? OnOutcomeInjected { get; init; } + + internal void Validate() + { + ValidateShared(); + + if (Outcome is null && OutcomeGenerator is null) + { + throw new InvalidOperationException( + "A chaos outcome strategy needs something to return: set Outcome or OutcomeGenerator."); + } + } +} + +/// Arguments for . +/// The pipeline's result type. +/// The execution context. +/// The outcome that was injected. +public readonly record struct OnChaosOutcomeArgs(ResilienceContext Context, Outcome Outcome); diff --git a/src/Resilion.Chaos/ChaosStrategyOptions.cs b/src/Resilion.Chaos/ChaosStrategyOptions.cs new file mode 100644 index 0000000..2c3e244 --- /dev/null +++ b/src/Resilion.Chaos/ChaosStrategyOptions.cs @@ -0,0 +1,119 @@ +namespace Resilion.Chaos; + +/// +/// Arguments passed to every chaos generator. +/// +/// The execution context for the call being considered for injection. +public readonly record struct ChaosGeneratorArgs(ResilienceContext Context); + +/// +/// Settings shared by every chaos strategy: whether it is active, how often it injects, and +/// where the injection decision's randomness comes from. +/// +/// +/// +/// Read this before using chaos outside a test. The defaults are +/// = true and = 0.001, so a +/// strategy added to a pipeline starts injecting immediately. Gate it on the environment: +/// +/// +/// EnabledGenerator = _ => !env.IsProduction() +/// +/// +/// Every generator takes precedence over its corresponding static value, uniformly across all +/// four strategies. +/// +/// +public abstract record ChaosStrategyOptions +{ + /// + /// Gets the probability of injecting, from 0.0 (never) to 1.0 (always). Defaults to + /// 0.001 — one call in a thousand. + /// + /// Ignored when is set. + public double InjectionRate { get; init; } = 0.001; + + /// + /// Gets a per-call injection probability, overriding . + /// + /// + /// A value outside [0, 1] is clamped rather than thrown, because this runs mid-execution + /// where an exception would be indistinguishable from an injected fault. + /// + public Func? InjectionRateGenerator { get; init; } + + /// Gets whether this strategy is active at all. Defaults to . + /// Ignored when is set. + public bool Enabled { get; init; } = true; + + /// + /// Gets a per-call enablement check, overriding . This is the intended + /// place for an environment guard. + /// + public Func? EnabledGenerator { get; init; } + + /// + /// Gets the source of randomness for the injection decision, returning a value in + /// [0, 1). Defaults to . + /// + /// + /// + /// This is the supported test seam, and it is not optional polish. Without + /// it a chaos strategy is as untestable as a strategy that ignores + /// PipelineBuilder.TimeProvider — you could only assert on injection statistically. + /// + /// + /// Randomizer = () => 0.0 // always inject + /// Randomizer = () => 1.0 // never inject + /// + /// + /// It is consulted on every decision with no short-circuit, so those two values hold + /// regardless of the configured rate. + /// + /// + public Func? Randomizer { get; init; } + + private protected void ValidateShared() + { + if (InjectionRateGenerator is null && (InjectionRate < 0.0 || InjectionRate > 1.0)) + { + throw new InvalidOperationException( + $"InjectionRate must be between 0.0 and 1.0 inclusive, but was {InjectionRate}. " + + "Use InjectionRateGenerator for a value computed per call."); + } + } +} + +/// +/// The single place the inject-or-not decision is made, so the four strategies cannot drift +/// apart on generator precedence, clamping, or which randomizer is used. +/// +internal static class ChaosDecision +{ + internal static bool ShouldInject(ChaosStrategyOptions options, ResilienceContext context) + { + var args = new ChaosGeneratorArgs(context); + + if (!(options.EnabledGenerator?.Invoke(args) ?? options.Enabled)) + { + return false; + } + + var rate = Math.Clamp(options.InjectionRateGenerator?.Invoke(args) ?? options.InjectionRate, 0.0, 1.0); + if (rate <= 0.0) + { + return false; + } + + // No short-circuit at rate >= 1.0: the randomizer is consulted on every decision so that + // Randomizer = () => 1.0 reliably means "never inject" at any configured rate. + return (options.Randomizer ?? Random.Shared.NextDouble)() < rate; + } + + internal static void RecordInjection(string strategyName, ResilienceContext context) + => ResilionTelemetry.ChaosInjections.Add( + 1, + new(ResilionTelemetry.PipelineNameTag, context.PipelineName), + new(ResilionTelemetry.OperationKeyTag, context.OperationKey), + new(ResilionTelemetry.ChaosStrategyTag, strategyName)); +} diff --git a/src/Resilion.Chaos/PublicAPI.Shipped.txt b/src/Resilion.Chaos/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/Resilion.Chaos/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Resilion.Chaos/PublicAPI.Unshipped.txt b/src/Resilion.Chaos/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..946a1d1 --- /dev/null +++ b/src/Resilion.Chaos/PublicAPI.Unshipped.txt @@ -0,0 +1,85 @@ +#nullable enable +Resilion.Chaos.ChaosBehaviorExtensions +Resilion.Chaos.ChaosBehaviorStrategyOptions +Resilion.Chaos.ChaosBehaviorStrategyOptions.Behavior.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosBehaviorStrategyOptions.Behavior.init -> void +Resilion.Chaos.ChaosBehaviorStrategyOptions.OnBehaviorInjected.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosBehaviorStrategyOptions.OnBehaviorInjected.init -> void +Resilion.Chaos.ChaosFaultExtensions +Resilion.Chaos.ChaosFaultStrategyOptions +Resilion.Chaos.ChaosFaultStrategyOptions.Fault.get -> System.Exception? +Resilion.Chaos.ChaosFaultStrategyOptions.Fault.init -> void +Resilion.Chaos.ChaosFaultStrategyOptions.FaultGenerator.get -> System.Func? +Resilion.Chaos.ChaosFaultStrategyOptions.FaultGenerator.init -> void +Resilion.Chaos.ChaosFaultStrategyOptions.OnFaultInjected.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosFaultStrategyOptions.OnFaultInjected.init -> void +Resilion.Chaos.ChaosGeneratorArgs +Resilion.Chaos.ChaosGeneratorArgs.ChaosGeneratorArgs() -> void +Resilion.Chaos.ChaosGeneratorArgs.ChaosGeneratorArgs(Resilion.ResilienceContext! Context) -> void +Resilion.Chaos.ChaosGeneratorArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.ChaosGeneratorArgs.Context.init -> void +Resilion.Chaos.ChaosLatencyExtensions +Resilion.Chaos.ChaosLatencyStrategyOptions +Resilion.Chaos.ChaosLatencyStrategyOptions.Latency.get -> System.TimeSpan +Resilion.Chaos.ChaosLatencyStrategyOptions.Latency.init -> void +Resilion.Chaos.ChaosLatencyStrategyOptions.LatencyGenerator.get -> System.Func? +Resilion.Chaos.ChaosLatencyStrategyOptions.LatencyGenerator.init -> void +Resilion.Chaos.ChaosLatencyStrategyOptions.OnLatencyInjected.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosLatencyStrategyOptions.OnLatencyInjected.init -> void +Resilion.Chaos.ChaosOutcomeExtensions +Resilion.Chaos.ChaosOutcomeStrategyOptions +Resilion.Chaos.ChaosOutcomeStrategyOptions.OnOutcomeInjected.get -> Resilion.ResilienceEventHandler>? +Resilion.Chaos.ChaosOutcomeStrategyOptions.OnOutcomeInjected.init -> void +Resilion.Chaos.ChaosOutcomeStrategyOptions.Outcome.get -> Resilion.Outcome? +Resilion.Chaos.ChaosOutcomeStrategyOptions.Outcome.init -> void +Resilion.Chaos.ChaosOutcomeStrategyOptions.OutcomeGenerator.get -> System.Func>? +Resilion.Chaos.ChaosOutcomeStrategyOptions.OutcomeGenerator.init -> void +Resilion.Chaos.ChaosStrategyOptions +Resilion.Chaos.ChaosStrategyOptions.Enabled.get -> bool +Resilion.Chaos.ChaosStrategyOptions.Enabled.init -> void +Resilion.Chaos.ChaosStrategyOptions.EnabledGenerator.get -> System.Func? +Resilion.Chaos.ChaosStrategyOptions.EnabledGenerator.init -> void +Resilion.Chaos.ChaosStrategyOptions.InjectionRate.get -> double +Resilion.Chaos.ChaosStrategyOptions.InjectionRate.init -> void +Resilion.Chaos.ChaosStrategyOptions.InjectionRateGenerator.get -> System.Func? +Resilion.Chaos.ChaosStrategyOptions.InjectionRateGenerator.init -> void +Resilion.Chaos.ChaosStrategyOptions.Randomizer.get -> System.Func? +Resilion.Chaos.ChaosStrategyOptions.Randomizer.init -> void +Resilion.Chaos.OnChaosBehaviorArgs +Resilion.Chaos.OnChaosBehaviorArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosBehaviorArgs.Context.init -> void +Resilion.Chaos.OnChaosBehaviorArgs.OnChaosBehaviorArgs() -> void +Resilion.Chaos.OnChaosBehaviorArgs.OnChaosBehaviorArgs(Resilion.ResilienceContext! Context) -> void +Resilion.Chaos.OnChaosFaultArgs +Resilion.Chaos.OnChaosFaultArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosFaultArgs.Context.init -> void +Resilion.Chaos.OnChaosFaultArgs.Fault.get -> System.Exception! +Resilion.Chaos.OnChaosFaultArgs.Fault.init -> void +Resilion.Chaos.OnChaosFaultArgs.OnChaosFaultArgs() -> void +Resilion.Chaos.OnChaosFaultArgs.OnChaosFaultArgs(Resilion.ResilienceContext! Context, System.Exception! Fault) -> void +Resilion.Chaos.OnChaosLatencyArgs +Resilion.Chaos.OnChaosLatencyArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosLatencyArgs.Context.init -> void +Resilion.Chaos.OnChaosLatencyArgs.Latency.get -> System.TimeSpan +Resilion.Chaos.OnChaosLatencyArgs.Latency.init -> void +Resilion.Chaos.OnChaosLatencyArgs.OnChaosLatencyArgs() -> void +Resilion.Chaos.OnChaosLatencyArgs.OnChaosLatencyArgs(Resilion.ResilienceContext! Context, System.TimeSpan Latency) -> void +Resilion.Chaos.OnChaosOutcomeArgs +Resilion.Chaos.OnChaosOutcomeArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosOutcomeArgs.Context.init -> void +Resilion.Chaos.OnChaosOutcomeArgs.OnChaosOutcomeArgs() -> void +Resilion.Chaos.OnChaosOutcomeArgs.OnChaosOutcomeArgs(Resilion.ResilienceContext! Context, Resilion.Outcome Outcome) -> void +Resilion.Chaos.OnChaosOutcomeArgs.Outcome.get -> Resilion.Outcome +Resilion.Chaos.OnChaosOutcomeArgs.Outcome.init -> void +static Resilion.Chaos.ChaosBehaviorExtensions.AddChaosBehavior(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosBehaviorStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosBehaviorExtensions.AddChaosBehavior(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosBehaviorStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosFaultStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! faultFactory) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosFaultStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! faultFactory) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosLatencyStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, double injectionRate, System.TimeSpan latency) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosLatencyStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, double injectionRate, System.TimeSpan latency) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosOutcomeExtensions.AddChaosOutcome(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosOutcomeStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosOutcomeExtensions.AddChaosOutcome(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! resultFactory) -> Resilion.PipelineBuilder! diff --git a/src/Resilion.Chaos/Resilion.Chaos.csproj b/src/Resilion.Chaos/Resilion.Chaos.csproj new file mode 100644 index 0000000..7f82bbd --- /dev/null +++ b/src/Resilion.Chaos/Resilion.Chaos.csproj @@ -0,0 +1,18 @@ + + + + Resilion.Chaos + Resilion.Chaos + Resilion.Chaos + Chaos engineering strategies for Resilion. Inject faults, outcomes, latency and custom behaviour into a pipeline to verify your resilience actually works. + + + + + + + + + + + diff --git a/src/Resilion.Extensions/PublicAPI.Unshipped.txt b/src/Resilion.Extensions/PublicAPI.Unshipped.txt index 2a2a59f..629a44e 100644 --- a/src/Resilion.Extensions/PublicAPI.Unshipped.txt +++ b/src/Resilion.Extensions/PublicAPI.Unshipped.txt @@ -60,8 +60,8 @@ Resilion.Extensions.ResiliencePipelineRegistry.InvalidatePipeline Resilion.Extensions.ResiliencePipelineRegistry.OnPipelineReplaced.get -> System.Action>? Resilion.Extensions.ResiliencePipelineRegistry.OnPipelineReplaced.set -> void Resilion.Extensions.ResiliencePipelineRegistry.TryGetPipeline(TKey key, out Resilion.Pipeline? pipeline) -> bool -static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action!, TOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action!, TOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action!, TOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Resilion.Extensions.ResilionServiceCollectionExtensions.ConfigureResiliencePipelineRegistry(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Resilion.RateLimiting/RateLimiterStrategy.cs b/src/Resilion.RateLimiting/RateLimiterStrategy.cs index d0d50d7..64c2e84 100644 --- a/src/Resilion.RateLimiting/RateLimiterStrategy.cs +++ b/src/Resilion.RateLimiting/RateLimiterStrategy.cs @@ -73,9 +73,9 @@ private async ValueTask> HandleRejection( Resilion.ResilionTelemetry.RateLimiterRejections.Add(1, new(Resilion.ResilionTelemetry.PipelineNameTag, context.PipelineName), new(Resilion.ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnRejected is { } handler && handler.HasHandler) + if (_options.OnRejected is { } handler) { - await handler.InvokeAsync(new OnRateLimitRejectedEvent(retryAfter, context)) + await handler(new OnRateLimitRejectedEvent(retryAfter, context)) .ConfigureAwait(false); } @@ -90,9 +90,9 @@ private Outcome HandleRejectionSync( Resilion.ResilionTelemetry.RateLimiterRejections.Add(1, new(Resilion.ResilionTelemetry.PipelineNameTag, context.PipelineName), new(Resilion.ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnRejected is { } handler && handler.HasHandler) + if (_options.OnRejected is { } handler) { - handler.Invoke(new OnRateLimitRejectedEvent(retryAfter, context)); + handler.InvokeSync(new OnRateLimitRejectedEvent(retryAfter, context)); } return Outcome.FromException(new RateLimitRejectedException(retryAfter)); diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs index 9f7d038..1c5f9a5 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs @@ -206,25 +206,25 @@ private async ValueTask FireEventAsync(CircuitStateChangedEvent? evt) switch (e.CurrentState) { case CircuitState.Open: - if (_onOpened is { } onOpened && onOpened.HasHandler) + if (_onOpened is { } onOpened) { - await onOpened.InvokeAsync(e).ConfigureAwait(false); + await onOpened(e).ConfigureAwait(false); } break; case CircuitState.Closed: - if (_onClosed is { } onClosed && onClosed.HasHandler) + if (_onClosed is { } onClosed) { - await onClosed.InvokeAsync(e).ConfigureAwait(false); + await onClosed(e).ConfigureAwait(false); } break; case CircuitState.HalfOpen: - if (_onHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + if (_onHalfOpened is { } onHalfOpened) { - await onHalfOpened.InvokeAsync(e).ConfigureAwait(false); + await onHalfOpened(e).ConfigureAwait(false); } break; @@ -245,25 +245,25 @@ private void FireEvent(CircuitStateChangedEvent? evt) switch (e.CurrentState) { case CircuitState.Open: - if (_onOpened is { } onOpened && onOpened.HasHandler) + if (_onOpened is { } onOpened) { - onOpened.Invoke(e); + onOpened.InvokeSync(e); } break; case CircuitState.Closed: - if (_onClosed is { } onClosed && onClosed.HasHandler) + if (_onClosed is { } onClosed) { - onClosed.Invoke(e); + onClosed.InvokeSync(e); } break; case CircuitState.HalfOpen: - if (_onHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + if (_onHalfOpened is { } onHalfOpened) { - onHalfOpened.Invoke(e); + onHalfOpened.InvokeSync(e); } break; diff --git a/src/Resilion/Fallback/FallbackStrategy.cs b/src/Resilion/Fallback/FallbackStrategy.cs index dba908c..22ffebd 100644 --- a/src/Resilion/Fallback/FallbackStrategy.cs +++ b/src/Resilion/Fallback/FallbackStrategy.cs @@ -33,9 +33,9 @@ protected internal override async ValueTask> ExecuteAsync( var fallbackCtx = new FallbackContext(outcome, context); var fallbackResult = await _options.FallbackAction.ExecuteAsync(fallbackCtx).ConfigureAwait(false); - if (_options.OnFallback is { } handler && handler.HasHandler) + if (_options.OnFallback is { } handler) { - await handler.InvokeAsync(new OnFallbackEvent(outcome, fallbackResult, context)) + await handler(new OnFallbackEvent(outcome, fallbackResult, context)) .ConfigureAwait(false); } @@ -63,9 +63,9 @@ protected internal override Outcome Execute( var fallbackCtx = new FallbackContext(outcome, context); var fallbackResult = _options.FallbackAction.Execute(fallbackCtx); - if (_options.OnFallback is { } handler && handler.HasHandler) + if (_options.OnFallback is { } handler) { - handler.Invoke(new OnFallbackEvent(outcome, fallbackResult, context)); + handler.InvokeSync(new OnFallbackEvent(outcome, fallbackResult, context)); } StrategyActivity.SetOutcome(activity, "fallback_applied"); diff --git a/src/Resilion/Hedging/HedgingStrategy.cs b/src/Resilion/Hedging/HedgingStrategy.cs index 1544b2f..12a6997 100644 --- a/src/Resilion/Hedging/HedgingStrategy.cs +++ b/src/Resilion/Hedging/HedgingStrategy.cs @@ -87,9 +87,9 @@ protected internal override async ValueTask> ExecuteAsync( ResilionTelemetry.HedgingAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); // Fire OnHedging event. - if (_options.OnHedging is { } handler && handler.HasHandler) + if (_options.OnHedging is { } handler) { - await handler.InvokeAsync(new OnHedgingEvent(attemptIndex, context)) + await handler(new OnHedgingEvent(attemptIndex, context)) .ConfigureAwait(false); } @@ -204,9 +204,9 @@ protected internal override Outcome Execute( // OnHedging handler is registered, so the counter and the event never disagree. ResilionTelemetry.HedgingAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnHedging is { } handler && handler.HasHandler) + if (_options.OnHedging is { } handler) { - handler.Invoke(new OnHedgingEvent(attemptIndex + 1, context)); + handler.InvokeSync(new OnHedgingEvent(attemptIndex + 1, context)); } } } diff --git a/src/Resilion/Internal/OrderingValidator.cs b/src/Resilion/Internal/OrderingValidator.cs index 8c4af5f..abeb67a 100644 --- a/src/Resilion/Internal/OrderingValidator.cs +++ b/src/Resilion/Internal/OrderingValidator.cs @@ -117,6 +117,29 @@ internal static OrderingValidationResult Validate(IReadOnlyList st } } + // Warning 4: Chaos strategies must be innermost. A chaos strategy placed outside a Retry + // injects its fault before Retry can see it, so the retry never observes the failure and + // the chaos run silently tests nothing — the worst outcome for a chaos experiment, since + // it looks like a pass. A warning rather than an error because stacking several chaos + // strategies innermost is legitimate, and a deliberate mid-pipeline injection (chaos + // between an outer and an inner pipeline via AddPipeline) is conceivable. + if (positions.TryGetValue(StrategyType.Chaos, out var chaosPositions)) + { + // Chaos entries are innermost exactly when they occupy the final `chaosPositions.Count` + // slots, so anything before that boundary has a non-chaos strategy inside it. + var innermostBoundary = strategies.Count - chaosPositions.Count; + foreach (var chaosPos in chaosPositions) + { + if (chaosPos < innermostBoundary) + { + warnings.Add( + "Chaos strategy at position " + chaosPos + " is not innermost. Strategies " + + "added after it will not observe the injected chaos, so they are not " + + "actually being tested. Chaos strategies should be added last."); + } + } + } + return new OrderingValidationResult(errors, warnings); } } diff --git a/src/Resilion/Internal/StrategyType.cs b/src/Resilion/Internal/StrategyType.cs index 5fb1057..b36efd3 100644 --- a/src/Resilion/Internal/StrategyType.cs +++ b/src/Resilion/Internal/StrategyType.cs @@ -13,4 +13,8 @@ internal enum StrategyType RateLimiter, Hedging, Custom, + + // Appended, never inserted: the preceding ordinals are load-bearing for tests and benchmarks + // that compare recorded strategy sequences. + Chaos, } diff --git a/src/Resilion/PublicAPI.Shipped.txt b/src/Resilion/PublicAPI.Shipped.txt index bc74cca..d6a0573 100644 --- a/src/Resilion/PublicAPI.Shipped.txt +++ b/src/Resilion/PublicAPI.Shipped.txt @@ -199,8 +199,6 @@ Resilion.ResilienceContextPool.Rent(System.Threading.CancellationToken cancellat Resilion.ResilienceContextPool.ResilienceContextPool(int maxPoolSize = 256) -> void Resilion.ResilienceContextPool.Return(Resilion.ResilienceContext! context) -> void Resilion.ResilienceEventHandler -Resilion.ResilienceEventHandler.HasHandler.get -> bool -Resilion.ResilienceEventHandler.ResilienceEventHandler() -> void Resilion.ResilienceProperties Resilion.ResilienceProperties.Count.get -> int Resilion.ResilienceProperties.GetValue(Resilion.ResiliencePropertyKey key, TValue defaultValue = default(TValue)) -> TValue @@ -322,8 +320,6 @@ static Resilion.Pipeline.Create(System.Action Resilion.Pipeline! static Resilion.Pipeline.Empty.get -> Resilion.Pipeline! static Resilion.ResilienceContextPool.Shared.get -> Resilion.ResilienceContextPool! -static Resilion.ResilienceEventHandler.implicit operator Resilion.ResilienceEventHandler(System.Action! handler) -> Resilion.ResilienceEventHandler -static Resilion.ResilienceEventHandler.implicit operator Resilion.ResilienceEventHandler(System.Func! handler) -> Resilion.ResilienceEventHandler static Resilion.ResiliencePropertyKey.operator !=(Resilion.ResiliencePropertyKey left, Resilion.ResiliencePropertyKey right) -> bool static Resilion.ResiliencePropertyKey.operator ==(Resilion.ResiliencePropertyKey left, Resilion.ResiliencePropertyKey right) -> bool static Resilion.RetryDelay.Constant(System.TimeSpan delay) -> Resilion.RetryDelay! diff --git a/src/Resilion/PublicAPI.Unshipped.txt b/src/Resilion/PublicAPI.Unshipped.txt index 2e5aecb..a59f728 100644 --- a/src/Resilion/PublicAPI.Unshipped.txt +++ b/src/Resilion/PublicAPI.Unshipped.txt @@ -33,5 +33,6 @@ Resilion.PredicateBuilder.HandleInner(System.Func.HandleResult(System.Func! predicate) -> Resilion.PredicateBuilder! Resilion.PredicateBuilder.HandleResult(TResult result) -> Resilion.PredicateBuilder! Resilion.PredicateBuilder.PredicateBuilder() -> void +const Resilion.ResilionTelemetry.ChaosStrategyTag = "chaos.strategy" -> string! static Resilion.PredicateBuilder.implicit operator System.Func!(Resilion.PredicateBuilder! builder) -> System.Func! static Resilion.PredicateBuilder.implicit operator System.Func, bool>!(Resilion.PredicateBuilder! builder) -> System.Func, bool>! diff --git a/src/Resilion/ResilienceEventHandler.cs b/src/Resilion/ResilienceEventHandler.cs index 993297a..b4bcc2f 100644 --- a/src/Resilion/ResilienceEventHandler.cs +++ b/src/Resilion/ResilienceEventHandler.cs @@ -1,109 +1,101 @@ namespace Resilion; /// -/// A union type that holds either a synchronous or an asynchronous -/// returning . Implicit conversions -/// let the compiler pick the right path automatically. +/// A strategy event callback. Returns so one property can accept both a +/// synchronous and an asynchronous handler. /// /// 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). +/// A synchronous handler returns default and costs nothing: a completed +/// is a struct, so it allocates no more than an +/// would. Only a handler that genuinely suspends allocates, and that is unavoidable. /// /// /// -/// // Sync — zero overhead, no ValueTask wrapping: -/// OnRetry = (RetryAttemptEvent e) => logger.Log(e.AttemptNumber) +/// // Synchronous — the common case. +/// OnRetry = e => { logger.LogWarning("Retry {Attempt}", e.AttemptNumber); return default; } /// -/// // Async — when genuinely needed: -/// OnRetry = async (RetryAttemptEvent e) => await telemetry.TrackAsync(e) +/// // Asynchronous. +/// OnRetry = async e => await telemetry.TrackAsync(e) +/// +/// // A method group works too. +/// OnRetry = LogRetry; /// /// +/// +/// This was a wrapper struct with implicit conversions from and +/// Func<TArgs, ValueTask> before 1.0. It let a synchronous handler skip the +/// return default, but a struct is not a delegate type, so C# would not convert a bare +/// lambda (CS1660) or a method group (CS0428) to it — every callback had to be +/// written as new Action<TArgs>(e => ...). The wrapper was also documented as +/// avoiding ValueTask wrapping; measurement showed both shapes allocate 0 B/op for a +/// synchronous handler, so it was buying syntax, not speed, and charging a compiler error for it. +/// /// -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; - } +/// The event arguments. +/// A that completes when the handler is done. +public delegate ValueTask ResilienceEventHandler(TArgs args); +/// +/// Invocation helpers for . +/// +internal static class ResilienceEventHandlerExtensions +{ /// - /// Invokes the handler synchronously. If the handler is async, blocks until completion. + /// Runs a handler to completion on the calling thread, for the synchronous execution paths. /// - /// The event arguments. /// - /// For a genuinely async handler under load with a present - /// (WPF, WinForms, legacy ASP.NET), this costs two threads per invocation — one ThreadPool - /// thread running the handler plus the calling thread blocked on it. Prefer - /// from an async execution path when possible. + /// + /// A synchronous handler — one that returns default — completes inline here: no thread + /// hop, no blocking, no allocation. That is the 90% case and it stays free. + /// + /// + /// A handler that actually suspends has to be waited on, and waiting on the calling thread + /// while a is installed is the classic deadlock: the + /// handler's continuation is posted back to the very thread that is blocked waiting for it. + /// The context is therefore cleared for the duration of the call, so any continuation inside + /// the handler resumes on the thread pool instead and the wait can complete. + /// + /// + /// Clearing beats the obvious alternative of invoking the handler inside + /// Task.Run: that would push every handler onto the thread pool, including the + /// synchronous ones that never needed to leave this thread. + /// /// - internal void Invoke(TArgs args) + internal static void InvokeSync(this ResilienceEventHandler handler, TArgs args) { - if (_syncHandler is not null) + var previous = SynchronizationContext.Current; + if (previous is null) { - _syncHandler(args); + // Nothing to deadlock against. + var direct = handler(args); + if (direct.IsCompleted) + { + direct.GetAwaiter().GetResult(); // propagates a faulted handler; does not block + return; + } + + direct.AsTask().GetAwaiter().GetResult(); return; } - if (_asyncHandler is not null) + SynchronizationContext.SetSynchronizationContext(null); + try { - var handler = _asyncHandler; - - if (SynchronizationContext.Current is null) + var task = handler(args); + if (task.IsCompleted) { - // No context to deadlock against: call directly. Completes synchronously with - // no thread hop in the common case (e.g. a handler that checks a cached value). - handler(args).GetAwaiter().GetResult(); + task.GetAwaiter().GetResult(); return; } - // A SynchronizationContext is present — run the handler on the thread pool so its - // continuations resume there instead of trying to resume on this (now blocked) thread. - Task.Run(() => handler(args).AsTask()).GetAwaiter().GetResult(); + // Safe to block: with no context captured, the continuation resumes on the pool. + task.AsTask().GetAwaiter().GetResult(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); } } - - /// - /// 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/Resilion.csproj b/src/Resilion/Resilion.csproj index f6f4a72..4168530 100644 --- a/src/Resilion/Resilion.csproj +++ b/src/Resilion/Resilion.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Resilion/Retry/RetryStrategy.cs b/src/Resilion/Retry/RetryStrategy.cs index 47c0793..0c5fd53 100644 --- a/src/Resilion/Retry/RetryStrategy.cs +++ b/src/Resilion/Retry/RetryStrategy.cs @@ -67,9 +67,9 @@ protected internal override async ValueTask> ExecuteAsync Execute( ResilionTelemetry.RetryAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnRetry is { } handler && handler.HasHandler) + if (_options.OnRetry is { } handler) { - handler.Invoke(new RetryAttemptEvent(retryNumber, delay, outcome.Exception!, context)); + handler.InvokeSync(new RetryAttemptEvent(retryNumber, delay, outcome.Exception!, context)); } if (delay > TimeSpan.Zero) @@ -205,9 +205,9 @@ protected internal override async ValueTask> ExecuteAsync( ResilionTelemetry.RetryAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnRetry is { } handler && handler.HasHandler) + if (_options.OnRetry is { } handler) { - await handler.InvokeAsync( + await handler( new RetryAttemptEvent(retryNumber, delay, outcome, context)) .ConfigureAwait(false); } @@ -262,9 +262,9 @@ protected internal override Outcome Execute( ResilionTelemetry.RetryAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnRetry is { } handler && handler.HasHandler) + if (_options.OnRetry is { } handler) { - handler.Invoke(new RetryAttemptEvent(retryNumber, delay, outcome, context)); + handler.InvokeSync(new RetryAttemptEvent(retryNumber, delay, outcome, context)); } if (delay > TimeSpan.Zero) diff --git a/src/Resilion/Telemetry/ResilionTelemetry.cs b/src/Resilion/Telemetry/ResilionTelemetry.cs index 3bea7de..d6554a4 100644 --- a/src/Resilion/Telemetry/ResilionTelemetry.cs +++ b/src/Resilion/Telemetry/ResilionTelemetry.cs @@ -31,6 +31,16 @@ public static class ResilionTelemetry /// public const string OperationKeyTag = "operation.key"; + /// + /// Tag key identifying which chaos strategy injected, on + /// resilion.chaos.injections. Emitted only by the Resilion.Chaos package. + /// + /// + /// Chaos shares the meter rather than declaring its own, so a single + /// .AddMeter("Resilion") picks up injections alongside every other instrument. + /// + public const string ChaosStrategyTag = "chaos.strategy"; + // Pipeline-level execution count and duration instruments are planned for a future // telemetry-wrapping component. They are intentionally omitted here rather than declared // unused — the per-strategy counters below are the only instruments this meter emits today. @@ -58,4 +68,8 @@ public static class ResilionTelemetry internal static readonly Counter RateLimiterRejections = Meter.CreateCounter( "resilion.rate_limiter.rejections", description: "Rate limiter rejections."); + + internal static readonly Counter ChaosInjections = Meter.CreateCounter( + "resilion.chaos.injections", + description: "Chaos injections performed."); } diff --git a/src/Resilion/Timeout/TimeoutStrategy.cs b/src/Resilion/Timeout/TimeoutStrategy.cs index 4711af1..e04c990 100644 --- a/src/Resilion/Timeout/TimeoutStrategy.cs +++ b/src/Resilion/Timeout/TimeoutStrategy.cs @@ -276,9 +276,9 @@ private async ValueTask> HandleTimeout( { ResilionTelemetry.TimeoutExpirations.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnTimeout is { } handler && handler.HasHandler) + if (_options.OnTimeout is { } handler) { - await handler.InvokeAsync(new OnTimeoutArgs(context, timeout, elapsed)).ConfigureAwait(false); + await handler(new OnTimeoutArgs(context, timeout, elapsed)).ConfigureAwait(false); } return Outcome.FromException( @@ -292,9 +292,9 @@ private void HandleTimeoutSync( { ResilionTelemetry.TimeoutExpirations.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); - if (_options.OnTimeout is { } handler && handler.HasHandler) + if (_options.OnTimeout is { } handler) { - handler.Invoke(new OnTimeoutArgs(context, timeout, elapsed)); + handler.InvokeSync(new OnTimeoutArgs(context, timeout, elapsed)); } } } diff --git a/tests/Resilion.Chaos.Tests/ChaosBehaviorStrategyTests.cs b/tests/Resilion.Chaos.Tests/ChaosBehaviorStrategyTests.cs new file mode 100644 index 0000000..cd271f0 --- /dev/null +++ b/tests/Resilion.Chaos.Tests/ChaosBehaviorStrategyTests.cs @@ -0,0 +1,146 @@ +using Resilion.Chaos; +using Xunit; + +namespace Resilion.Chaos.Tests; + +public class ChaosBehaviorStrategyTests +{ + [Fact] + public async Task Async_AlwaysInject_InvokesBehavior() + { + var ran = 0; + var pipeline = Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Behavior = _ => { ran++; return default; }, + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + Assert.Equal(1, ran); + } + + [Fact] + public void Sync_AlwaysInject_InvokesBehavior() + { + var ran = 0; + var pipeline = Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Behavior = _ => { ran++; return default; }, + })); + + Assert.Equal("real", pipeline.Execute(ct => "real")); + Assert.Equal(1, ran); + } + + [Fact] + public async Task Async_NeverInject_DoesNotInvokeBehavior() + { + var ran = 0; + var pipeline = Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 1.0, + Behavior = _ => { ran++; return default; }, + })); + + await pipeline.ExecuteAsync(ct => new ValueTask("real")); + Assert.Equal(0, ran); + } + + [Fact] + public async Task Async_AsyncBehavior_IsAwaitedBeforeTheCallback() + { + var order = new List(); + var pipeline = Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Behavior = async _ => + { + await Task.Yield(); + order.Add("behavior"); + }, + })); + + await pipeline.ExecuteAsync(ct => { order.Add("callback"); return new ValueTask("real"); }); + + Assert.Equal(new[] { "behavior", "callback" }, order); + } + + [Fact] + public void Sync_AsyncBehavior_IsAwaitedNotFireAndForget() + { + // Guards against calling the delegate's own Invoke, which returns a ValueTask that is + // then discarded: the payload would start and the pipeline would carry on without it. + var completed = false; + var pipeline = Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Behavior = async _ => + { + await Task.Delay(50); + completed = true; + }, + })); + + Assert.Equal("real", pipeline.Execute(ct => "real")); + Assert.True(completed, "The async behavior was not awaited before the callback ran."); + } + + [Fact] + public async Task Async_BehaviorThrows_ExceptionPropagates() + { + // Deliberately not caught by the strategy: a behavior that throws is a fault the + // pipeline is supposed to see. + var pipeline = Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Behavior = _ => throw new InvalidOperationException("boom"), + })); + + var ex = await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + + Assert.Equal("boom", ex.Message); + } + + [Fact] + public async Task Async_Behavior_ReceivesTheExecutionContext() + { + string? seenKey = null; + var pipeline = Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Behavior = a => { seenKey = a.Context.OperationKey; return default; }, + })); + + // ExecuteOutcomeAsync is the only untyped entry point that accepts a caller-owned + // context, which is how OperationKey gets set. + var context = ResilienceContextPool.Shared.Rent(); + context.OperationKey = "op-key"; + try + { + await pipeline.ExecuteOutcomeAsync( + static (_, _) => new ValueTask>(Outcome.FromResult("real")), + 0, + context); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + + Assert.Equal("op-key", seenKey); + } + + [Fact] + public void Options_NoBehaviorHandler_ThrowsOnAdd() + => Assert.Throws( + () => Pipeline.Create(b => b.AddChaosBehavior(new ChaosBehaviorStrategyOptions()))); +} diff --git a/tests/Resilion.Chaos.Tests/ChaosFaultStrategyTests.cs b/tests/Resilion.Chaos.Tests/ChaosFaultStrategyTests.cs new file mode 100644 index 0000000..2ba37ef --- /dev/null +++ b/tests/Resilion.Chaos.Tests/ChaosFaultStrategyTests.cs @@ -0,0 +1,205 @@ +using Resilion.Chaos; +using Xunit; + +namespace Resilion.Chaos.Tests; + +public class ChaosFaultStrategyTests +{ + // ────────────────────────────────────────────────────────────────── + // Injection decision — driven by Randomizer, never by chance + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_AlwaysInject_ThrowsConfiguredFault() + { + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + var ex = await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + + Assert.Equal("injected", ex.Message); + } + + [Fact] + public async Task Async_NeverInject_PassesThrough() + { + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 1.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public void Sync_AlwaysInject_ThrowsConfiguredFault() + { + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + var ex = Assert.Throws(() => pipeline.Execute(ct => "real")); + Assert.Equal("injected", ex.Message); + } + + // ────────────────────────────────────────────────────────────────── + // Generator precedence + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_FaultGenerator_TakesPrecedenceOverStaticFault() + { + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Fault = new InvalidOperationException("static"), + FaultGenerator = _ => new TimeoutException("generated"), + })); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public async Task Async_FaultGeneratorReturningNull_PassesThrough() + { + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => null, + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public async Task Async_EnabledFalse_NeverInjects() + { + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + Enabled = false, + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public async Task Async_EnabledGenerator_TakesPrecedenceOverEnabled() + { + // The documented production guard: Enabled says yes, the generator says no. + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + Enabled = true, + EnabledGenerator = _ => false, + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public async Task Async_InjectionRateGenerator_TakesPrecedenceOverRate() + { + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 0.0, + InjectionRateGenerator = _ => 1.0, + Randomizer = () => 0.5, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public async Task Async_InjectionRateGeneratorOutOfRange_IsClamped() + { + // Clamped, not thrown: this runs mid-execution, where an exception would be + // indistinguishable from an injected fault. + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRateGenerator = _ => 42.0, + Randomizer = () => 0.99, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + + var never = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRateGenerator = _ => -5.0, + Randomizer = () => 0.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + })); + + Assert.Equal("real", await never.ExecuteAsync(ct => new ValueTask("real"))); + } + + // ────────────────────────────────────────────────────────────────── + // Callback and validation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OnFaultInjected_ReceivesTheInjectedFault() + { + Exception? seen = null; + var pipeline = Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + OnFaultInjected = a => { seen = a.Fault; return default; }, + })); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + + Assert.NotNull(seen); + Assert.Equal("injected", seen!.Message); + } + + [Fact] + public void Options_NeitherFaultNorGenerator_ThrowsOnAdd() + => Assert.Throws( + () => Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions()))); + + [Fact] + public void Options_InjectionRateOutOfRange_ThrowsOnAdd() + => Assert.Throws( + () => Pipeline.Create(b => b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.5, + Fault = new InvalidOperationException(), + }))); + + [Fact] + public async Task Async_ConvenienceOverload_InjectsAtGivenRate() + { + // Rate 1.0 with the default randomizer: Random.Shared.NextDouble() returns [0,1), + // which is always < 1.0, so this injects without needing a Randomizer. + var pipeline = Pipeline.Create(b => + b.AddChaosFault(1.0, () => new InvalidOperationException("injected"))); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } +} diff --git a/tests/Resilion.Chaos.Tests/ChaosLatencyStrategyTests.cs b/tests/Resilion.Chaos.Tests/ChaosLatencyStrategyTests.cs new file mode 100644 index 0000000..ba3d606 --- /dev/null +++ b/tests/Resilion.Chaos.Tests/ChaosLatencyStrategyTests.cs @@ -0,0 +1,175 @@ +using Microsoft.Extensions.Time.Testing; +using Resilion.Chaos; +using Xunit; + +namespace Resilion.Chaos.Tests; + +public class ChaosLatencyStrategyTests +{ + [Fact] + public async Task Async_AlwaysInject_DelaysByConfiguredLatency() + { + var time = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = time; + b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Latency = TimeSpan.FromSeconds(5), + }); + }); + + var task = pipeline.ExecuteAsync(ct => new ValueTask("real")).AsTask(); + + // Not yet: the delay is driven by the fake clock, so nothing has elapsed. + Assert.False(task.IsCompleted); + + time.Advance(TimeSpan.FromSeconds(5)); + Assert.Equal("real", await task); + } + + [Fact] + public async Task Async_UsesBuilderTimeProvider_NotSystem() + { + // If the strategy read TimeProvider.System this would hang for 10 real minutes rather + // than completing the moment the fake clock advances. + var time = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = time; + b.AddChaosLatency(1.0, TimeSpan.FromMinutes(10)); + }); + + var task = pipeline.ExecuteAsync(ct => new ValueTask("real")).AsTask(); + time.Advance(TimeSpan.FromMinutes(10)); + + Assert.Equal("real", await task.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Fact] + public async Task Async_NeverInject_DoesNotDelay() + { + var time = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = time; + b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 1.0, + Latency = TimeSpan.FromHours(1), + }); + }); + + // Completes without the clock moving at all. WaitAsync so that a regression here fails + // fast instead of hanging CI forever on a delay the fake clock will never advance. + var task = pipeline.ExecuteAsync(ct => new ValueTask("real")).AsTask(); + Assert.Equal("real", await task.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Fact] + public async Task Async_LatencyGenerator_TakesPrecedence() + { + var time = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = time; + b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Latency = TimeSpan.FromHours(1), + LatencyGenerator = _ => TimeSpan.FromSeconds(2), + }); + }); + + var task = pipeline.ExecuteAsync(ct => new ValueTask("real")).AsTask(); + time.Advance(TimeSpan.FromSeconds(2)); + + Assert.Equal("real", await task); + } + + [Fact] + public async Task Async_NegativeLatency_ClampedToZero() + { + var time = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = time; + b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + LatencyGenerator = _ => TimeSpan.FromSeconds(-30), + }); + }); + + // Clamped, so it completes without advancing the clock. WaitAsync for the same + // fail-fast reason as above. + var task = pipeline.ExecuteAsync(ct => new ValueTask("real")).AsTask(); + Assert.Equal("real", await task.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Fact] + public async Task Async_Cancellation_DuringInjectedLatency_Propagates() + { + var time = new FakeTimeProvider(); + using var cts = new CancellationTokenSource(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = time; + b.AddChaosLatency(1.0, TimeSpan.FromMinutes(5)); + }); + + var task = pipeline.ExecuteAsync(ct => new ValueTask("real"), cts.Token).AsTask(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => task); + } + + [Fact] + public void Sync_AlwaysInject_DelaysByConfiguredLatency() + { + // The sync path waits on CancellationToken.WaitHandle, which has no TimeProvider + // equivalent, so this is real wall-clock time — kept tiny deliberately. Documented on + // the strategy; same limitation as synchronous retry delays (future-plans #9). + var pipeline = Pipeline.Create(b => b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Latency = TimeSpan.FromMilliseconds(120), + })); + + var started = DateTimeOffset.UtcNow; + Assert.Equal("real", pipeline.Execute(ct => "real")); + + Assert.True( + DateTimeOffset.UtcNow - started >= TimeSpan.FromMilliseconds(80), + "Sync latency injection should have blocked for roughly the configured delay."); + } + + [Fact] + public void Sync_NeverInject_DoesNotDelay() + { + var pipeline = Pipeline.Create(b => b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 1.0, + Latency = TimeSpan.FromSeconds(30), + })); + + var started = DateTimeOffset.UtcNow; + Assert.Equal("real", pipeline.Execute(ct => "real")); + Assert.True(DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Options_NegativeLatency_ThrowsOnAdd() + => Assert.Throws( + () => Pipeline.Create(b => b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + Latency = TimeSpan.FromSeconds(-1), + }))); +} diff --git a/tests/Resilion.Chaos.Tests/ChaosOrderingTests.cs b/tests/Resilion.Chaos.Tests/ChaosOrderingTests.cs new file mode 100644 index 0000000..66e3bc8 --- /dev/null +++ b/tests/Resilion.Chaos.Tests/ChaosOrderingTests.cs @@ -0,0 +1,47 @@ +using Resilion.Chaos; +using Xunit; + +namespace Resilion.Chaos.Tests; + +public class ChaosOrderingTests +{ + [Fact] + public void ChaosNotInnermost_DoesNotThrow() + { + // The not-innermost rule is a warning, not an error, so building must still succeed. + // The warning text itself is asserted in Resilion.Tests' OrderingValidatorTests, which + // can reach the internal validator directly. + var pipeline = Pipeline.Create(b => b + .AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 0.0, + Fault = new InvalidOperationException(), + }) + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 1 })); + + Assert.NotNull(pipeline); + } + + [Fact] + public async Task ChaosInnermost_IsObservedByAnOuterRetry() + { + // The end-to-end counterpart to the ordering rule: added last, the fault is inside the + // retry, so the retry sees it and recovers. + var injections = 0; + var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Constant(TimeSpan.Zero), + }) + .AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRateGenerator = _ => injections < 2 ? 1.0 : 0.0, + Randomizer = () => 0.0, + FaultGenerator = _ => { injections++; return new InvalidOperationException("injected"); }, + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + Assert.Equal(2, injections); + } +} diff --git a/tests/Resilion.Chaos.Tests/ChaosOutcomeStrategyTests.cs b/tests/Resilion.Chaos.Tests/ChaosOutcomeStrategyTests.cs new file mode 100644 index 0000000..acb593a --- /dev/null +++ b/tests/Resilion.Chaos.Tests/ChaosOutcomeStrategyTests.cs @@ -0,0 +1,149 @@ +using Resilion.Chaos; +using Xunit; + +namespace Resilion.Chaos.Tests; + +public class ChaosOutcomeStrategyTests +{ + [Fact] + public async Task Async_AlwaysInject_ReturnsConfiguredOutcome() + { + var pipeline = Pipeline.Create(b => b.AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Outcome = Outcome.FromResult("chaos"), + })); + + Assert.Equal("chaos", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public void Sync_AlwaysInject_ReturnsConfiguredOutcome() + { + var pipeline = Pipeline.Create(b => b.AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Outcome = Outcome.FromResult("chaos"), + })); + + Assert.Equal("chaos", pipeline.Execute(ct => "real")); + } + + [Fact] + public async Task Async_NeverInject_PassesThrough() + { + var pipeline = Pipeline.Create(b => b.AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 1.0, + Outcome = Outcome.FromResult("chaos"), + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public async Task Async_OutcomeGenerator_TakesPrecedence() + { + var pipeline = Pipeline.Create(b => b.AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Outcome = Outcome.FromResult("static"), + OutcomeGenerator = _ => Outcome.FromResult("generated"), + })); + + Assert.Equal("generated", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + // ────────────────────────────────────────────────────────────────── + // The reason chaos must be innermost + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_InjectedFailureOutcome_IsSeenByOuterRetry() + { + // Chaos is added last, so it sits inside Retry and Retry observes the injected failure. + // This is the whole point of the innermost rule: with the order reversed the retry would + // never see the fault and the chaos run would silently prove nothing. + var injections = 0; + var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.Constant(TimeSpan.Zero), + ShouldHandle = o => o.IsFailure, + }) + .AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + // Inject on the first two attempts, then let the real call through. + InjectionRateGenerator = _ => injections < 2 ? 1.0 : 0.0, + Randomizer = () => 0.0, + OutcomeGenerator = _ => + { + injections++; + return Outcome.FromException(new InvalidOperationException("injected")); + }, + })); + + Assert.Equal("real", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + Assert.Equal(2, injections); + } + + [Fact] + public async Task Async_InjectedResultValue_IsSeenByResultBasedRetry() + { + // A result-only predicate: a chaos *fault* could never trigger this, which is exactly + // why AddChaosOutcome exists alongside AddChaosFault. + var attempts = 0; + var injected = false; + var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.Constant(TimeSpan.Zero), + ShouldHandle = o => o.TryGetResult(out var r) && r == 503, + }) + .AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + InjectionRateGenerator = _ => injected ? 0.0 : 1.0, + Randomizer = () => 0.0, + OutcomeGenerator = _ => { injected = true; return Outcome.FromResult(503); }, + })); + + var result = await pipeline.ExecuteAsync(ct => { attempts++; return new ValueTask(200); }); + + Assert.Equal(200, result); + Assert.Equal(1, attempts); + } + + [Fact] + public void Options_NeitherOutcomeNorGenerator_ThrowsOnAdd() + => Assert.Throws( + () => Pipeline.Create(b => b.AddChaosOutcome(new ChaosOutcomeStrategyOptions()))); + + [Fact] + public async Task Async_ConvenienceOverload_InjectsResult() + { + var pipeline = Pipeline.Create(b => b.AddChaosOutcome(1.0, () => "chaos")); + Assert.Equal("chaos", await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + } + + [Fact] + public async Task Async_OnOutcomeInjected_ReceivesTheInjectedOutcome() + { + string? seen = null; + var pipeline = Pipeline.Create(b => b.AddChaosOutcome(new ChaosOutcomeStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Outcome = Outcome.FromResult("chaos"), + OnOutcomeInjected = a => { seen = a.Outcome.GetResultOrDefault(); return default; }, + })); + + await pipeline.ExecuteAsync(ct => new ValueTask("real")); + Assert.Equal("chaos", seen); + } +} diff --git a/tests/Resilion.Chaos.Tests/ChaosTelemetryTests.cs b/tests/Resilion.Chaos.Tests/ChaosTelemetryTests.cs new file mode 100644 index 0000000..a6c14c6 --- /dev/null +++ b/tests/Resilion.Chaos.Tests/ChaosTelemetryTests.cs @@ -0,0 +1,192 @@ +using System.Diagnostics.Metrics; +using Resilion.Chaos; +using Xunit; + +namespace Resilion.Chaos.Tests; + +public class ChaosTelemetryTests +{ + /// + /// Records resilion.chaos.injections for one pipeline name. + /// + /// + /// The filter on pipeline name is load-bearing, not tidiness: is + /// process-global and xunit runs test classes in parallel, so without it a listener sees + /// injections from whatever else is running and the assertions become flaky. + /// + private sealed class InjectionTracker : IDisposable + { + private readonly MeterListener _listener; + private readonly string _pipelineName; + private readonly object _gate = new(); + + internal List<(string Strategy, string? OperationKey)> Injections { get; } = []; + + internal InjectionTracker(string pipelineName) + { + _pipelineName = pipelineName; + _listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == ResilionTelemetry.MeterName + && instrument.Name == "resilion.chaos.injections") + { + listener.EnableMeasurementEvents(instrument); + } + }, + }; + + _listener.SetMeasurementEventCallback((_, _, tags, _) => + { + string? pipeline = null, strategy = null, operationKey = null; + foreach (var tag in tags) + { + switch (tag.Key) + { + case ResilionTelemetry.PipelineNameTag: pipeline = tag.Value as string; break; + case ResilionTelemetry.ChaosStrategyTag: strategy = tag.Value as string; break; + case ResilionTelemetry.OperationKeyTag: operationKey = tag.Value as string; break; + } + } + + if (pipeline != _pipelineName) + { + return; + } + + lock (_gate) + { + Injections.Add((strategy ?? "", operationKey)); + } + }); + + _listener.Start(); + } + + public void Dispose() => _listener.Dispose(); + } + + [Fact] + public async Task Injection_IncrementsChaosInjectionsCounter() + { + const string name = "chaos-telemetry-increments"; + using var tracker = new InjectionTracker(name); + + var pipeline = Pipeline.Create(b => + { + b.Name = name; + b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + }); + }); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(ct => new ValueTask("real"))); + + Assert.Single(tracker.Injections); + } + + [Fact] + public async Task Injection_CounterCarriesPipelineNameAndChaosStrategyTags() + { + const string name = "chaos-telemetry-tags"; + using var tracker = new InjectionTracker(name); + + var pipeline = Pipeline.Create(b => + { + b.Name = name; + b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Latency = TimeSpan.Zero, + }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask("real")); + + var injection = Assert.Single(tracker.Injections); + Assert.Equal("latency", injection.Strategy); + } + + [Fact] + public async Task Injection_EachStrategyReportsItsOwnName() + { + const string name = "chaos-telemetry-strategy-names"; + using var tracker = new InjectionTracker(name); + + var pipeline = Pipeline.Create(b => + { + b.Name = name; + b.SuppressOrderingWarnings = true; + b.AddChaosBehavior(new ChaosBehaviorStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Behavior = _ => default, + }); + b.AddChaosLatency(new ChaosLatencyStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + Latency = TimeSpan.Zero, + }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask("real")); + + Assert.Equal( + new[] { "behavior", "latency" }, + tracker.Injections.Select(i => i.Strategy).OrderBy(s => s).ToArray()); + } + + [Fact] + public async Task NoInjection_DoesNotIncrementCounter() + { + const string name = "chaos-telemetry-no-injection"; + using var tracker = new InjectionTracker(name); + + var pipeline = Pipeline.Create(b => + { + b.Name = name; + b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 1.0, + FaultGenerator = _ => new InvalidOperationException("injected"), + }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask("real")); + + Assert.Empty(tracker.Injections); + } + + [Fact] + public async Task FaultGeneratorReturningNull_DoesNotIncrementCounter() + { + // The injection decision was taken but produced nothing, so nothing was injected and + // the counter must not claim otherwise. + const string name = "chaos-telemetry-null-fault"; + using var tracker = new InjectionTracker(name); + + var pipeline = Pipeline.Create(b => + { + b.Name = name; + b.AddChaosFault(new ChaosFaultStrategyOptions + { + InjectionRate = 1.0, + Randomizer = () => 0.0, + FaultGenerator = _ => null, + }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask("real")); + + Assert.Empty(tracker.Injections); + } +} diff --git a/tests/Resilion.Chaos.Tests/Resilion.Chaos.Tests.csproj b/tests/Resilion.Chaos.Tests/Resilion.Chaos.Tests.csproj new file mode 100644 index 0000000..d7c50c6 --- /dev/null +++ b/tests/Resilion.Chaos.Tests/Resilion.Chaos.Tests.csproj @@ -0,0 +1,13 @@ + + + + Resilion.Chaos.Tests + Resilion.Chaos.Tests + + + + + + + + diff --git a/tests/Resilion.Http.Tests/TypedStrategyBindingTests.cs b/tests/Resilion.Http.Tests/TypedStrategyBindingTests.cs index 5c36eee..16dbd28 100644 --- a/tests/Resilion.Http.Tests/TypedStrategyBindingTests.cs +++ b/tests/Resilion.Http.Tests/TypedStrategyBindingTests.cs @@ -250,12 +250,11 @@ public async Task Async_GetRequestMessage_ReturnsTheAttemptsRequest() MaxRetryAttempts = 1, Delay = RetryDelay.None, ShouldHandle = HttpResiliencePredicates.IsTransient, - // Needs the explicit Action: ResilienceEventHandler is not itself a - // delegate type, so a bare lambda does not convert (CS1660). - OnRetry = new Action>(args => + OnRetry = args => { lock (seen) { seen.Add(args.Context.GetRequestMessage()?.RequestUri); } - }), + return default; + }, }))); using var scope = provider; diff --git a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs index 7bd5c66..b980bbd 100644 --- a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs +++ b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs @@ -114,8 +114,11 @@ public async Task Open_AfterBreakDuration_TransitionsToHalfOpen_ThenClosed() { var fakeTime = new FakeTimeProvider(); var stateChanges = new List<(CircuitState From, CircuitState To)>(); - Action onState = e => + ResilienceEventHandler onState = e => + { stateChanges.Add((e.PreviousState, e.CurrentState)); + return default; + }; var pipeline = Pipeline.Create(b => { diff --git a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerTypedStrategyTests.cs b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerTypedStrategyTests.cs index 769e7b7..8bc154a 100644 --- a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerTypedStrategyTests.cs +++ b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerTypedStrategyTests.cs @@ -129,9 +129,21 @@ public async Task Open_AfterBreakDuration_TransitionsToHalfOpen_ThenClosed() var opened = new List(); var halfOpened = new List(); var closed = new List(); - Action onOpened = e => opened.Add(e); - Action onHalfOpened = e => halfOpened.Add(e); - Action onClosed = e => closed.Add(e); + ResilienceEventHandler onOpened = e => + { + opened.Add(e); + return default; + }; + ResilienceEventHandler onHalfOpened = e => + { + halfOpened.Add(e); + return default; + }; + ResilienceEventHandler onClosed = e => + { + closed.Add(e); + return default; + }; var pipeline = Pipeline.Create(b => { diff --git a/tests/Resilion.Tests/Fallback/FallbackStrategyTests.cs b/tests/Resilion.Tests/Fallback/FallbackStrategyTests.cs index 50ff14c..89e0cf4 100644 --- a/tests/Resilion.Tests/Fallback/FallbackStrategyTests.cs +++ b/tests/Resilion.Tests/Fallback/FallbackStrategyTests.cs @@ -179,7 +179,11 @@ await Assert.ThrowsAsync(() => public async Task Async_OnFallback_IsFired() { OnFallbackEvent? capturedEvent = null; - Action> onFallback = e => capturedEvent = e; + ResilienceEventHandler> onFallback = e => + { + capturedEvent = e; + return default; + }; var pipeline = Pipeline.Create(b => b.AddFallback( new FallbackStrategyOptions diff --git a/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs index 3dbf0c6..92ae842 100644 --- a/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs +++ b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs @@ -175,8 +175,11 @@ public async Task ActionGenerator_CustomActionsPerAttempt() public async Task OnHedging_FiredForEachHedgedAttempt() { var hedgingEvents = new List(); - Action> onHedging = e => + ResilienceEventHandler> onHedging = e => + { hedgingEvents.Add(e.AttemptNumber); + return default; + }; var pipeline = Pipeline.Create(b => b.AddHedging( new HedgingStrategyOptions diff --git a/tests/Resilion.Tests/Integration/CompositionTests.cs b/tests/Resilion.Tests/Integration/CompositionTests.cs index 0d194e9..c29175f 100644 --- a/tests/Resilion.Tests/Integration/CompositionTests.cs +++ b/tests/Resilion.Tests/Integration/CompositionTests.cs @@ -72,7 +72,11 @@ public async Task CanonicalFiveStrategyPipeline_RetryRecoversFromTransientFailur public async Task AddPipeline_ComposesTwoPrebuiltPipelines_BothRun() { var innerCalls = new List(); - Action onInnerRetry = e => innerCalls.Add("inner-retry"); + ResilienceEventHandler onInnerRetry = e => + { + innerCalls.Add("inner-retry"); + return default; + }; var innerRetryPipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions { diff --git a/tests/Resilion.Tests/OrderingValidatorTests.cs b/tests/Resilion.Tests/OrderingValidatorTests.cs index 61991d5..b95596d 100644 --- a/tests/Resilion.Tests/OrderingValidatorTests.cs +++ b/tests/Resilion.Tests/OrderingValidatorTests.cs @@ -260,4 +260,90 @@ public void SuppressWarnings_NoWarningsEmitted_AndDoesNotThrow() Assert.NotNull(pipeline); Assert.Empty(captured); } + + // ────────────────────────────────────────────────────────────────── + // Chaos must be innermost (Resilion.Chaos) + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void ChaosInnermost_NoWarning() + { + var strategies = new List + { + StrategyType.Timeout, + StrategyType.Retry, + StrategyType.CircuitBreaker, + StrategyType.Chaos, + }; + + var result = OrderingValidator.Validate(strategies); + Assert.Empty(result.Errors); + Assert.Empty(result.Warnings); + } + + [Fact] + public void ChaosOutsideRetry_EmitsNotInnermostWarning() + { + // The chaos fault is injected before Retry can see it, so the retry never observes a + // failure and the experiment silently proves nothing. + var strategies = new List + { + StrategyType.Chaos, + StrategyType.Retry, + }; + + var result = OrderingValidator.Validate(strategies); + Assert.Empty(result.Errors); + Assert.Contains(result.Warnings, w => w.Contains("not innermost")); + } + + [Fact] + public void MultipleChaosStrategiesInnermost_NoWarning() + { + // Stacking chaos strategies at the end is legitimate — latency then fault, say. + var strategies = new List + { + StrategyType.Timeout, + StrategyType.Retry, + StrategyType.Chaos, + StrategyType.Chaos, + }; + + var result = OrderingValidator.Validate(strategies); + + // Assert on the chaos rule specifically rather than on an empty collection: the other + // rules are free to warn about this shape for their own reasons. + Assert.DoesNotContain(result.Warnings, w => w.Contains("not innermost")); + } + + [Fact] + public void ChaosSplitAcrossThePipeline_WarnsOnlyForTheOuterOne() + { + var strategies = new List + { + StrategyType.Chaos, // position 0 — not in the trailing chaos block + StrategyType.Retry, + StrategyType.Chaos, // position 2 — innermost, fine + }; + + var result = OrderingValidator.Validate(strategies); + var warning = Assert.Single(result.Warnings, w => w.Contains("not innermost")); + Assert.Contains("position 0", warning); + } + + [Fact] + public void ChaosNotInnermost_IsWarningNotError() + { + // Deliberately advisory: a mid-pipeline injection between an outer and an inner pipeline + // is conceivable, so this must not block a build. + var strategies = new List + { + StrategyType.Chaos, + StrategyType.Timeout, + }; + + var result = OrderingValidator.Validate(strategies); + Assert.Empty(result.Errors); + Assert.NotEmpty(result.Warnings); + } } diff --git a/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs b/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs index 4b7a8f1..640c47a 100644 --- a/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs +++ b/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs @@ -170,7 +170,11 @@ public async Task Async_OnRejected_IsFired() }); OnRateLimitRejectedEvent? capturedEvent = null; - Action onRejected = e => capturedEvent = e; + ResilienceEventHandler onRejected = e => + { + capturedEvent = e; + return default; + }; var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions { diff --git a/tests/Resilion.Tests/ResilienceEventHandlerTests.cs b/tests/Resilion.Tests/ResilienceEventHandlerTests.cs index 06cf406..1586fd3 100644 --- a/tests/Resilion.Tests/ResilienceEventHandlerTests.cs +++ b/tests/Resilion.Tests/ResilienceEventHandlerTests.cs @@ -6,62 +6,254 @@ public class ResilienceEventHandlerTests { private readonly record struct TestEvent(string Message); + private static string? _fromMethodGroup; + + private static ValueTask RecordIt(TestEvent e) + { + _fromMethodGroup = e.Message; + return default; + } + + // ────────────────────────────────────────────────────────────────── + // The shapes that must compile — this is the whole point of the type + // being a delegate rather than a wrapper struct (future-plans #62) + // ────────────────────────────────────────────────────────────────── + [Fact] - public async Task SyncHandler_InvokedViaInvokeAsync() + public async Task BareLambda_SynchronousBody_Converts() { + // Previously CS1660: a lambda cannot convert to a non-delegate type, so every callback + // had to be written as new Action(e => ...). string? captured = null; - Action action = e => { captured = e.Message; }; - ResilienceEventHandler handler = action; + ResilienceEventHandler handler = e => { captured = e.Message; return default; }; - Assert.True(handler.HasHandler); - await handler.InvokeAsync(new TestEvent("hello")); + await handler(new TestEvent("hello")); Assert.Equal("hello", captured); } [Fact] - public async Task AsyncHandler_InvokedViaInvokeAsync() + public async Task BareLambda_AsyncBody_Converts() { string? captured = null; - Func func = async e => + ResilienceEventHandler handler = async e => { await Task.Yield(); captured = e.Message; }; - ResilienceEventHandler handler = func; - Assert.True(handler.HasHandler); - await handler.InvokeAsync(new TestEvent("async")); + await handler(new TestEvent("async")); Assert.Equal("async", captured); } [Fact] - public void SyncHandler_InvokedViaInvoke() + public async Task MethodGroup_Converts() + { + // Previously CS0428. + _fromMethodGroup = null; + ResilienceEventHandler handler = RecordIt; + + await handler(new TestEvent("method group")); + + Assert.Equal("method group", _fromMethodGroup); + } + + // ────────────────────────────────────────────────────────────────── + // InvokeSync — the synchronous execution path + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void InvokeSync_SynchronousHandler_RunsInlineOnTheCallingThread() + { + var callingThread = Environment.CurrentManagedThreadId; + var ranOn = 0; + ResilienceEventHandler handler = _ => + { + ranOn = Environment.CurrentManagedThreadId; + return default; + }; + + handler.InvokeSync(new TestEvent("sync")); + + Assert.Equal(callingThread, ranOn); + } + + [Fact] + public void InvokeSync_AsynchronousHandler_CompletesAndObservesResult() { string? captured = null; - Action action = e => { captured = e.Message; }; - ResilienceEventHandler handler = action; + ResilienceEventHandler handler = async e => + { + await Task.Yield(); + captured = e.Message; + }; + + handler.InvokeSync(new TestEvent("awaited")); + + Assert.Equal("awaited", captured); + } + + [Fact] + public void InvokeSync_HandlerThrows_PropagatesSynchronously() + { + ResilienceEventHandler handler = _ => throw new InvalidOperationException("boom"); + + var ex = Assert.Throws(() => handler.InvokeSync(new TestEvent("x"))); + Assert.Equal("boom", ex.Message); + } + + [Fact] + public void InvokeSync_AsyncHandlerThrows_PropagatesSynchronously() + { + ResilienceEventHandler handler = async _ => + { + await Task.Yield(); + throw new InvalidOperationException("async boom"); + }; + + var ex = Assert.Throws(() => handler.InvokeSync(new TestEvent("x"))); + Assert.Equal("async boom", ex.Message); + } + + // ────────────────────────────────────────────────────────────────── + // SynchronizationContext — the deadlock this path exists to avoid + // ────────────────────────────────────────────────────────────────── + + /// + /// A context that never runs posted work. Any continuation that captures it is lost, so a + /// handler whose continuation is posted here can never complete — which is exactly the + /// deadlock a WPF or WinForms caller would hit. + /// + private sealed class DeadEndSynchronizationContext : SynchronizationContext + { + private int _posts; + + internal int Posts => Volatile.Read(ref _posts); - handler.Invoke(new TestEvent("sync")); + public override void Post(SendOrPostCallback d, object? state) => Interlocked.Increment(ref _posts); - Assert.Equal("sync", captured); + public override void Send(SendOrPostCallback d, object? state) => Interlocked.Increment(ref _posts); + } + + private static (bool Completed, T? Result, Exception? Error) RunOnContext( + SynchronizationContext context, + Func body, + TimeSpan timeout) + { + T? result = default; + Exception? error = null; + var thread = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(context); + try { result = body(); } + catch (Exception ex) { error = ex; } + }) + { IsBackground = true }; + + thread.Start(); + return (thread.Join(timeout), result, error); } [Fact] - public void DefaultHandler_HasNoHandler() + public async Task InvokeSync_AsyncHandlerUnderSynchronizationContext_DoesNotDeadlock() { - ResilienceEventHandler handler = default; + // Without clearing the context, the handler's continuation would be posted back to the + // very thread blocked waiting for it, and this would never return. + var context = new DeadEndSynchronizationContext(); + var captured = new TaskCompletionSource(); - Assert.False(handler.HasHandler); + ResilienceEventHandler handler = async e => + { + await Task.Yield(); + captured.TrySetResult(e.Message); + }; + + var (completed, _, error) = RunOnContext( + context, + () => { handler.InvokeSync(new TestEvent("no deadlock")); return true; }, + TimeSpan.FromSeconds(10)); + + Assert.True(completed, "InvokeSync deadlocked under a SynchronizationContext."); + Assert.Null(error); + + // The thread has been joined, so InvokeSync has already returned and the handler it + // waited on has finished; awaiting here just reads the value across the memory barrier. + Assert.True(captured.Task.IsCompleted); + Assert.Equal("no deadlock", await captured.Task); } [Fact] - public async Task DefaultHandler_InvokeAsync_DoesNotThrow() + public void InvokeSync_SynchronousHandlerUnderSynchronizationContext_DoesNotHopThreads() { - ResilienceEventHandler handler = default; + // The reason the context is cleared rather than the handler being pushed onto the thread + // pool: a synchronous handler must not pay for a thread hop it never needed. + var context = new DeadEndSynchronizationContext(); + var ranOn = 0; + var invokedOn = 0; + + ResilienceEventHandler handler = _ => + { + ranOn = Environment.CurrentManagedThreadId; + return default; + }; + + var (completed, _, error) = RunOnContext( + context, + () => + { + invokedOn = Environment.CurrentManagedThreadId; + handler.InvokeSync(new TestEvent("inline")); + return true; + }, + TimeSpan.FromSeconds(10)); + + Assert.True(completed); + Assert.Null(error); + Assert.Equal(invokedOn, ranOn); + Assert.Equal(0, context.Posts); + } + + [Fact] + public void InvokeSync_RestoresTheAmbientSynchronizationContext() + { + var context = new DeadEndSynchronizationContext(); + ResilienceEventHandler handler = async _ => await Task.Yield(); + + var (completed, same, error) = RunOnContext( + context, + () => + { + handler.InvokeSync(new TestEvent("x")); + return ReferenceEquals(SynchronizationContext.Current, context); + }, + TimeSpan.FromSeconds(10)); + + Assert.True(completed); + Assert.Null(error); + Assert.True(same, "InvokeSync must restore the caller's SynchronizationContext."); + } + + // ────────────────────────────────────────────────────────────────── + // Cost + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void SynchronousHandler_AllocatesNothingPerInvocation() + { + // The claim that replaced the old wrapper struct: returning a completed ValueTask costs + // no more than an Action would, because ValueTask is a struct. + ResilienceEventHandler handler = _ => default; + var args = new TestEvent("x"); + + handler.InvokeSync(args); // JIT warm-up + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1_000; i++) + { + handler.InvokeSync(args); + } - // Should not throw - await handler.InvokeAsync(new TestEvent("ignored")); + Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); } } diff --git a/tests/Resilion.Tests/Retry/RetryStrategyTests.cs b/tests/Resilion.Tests/Retry/RetryStrategyTests.cs index 1c6d8c7..bd9e9ee 100644 --- a/tests/Resilion.Tests/Retry/RetryStrategyTests.cs +++ b/tests/Resilion.Tests/Retry/RetryStrategyTests.cs @@ -173,7 +173,11 @@ await Assert.ThrowsAsync(() => public async Task Async_OnRetry_FiredForEachRetry() { var retryEvents = new List(); - Action onRetry = e => retryEvents.Add(e); + ResilienceEventHandler onRetry = e => + { + retryEvents.Add(e); + return default; + }; var callCount = 0; var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions @@ -420,7 +424,11 @@ public async Task MaxDelay_CapsComputedDelayFromCustomDelegate() { var fakeTime = new FakeTimeProvider(); TimeSpan? capturedDelay = null; - Action onRetry = e => capturedDelay = e.RetryDelay; + ResilienceEventHandler onRetry = e => + { + capturedDelay = e.RetryDelay; + return default; + }; var pipeline = Pipeline.Create(b => { diff --git a/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs b/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs index 2d75689..ccf0122 100644 --- a/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs +++ b/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs @@ -165,7 +165,7 @@ public async Task Async_OnTimeout_IsFired() var fakeTime = new FakeTimeProvider(); OnTimeoutArgs? capturedArgs = null; - Action onTimeout = args => { capturedArgs = args; }; + ResilienceEventHandler onTimeout = args => { capturedArgs = args; return default; }; var pipeline = Pipeline.Create(b => { From effdf6899be631ef950cd805f63161564a42001e Mon Sep 17 00:00:00 2001 From: Ali-Tegna <31090457+FreakyAli@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:22:08 +0530 Subject: [PATCH 2/5] chore: freeze public API and cut 1.0.0 --- CHANGELOG.md | 7 +- src/Resilion.Chaos/PublicAPI.Shipped.txt | 84 +++++++++++++++++++ src/Resilion.Chaos/PublicAPI.Unshipped.txt | 84 ------------------- src/Resilion.Extensions/PublicAPI.Shipped.txt | 66 +++++++++++++++ .../PublicAPI.Unshipped.txt | 66 --------------- src/Resilion.Http/PublicAPI.Shipped.txt | 50 +++++++++++ src/Resilion.Http/PublicAPI.Unshipped.txt | 50 ----------- src/Resilion/PublicAPI.Shipped.txt | 37 ++++++++ src/Resilion/PublicAPI.Unshipped.txt | 37 -------- 9 files changed, 243 insertions(+), 238 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4834627..80e4537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +_No changes yet._ + +## [1.0.0] - 2026-10-25 + ### Added - **`Resilion.Http` — `HttpClient` and `IHttpClientFactory` integration.** The most common use for @@ -334,5 +338,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added an XML doc `` on `Outcome`'s implicit operator warning about the `Outcome` success-vs-failure ambiguity - Documented ordering validation (`ThrowOnOrderingErrors`, `SuppressOrderingWarnings`) in `docs/pipelines.md` -[Unreleased]: https://github.com/FreakyAli/Resilion/compare/v1.0.0-pre...HEAD +[Unreleased]: https://github.com/FreakyAli/Resilion/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/FreakyAli/Resilion/compare/v1.0.0-pre...v1.0.0 [1.0.0-pre]: https://github.com/FreakyAli/Resilion/releases/tag/v1.0.0-pre diff --git a/src/Resilion.Chaos/PublicAPI.Shipped.txt b/src/Resilion.Chaos/PublicAPI.Shipped.txt index 7dc5c58..946a1d1 100644 --- a/src/Resilion.Chaos/PublicAPI.Shipped.txt +++ b/src/Resilion.Chaos/PublicAPI.Shipped.txt @@ -1 +1,85 @@ #nullable enable +Resilion.Chaos.ChaosBehaviorExtensions +Resilion.Chaos.ChaosBehaviorStrategyOptions +Resilion.Chaos.ChaosBehaviorStrategyOptions.Behavior.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosBehaviorStrategyOptions.Behavior.init -> void +Resilion.Chaos.ChaosBehaviorStrategyOptions.OnBehaviorInjected.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosBehaviorStrategyOptions.OnBehaviorInjected.init -> void +Resilion.Chaos.ChaosFaultExtensions +Resilion.Chaos.ChaosFaultStrategyOptions +Resilion.Chaos.ChaosFaultStrategyOptions.Fault.get -> System.Exception? +Resilion.Chaos.ChaosFaultStrategyOptions.Fault.init -> void +Resilion.Chaos.ChaosFaultStrategyOptions.FaultGenerator.get -> System.Func? +Resilion.Chaos.ChaosFaultStrategyOptions.FaultGenerator.init -> void +Resilion.Chaos.ChaosFaultStrategyOptions.OnFaultInjected.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosFaultStrategyOptions.OnFaultInjected.init -> void +Resilion.Chaos.ChaosGeneratorArgs +Resilion.Chaos.ChaosGeneratorArgs.ChaosGeneratorArgs() -> void +Resilion.Chaos.ChaosGeneratorArgs.ChaosGeneratorArgs(Resilion.ResilienceContext! Context) -> void +Resilion.Chaos.ChaosGeneratorArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.ChaosGeneratorArgs.Context.init -> void +Resilion.Chaos.ChaosLatencyExtensions +Resilion.Chaos.ChaosLatencyStrategyOptions +Resilion.Chaos.ChaosLatencyStrategyOptions.Latency.get -> System.TimeSpan +Resilion.Chaos.ChaosLatencyStrategyOptions.Latency.init -> void +Resilion.Chaos.ChaosLatencyStrategyOptions.LatencyGenerator.get -> System.Func? +Resilion.Chaos.ChaosLatencyStrategyOptions.LatencyGenerator.init -> void +Resilion.Chaos.ChaosLatencyStrategyOptions.OnLatencyInjected.get -> Resilion.ResilienceEventHandler? +Resilion.Chaos.ChaosLatencyStrategyOptions.OnLatencyInjected.init -> void +Resilion.Chaos.ChaosOutcomeExtensions +Resilion.Chaos.ChaosOutcomeStrategyOptions +Resilion.Chaos.ChaosOutcomeStrategyOptions.OnOutcomeInjected.get -> Resilion.ResilienceEventHandler>? +Resilion.Chaos.ChaosOutcomeStrategyOptions.OnOutcomeInjected.init -> void +Resilion.Chaos.ChaosOutcomeStrategyOptions.Outcome.get -> Resilion.Outcome? +Resilion.Chaos.ChaosOutcomeStrategyOptions.Outcome.init -> void +Resilion.Chaos.ChaosOutcomeStrategyOptions.OutcomeGenerator.get -> System.Func>? +Resilion.Chaos.ChaosOutcomeStrategyOptions.OutcomeGenerator.init -> void +Resilion.Chaos.ChaosStrategyOptions +Resilion.Chaos.ChaosStrategyOptions.Enabled.get -> bool +Resilion.Chaos.ChaosStrategyOptions.Enabled.init -> void +Resilion.Chaos.ChaosStrategyOptions.EnabledGenerator.get -> System.Func? +Resilion.Chaos.ChaosStrategyOptions.EnabledGenerator.init -> void +Resilion.Chaos.ChaosStrategyOptions.InjectionRate.get -> double +Resilion.Chaos.ChaosStrategyOptions.InjectionRate.init -> void +Resilion.Chaos.ChaosStrategyOptions.InjectionRateGenerator.get -> System.Func? +Resilion.Chaos.ChaosStrategyOptions.InjectionRateGenerator.init -> void +Resilion.Chaos.ChaosStrategyOptions.Randomizer.get -> System.Func? +Resilion.Chaos.ChaosStrategyOptions.Randomizer.init -> void +Resilion.Chaos.OnChaosBehaviorArgs +Resilion.Chaos.OnChaosBehaviorArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosBehaviorArgs.Context.init -> void +Resilion.Chaos.OnChaosBehaviorArgs.OnChaosBehaviorArgs() -> void +Resilion.Chaos.OnChaosBehaviorArgs.OnChaosBehaviorArgs(Resilion.ResilienceContext! Context) -> void +Resilion.Chaos.OnChaosFaultArgs +Resilion.Chaos.OnChaosFaultArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosFaultArgs.Context.init -> void +Resilion.Chaos.OnChaosFaultArgs.Fault.get -> System.Exception! +Resilion.Chaos.OnChaosFaultArgs.Fault.init -> void +Resilion.Chaos.OnChaosFaultArgs.OnChaosFaultArgs() -> void +Resilion.Chaos.OnChaosFaultArgs.OnChaosFaultArgs(Resilion.ResilienceContext! Context, System.Exception! Fault) -> void +Resilion.Chaos.OnChaosLatencyArgs +Resilion.Chaos.OnChaosLatencyArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosLatencyArgs.Context.init -> void +Resilion.Chaos.OnChaosLatencyArgs.Latency.get -> System.TimeSpan +Resilion.Chaos.OnChaosLatencyArgs.Latency.init -> void +Resilion.Chaos.OnChaosLatencyArgs.OnChaosLatencyArgs() -> void +Resilion.Chaos.OnChaosLatencyArgs.OnChaosLatencyArgs(Resilion.ResilienceContext! Context, System.TimeSpan Latency) -> void +Resilion.Chaos.OnChaosOutcomeArgs +Resilion.Chaos.OnChaosOutcomeArgs.Context.get -> Resilion.ResilienceContext! +Resilion.Chaos.OnChaosOutcomeArgs.Context.init -> void +Resilion.Chaos.OnChaosOutcomeArgs.OnChaosOutcomeArgs() -> void +Resilion.Chaos.OnChaosOutcomeArgs.OnChaosOutcomeArgs(Resilion.ResilienceContext! Context, Resilion.Outcome Outcome) -> void +Resilion.Chaos.OnChaosOutcomeArgs.Outcome.get -> Resilion.Outcome +Resilion.Chaos.OnChaosOutcomeArgs.Outcome.init -> void +static Resilion.Chaos.ChaosBehaviorExtensions.AddChaosBehavior(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosBehaviorStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosBehaviorExtensions.AddChaosBehavior(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosBehaviorStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosFaultStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! faultFactory) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosFaultStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! faultFactory) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosLatencyStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, double injectionRate, System.TimeSpan latency) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosLatencyStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, double injectionRate, System.TimeSpan latency) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosOutcomeExtensions.AddChaosOutcome(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosOutcomeStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Chaos.ChaosOutcomeExtensions.AddChaosOutcome(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! resultFactory) -> Resilion.PipelineBuilder! diff --git a/src/Resilion.Chaos/PublicAPI.Unshipped.txt b/src/Resilion.Chaos/PublicAPI.Unshipped.txt index 946a1d1..7dc5c58 100644 --- a/src/Resilion.Chaos/PublicAPI.Unshipped.txt +++ b/src/Resilion.Chaos/PublicAPI.Unshipped.txt @@ -1,85 +1 @@ #nullable enable -Resilion.Chaos.ChaosBehaviorExtensions -Resilion.Chaos.ChaosBehaviorStrategyOptions -Resilion.Chaos.ChaosBehaviorStrategyOptions.Behavior.get -> Resilion.ResilienceEventHandler? -Resilion.Chaos.ChaosBehaviorStrategyOptions.Behavior.init -> void -Resilion.Chaos.ChaosBehaviorStrategyOptions.OnBehaviorInjected.get -> Resilion.ResilienceEventHandler? -Resilion.Chaos.ChaosBehaviorStrategyOptions.OnBehaviorInjected.init -> void -Resilion.Chaos.ChaosFaultExtensions -Resilion.Chaos.ChaosFaultStrategyOptions -Resilion.Chaos.ChaosFaultStrategyOptions.Fault.get -> System.Exception? -Resilion.Chaos.ChaosFaultStrategyOptions.Fault.init -> void -Resilion.Chaos.ChaosFaultStrategyOptions.FaultGenerator.get -> System.Func? -Resilion.Chaos.ChaosFaultStrategyOptions.FaultGenerator.init -> void -Resilion.Chaos.ChaosFaultStrategyOptions.OnFaultInjected.get -> Resilion.ResilienceEventHandler? -Resilion.Chaos.ChaosFaultStrategyOptions.OnFaultInjected.init -> void -Resilion.Chaos.ChaosGeneratorArgs -Resilion.Chaos.ChaosGeneratorArgs.ChaosGeneratorArgs() -> void -Resilion.Chaos.ChaosGeneratorArgs.ChaosGeneratorArgs(Resilion.ResilienceContext! Context) -> void -Resilion.Chaos.ChaosGeneratorArgs.Context.get -> Resilion.ResilienceContext! -Resilion.Chaos.ChaosGeneratorArgs.Context.init -> void -Resilion.Chaos.ChaosLatencyExtensions -Resilion.Chaos.ChaosLatencyStrategyOptions -Resilion.Chaos.ChaosLatencyStrategyOptions.Latency.get -> System.TimeSpan -Resilion.Chaos.ChaosLatencyStrategyOptions.Latency.init -> void -Resilion.Chaos.ChaosLatencyStrategyOptions.LatencyGenerator.get -> System.Func? -Resilion.Chaos.ChaosLatencyStrategyOptions.LatencyGenerator.init -> void -Resilion.Chaos.ChaosLatencyStrategyOptions.OnLatencyInjected.get -> Resilion.ResilienceEventHandler? -Resilion.Chaos.ChaosLatencyStrategyOptions.OnLatencyInjected.init -> void -Resilion.Chaos.ChaosOutcomeExtensions -Resilion.Chaos.ChaosOutcomeStrategyOptions -Resilion.Chaos.ChaosOutcomeStrategyOptions.OnOutcomeInjected.get -> Resilion.ResilienceEventHandler>? -Resilion.Chaos.ChaosOutcomeStrategyOptions.OnOutcomeInjected.init -> void -Resilion.Chaos.ChaosOutcomeStrategyOptions.Outcome.get -> Resilion.Outcome? -Resilion.Chaos.ChaosOutcomeStrategyOptions.Outcome.init -> void -Resilion.Chaos.ChaosOutcomeStrategyOptions.OutcomeGenerator.get -> System.Func>? -Resilion.Chaos.ChaosOutcomeStrategyOptions.OutcomeGenerator.init -> void -Resilion.Chaos.ChaosStrategyOptions -Resilion.Chaos.ChaosStrategyOptions.Enabled.get -> bool -Resilion.Chaos.ChaosStrategyOptions.Enabled.init -> void -Resilion.Chaos.ChaosStrategyOptions.EnabledGenerator.get -> System.Func? -Resilion.Chaos.ChaosStrategyOptions.EnabledGenerator.init -> void -Resilion.Chaos.ChaosStrategyOptions.InjectionRate.get -> double -Resilion.Chaos.ChaosStrategyOptions.InjectionRate.init -> void -Resilion.Chaos.ChaosStrategyOptions.InjectionRateGenerator.get -> System.Func? -Resilion.Chaos.ChaosStrategyOptions.InjectionRateGenerator.init -> void -Resilion.Chaos.ChaosStrategyOptions.Randomizer.get -> System.Func? -Resilion.Chaos.ChaosStrategyOptions.Randomizer.init -> void -Resilion.Chaos.OnChaosBehaviorArgs -Resilion.Chaos.OnChaosBehaviorArgs.Context.get -> Resilion.ResilienceContext! -Resilion.Chaos.OnChaosBehaviorArgs.Context.init -> void -Resilion.Chaos.OnChaosBehaviorArgs.OnChaosBehaviorArgs() -> void -Resilion.Chaos.OnChaosBehaviorArgs.OnChaosBehaviorArgs(Resilion.ResilienceContext! Context) -> void -Resilion.Chaos.OnChaosFaultArgs -Resilion.Chaos.OnChaosFaultArgs.Context.get -> Resilion.ResilienceContext! -Resilion.Chaos.OnChaosFaultArgs.Context.init -> void -Resilion.Chaos.OnChaosFaultArgs.Fault.get -> System.Exception! -Resilion.Chaos.OnChaosFaultArgs.Fault.init -> void -Resilion.Chaos.OnChaosFaultArgs.OnChaosFaultArgs() -> void -Resilion.Chaos.OnChaosFaultArgs.OnChaosFaultArgs(Resilion.ResilienceContext! Context, System.Exception! Fault) -> void -Resilion.Chaos.OnChaosLatencyArgs -Resilion.Chaos.OnChaosLatencyArgs.Context.get -> Resilion.ResilienceContext! -Resilion.Chaos.OnChaosLatencyArgs.Context.init -> void -Resilion.Chaos.OnChaosLatencyArgs.Latency.get -> System.TimeSpan -Resilion.Chaos.OnChaosLatencyArgs.Latency.init -> void -Resilion.Chaos.OnChaosLatencyArgs.OnChaosLatencyArgs() -> void -Resilion.Chaos.OnChaosLatencyArgs.OnChaosLatencyArgs(Resilion.ResilienceContext! Context, System.TimeSpan Latency) -> void -Resilion.Chaos.OnChaosOutcomeArgs -Resilion.Chaos.OnChaosOutcomeArgs.Context.get -> Resilion.ResilienceContext! -Resilion.Chaos.OnChaosOutcomeArgs.Context.init -> void -Resilion.Chaos.OnChaosOutcomeArgs.OnChaosOutcomeArgs() -> void -Resilion.Chaos.OnChaosOutcomeArgs.OnChaosOutcomeArgs(Resilion.ResilienceContext! Context, Resilion.Outcome Outcome) -> void -Resilion.Chaos.OnChaosOutcomeArgs.Outcome.get -> Resilion.Outcome -Resilion.Chaos.OnChaosOutcomeArgs.Outcome.init -> void -static Resilion.Chaos.ChaosBehaviorExtensions.AddChaosBehavior(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosBehaviorStrategyOptions! options) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosBehaviorExtensions.AddChaosBehavior(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosBehaviorStrategyOptions! options) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosFaultStrategyOptions! options) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! faultFactory) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosFaultStrategyOptions! options) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosFaultExtensions.AddChaosFault(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! faultFactory) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosLatencyStrategyOptions! options) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, double injectionRate, System.TimeSpan latency) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosLatencyStrategyOptions! options) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosLatencyExtensions.AddChaosLatency(this Resilion.PipelineBuilder! builder, double injectionRate, System.TimeSpan latency) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosOutcomeExtensions.AddChaosOutcome(this Resilion.PipelineBuilder! builder, Resilion.Chaos.ChaosOutcomeStrategyOptions! options) -> Resilion.PipelineBuilder! -static Resilion.Chaos.ChaosOutcomeExtensions.AddChaosOutcome(this Resilion.PipelineBuilder! builder, double injectionRate, System.Func! resultFactory) -> Resilion.PipelineBuilder! diff --git a/src/Resilion.Extensions/PublicAPI.Shipped.txt b/src/Resilion.Extensions/PublicAPI.Shipped.txt index 1023678..a730dba 100644 --- a/src/Resilion.Extensions/PublicAPI.Shipped.txt +++ b/src/Resilion.Extensions/PublicAPI.Shipped.txt @@ -1,18 +1,84 @@ #nullable enable +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.BreakDuration.get -> System.TimeSpan +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.BreakDuration.set -> void +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.CircuitBreakerStrategyConfiguration() -> void +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.FailureRatioThreshold.get -> double +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.FailureRatioThreshold.set -> void +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.MinimumThroughput.get -> int +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.MinimumThroughput.set -> void +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.SamplingDuration.get -> System.TimeSpan +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.SamplingDuration.set -> void +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.ToOptions() -> Resilion.CircuitBreakerStrategyOptions! +Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.ToOptions() -> Resilion.CircuitBreakerStrategyOptions! +Resilion.Extensions.Configuration.HedgingStrategyConfiguration +Resilion.Extensions.Configuration.HedgingStrategyConfiguration.HedgingDelay.get -> System.TimeSpan +Resilion.Extensions.Configuration.HedgingStrategyConfiguration.HedgingDelay.set -> void +Resilion.Extensions.Configuration.HedgingStrategyConfiguration.HedgingStrategyConfiguration() -> void +Resilion.Extensions.Configuration.HedgingStrategyConfiguration.MaxHedgedAttempts.get -> int +Resilion.Extensions.Configuration.HedgingStrategyConfiguration.MaxHedgedAttempts.set -> void +Resilion.Extensions.Configuration.HedgingStrategyConfiguration.ToOptions() -> Resilion.HedgingStrategyOptions! +Resilion.Extensions.Configuration.RetryDelayConfiguration +Resilion.Extensions.Configuration.RetryDelayConfiguration.BaseDelay.get -> System.TimeSpan +Resilion.Extensions.Configuration.RetryDelayConfiguration.BaseDelay.set -> void +Resilion.Extensions.Configuration.RetryDelayConfiguration.Kind.get -> Resilion.Extensions.Configuration.RetryDelayKind +Resilion.Extensions.Configuration.RetryDelayConfiguration.Kind.set -> void +Resilion.Extensions.Configuration.RetryDelayConfiguration.RetryDelayConfiguration() -> void +Resilion.Extensions.Configuration.RetryDelayConfiguration.ToRetryDelay() -> Resilion.RetryDelay! +Resilion.Extensions.Configuration.RetryDelayKind +Resilion.Extensions.Configuration.RetryDelayKind.Constant = 0 -> Resilion.Extensions.Configuration.RetryDelayKind +Resilion.Extensions.Configuration.RetryDelayKind.Exponential = 2 -> Resilion.Extensions.Configuration.RetryDelayKind +Resilion.Extensions.Configuration.RetryDelayKind.Linear = 1 -> Resilion.Extensions.Configuration.RetryDelayKind +Resilion.Extensions.Configuration.RetryStrategyConfiguration +Resilion.Extensions.Configuration.RetryStrategyConfiguration.Delay.get -> Resilion.Extensions.Configuration.RetryDelayConfiguration! +Resilion.Extensions.Configuration.RetryStrategyConfiguration.Delay.set -> void +Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxDelay.get -> System.TimeSpan? +Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxDelay.set -> void +Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxRetryAttempts.get -> int +Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxRetryAttempts.set -> void +Resilion.Extensions.Configuration.RetryStrategyConfiguration.RetryStrategyConfiguration() -> void +Resilion.Extensions.Configuration.RetryStrategyConfiguration.ToOptions() -> Resilion.RetryStrategyOptions! +Resilion.Extensions.Configuration.RetryStrategyConfiguration.ToOptions() -> Resilion.RetryStrategyOptions! +Resilion.Extensions.Configuration.RetryStrategyConfiguration.UseJitter.get -> bool +Resilion.Extensions.Configuration.RetryStrategyConfiguration.UseJitter.set -> void +Resilion.Extensions.Configuration.TimeoutStrategyConfiguration +Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.Timeout.get -> System.TimeSpan +Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.Timeout.set -> void +Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.TimeoutStrategyConfiguration() -> void +Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.ToOptions() -> Resilion.TimeoutStrategyOptions! Resilion.Extensions.IPipelineProvider Resilion.Extensions.IPipelineProvider.GetPipeline(TKey key) -> Resilion.Pipeline! Resilion.Extensions.IPipelineProvider.GetPipeline(TKey key) -> Resilion.Pipeline! Resilion.Extensions.IPipelineProvider.TryGetPipeline(TKey key, out Resilion.Pipeline? pipeline) -> bool +Resilion.Extensions.PipelineReplacedArgs +Resilion.Extensions.PipelineReplacedArgs.Key.get -> TKey +Resilion.Extensions.PipelineReplacedArgs.Key.init -> void +Resilion.Extensions.PipelineReplacedArgs.PipelineReplacedArgs() -> void +Resilion.Extensions.PipelineReplacedArgs.PipelineReplacedArgs(TKey Key, System.Type? ResultType, System.IAsyncDisposable! ReplacedPipeline) -> void +Resilion.Extensions.PipelineReplacedArgs.ReplacedPipeline.get -> System.IAsyncDisposable! +Resilion.Extensions.PipelineReplacedArgs.ReplacedPipeline.init -> void +Resilion.Extensions.PipelineReplacedArgs.ResultType.get -> System.Type? +Resilion.Extensions.PipelineReplacedArgs.ResultType.init -> void Resilion.Extensions.ResiliencePipelineRegistry Resilion.Extensions.ResiliencePipelineRegistry.Dispose() -> void Resilion.Extensions.ResiliencePipelineRegistry.GetPipeline(TKey key) -> Resilion.Pipeline! Resilion.Extensions.ResiliencePipelineRegistry.GetPipeline(TKey key) -> Resilion.Pipeline! +Resilion.Extensions.ResiliencePipelineRegistry.InvalidatePipeline(TKey key) -> bool +Resilion.Extensions.ResiliencePipelineRegistry.InvalidatePipeline(TKey key) -> bool +Resilion.Extensions.ResiliencePipelineRegistry.OnPipelineReplaced.get -> System.Action>? +Resilion.Extensions.ResiliencePipelineRegistry.OnPipelineReplaced.set -> void Resilion.Extensions.ResiliencePipelineRegistry.RegisterPipeline(TKey key, System.Action! configure) -> void Resilion.Extensions.ResiliencePipelineRegistry.RegisterPipeline(TKey key, System.Action!>! configure) -> void Resilion.Extensions.ResiliencePipelineRegistry.ResiliencePipelineRegistry() -> void Resilion.Extensions.ResiliencePipelineRegistry.TryGetPipeline(TKey key, out Resilion.Pipeline? pipeline) -> bool +Resilion.Extensions.ResiliencePipelineRegistry.TryGetPipeline(TKey key, out Resilion.Pipeline? pipeline) -> bool Resilion.Extensions.ResilionServiceCollectionExtensions static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action!, TOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action!, TOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResilienceServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResilion(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Resilion.Extensions.ResilionServiceCollectionExtensions.ConfigureResiliencePipelineRegistry(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Resilion.Extensions/PublicAPI.Unshipped.txt b/src/Resilion.Extensions/PublicAPI.Unshipped.txt index 629a44e..7dc5c58 100644 --- a/src/Resilion.Extensions/PublicAPI.Unshipped.txt +++ b/src/Resilion.Extensions/PublicAPI.Unshipped.txt @@ -1,67 +1 @@ #nullable enable -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.BreakDuration.get -> System.TimeSpan -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.BreakDuration.set -> void -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.CircuitBreakerStrategyConfiguration() -> void -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.FailureRatioThreshold.get -> double -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.FailureRatioThreshold.set -> void -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.MinimumThroughput.get -> int -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.MinimumThroughput.set -> void -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.SamplingDuration.get -> System.TimeSpan -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.SamplingDuration.set -> void -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.ToOptions() -> Resilion.CircuitBreakerStrategyOptions! -Resilion.Extensions.Configuration.CircuitBreakerStrategyConfiguration.ToOptions() -> Resilion.CircuitBreakerStrategyOptions! -Resilion.Extensions.Configuration.HedgingStrategyConfiguration -Resilion.Extensions.Configuration.HedgingStrategyConfiguration.HedgingDelay.get -> System.TimeSpan -Resilion.Extensions.Configuration.HedgingStrategyConfiguration.HedgingDelay.set -> void -Resilion.Extensions.Configuration.HedgingStrategyConfiguration.HedgingStrategyConfiguration() -> void -Resilion.Extensions.Configuration.HedgingStrategyConfiguration.MaxHedgedAttempts.get -> int -Resilion.Extensions.Configuration.HedgingStrategyConfiguration.MaxHedgedAttempts.set -> void -Resilion.Extensions.Configuration.HedgingStrategyConfiguration.ToOptions() -> Resilion.HedgingStrategyOptions! -Resilion.Extensions.Configuration.RetryDelayConfiguration -Resilion.Extensions.Configuration.RetryDelayConfiguration.BaseDelay.get -> System.TimeSpan -Resilion.Extensions.Configuration.RetryDelayConfiguration.BaseDelay.set -> void -Resilion.Extensions.Configuration.RetryDelayConfiguration.Kind.get -> Resilion.Extensions.Configuration.RetryDelayKind -Resilion.Extensions.Configuration.RetryDelayConfiguration.Kind.set -> void -Resilion.Extensions.Configuration.RetryDelayConfiguration.RetryDelayConfiguration() -> void -Resilion.Extensions.Configuration.RetryDelayConfiguration.ToRetryDelay() -> Resilion.RetryDelay! -Resilion.Extensions.Configuration.RetryDelayKind -Resilion.Extensions.Configuration.RetryDelayKind.Constant = 0 -> Resilion.Extensions.Configuration.RetryDelayKind -Resilion.Extensions.Configuration.RetryDelayKind.Exponential = 2 -> Resilion.Extensions.Configuration.RetryDelayKind -Resilion.Extensions.Configuration.RetryDelayKind.Linear = 1 -> Resilion.Extensions.Configuration.RetryDelayKind -Resilion.Extensions.Configuration.RetryStrategyConfiguration -Resilion.Extensions.Configuration.RetryStrategyConfiguration.Delay.get -> Resilion.Extensions.Configuration.RetryDelayConfiguration! -Resilion.Extensions.Configuration.RetryStrategyConfiguration.Delay.set -> void -Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxDelay.get -> System.TimeSpan? -Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxDelay.set -> void -Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxRetryAttempts.get -> int -Resilion.Extensions.Configuration.RetryStrategyConfiguration.MaxRetryAttempts.set -> void -Resilion.Extensions.Configuration.RetryStrategyConfiguration.RetryStrategyConfiguration() -> void -Resilion.Extensions.Configuration.RetryStrategyConfiguration.ToOptions() -> Resilion.RetryStrategyOptions! -Resilion.Extensions.Configuration.RetryStrategyConfiguration.ToOptions() -> Resilion.RetryStrategyOptions! -Resilion.Extensions.Configuration.RetryStrategyConfiguration.UseJitter.get -> bool -Resilion.Extensions.Configuration.RetryStrategyConfiguration.UseJitter.set -> void -Resilion.Extensions.Configuration.TimeoutStrategyConfiguration -Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.Timeout.get -> System.TimeSpan -Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.Timeout.set -> void -Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.TimeoutStrategyConfiguration() -> void -Resilion.Extensions.Configuration.TimeoutStrategyConfiguration.ToOptions() -> Resilion.TimeoutStrategyOptions! -Resilion.Extensions.PipelineReplacedArgs -Resilion.Extensions.PipelineReplacedArgs.Key.get -> TKey -Resilion.Extensions.PipelineReplacedArgs.Key.init -> void -Resilion.Extensions.PipelineReplacedArgs.PipelineReplacedArgs() -> void -Resilion.Extensions.PipelineReplacedArgs.PipelineReplacedArgs(TKey Key, System.Type? ResultType, System.IAsyncDisposable! ReplacedPipeline) -> void -Resilion.Extensions.PipelineReplacedArgs.ReplacedPipeline.get -> System.IAsyncDisposable! -Resilion.Extensions.PipelineReplacedArgs.ReplacedPipeline.init -> void -Resilion.Extensions.PipelineReplacedArgs.ResultType.get -> System.Type? -Resilion.Extensions.PipelineReplacedArgs.ResultType.init -> void -Resilion.Extensions.ResiliencePipelineRegistry.InvalidatePipeline(TKey key) -> bool -Resilion.Extensions.ResiliencePipelineRegistry.InvalidatePipeline(TKey key) -> bool -Resilion.Extensions.ResiliencePipelineRegistry.OnPipelineReplaced.get -> System.Action>? -Resilion.Extensions.ResiliencePipelineRegistry.OnPipelineReplaced.set -> void -Resilion.Extensions.ResiliencePipelineRegistry.TryGetPipeline(TKey key, out Resilion.Pipeline? pipeline) -> bool -static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action!, TOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Resilion.Extensions.ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, string? optionsName, System.Action!, TOptions!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Resilion.Extensions.ResilionServiceCollectionExtensions.ConfigureResiliencePipelineRegistry(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Resilion.Http/PublicAPI.Shipped.txt b/src/Resilion.Http/PublicAPI.Shipped.txt index 7dc5c58..a351dbc 100644 --- a/src/Resilion.Http/PublicAPI.Shipped.txt +++ b/src/Resilion.Http/PublicAPI.Shipped.txt @@ -1 +1,51 @@ #nullable enable +Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions +Resilion.Http.HttpRequestReplay +Resilion.Http.HttpRequestReplay.CloneRequest = 1 -> Resilion.Http.HttpRequestReplay +Resilion.Http.HttpRequestReplay.ReuseRequest = 0 -> Resilion.Http.HttpRequestReplay +Resilion.Http.HttpResilienceContextExtensions +Resilion.Http.HttpResiliencePredicates +Resilion.Http.HttpStandardHedgingOptions +Resilion.Http.HttpStandardHedgingOptions.AttemptTimeout.get -> Resilion.TimeoutStrategyOptions! +Resilion.Http.HttpStandardHedgingOptions.AttemptTimeout.set -> void +Resilion.Http.HttpStandardHedgingOptions.CircuitBreaker.get -> Resilion.CircuitBreakerStrategyOptions! +Resilion.Http.HttpStandardHedgingOptions.CircuitBreaker.set -> void +Resilion.Http.HttpStandardHedgingOptions.Hedging.get -> Resilion.HedgingStrategyOptions! +Resilion.Http.HttpStandardHedgingOptions.Hedging.set -> void +Resilion.Http.HttpStandardHedgingOptions.HttpStandardHedgingOptions() -> void +Resilion.Http.HttpStandardHedgingOptions.RateLimiter.get -> Resilion.RateLimiting.RateLimiterStrategyOptions? +Resilion.Http.HttpStandardHedgingOptions.RateLimiter.set -> void +Resilion.Http.HttpStandardHedgingOptions.TimeProvider.get -> System.TimeProvider! +Resilion.Http.HttpStandardHedgingOptions.TimeProvider.set -> void +Resilion.Http.HttpStandardHedgingOptions.TotalRequestTimeout.get -> Resilion.TimeoutStrategyOptions! +Resilion.Http.HttpStandardHedgingOptions.TotalRequestTimeout.set -> void +Resilion.Http.HttpStandardResilienceOptions +Resilion.Http.HttpStandardResilienceOptions.AttemptTimeout.get -> Resilion.TimeoutStrategyOptions! +Resilion.Http.HttpStandardResilienceOptions.AttemptTimeout.set -> void +Resilion.Http.HttpStandardResilienceOptions.CircuitBreaker.get -> Resilion.CircuitBreakerStrategyOptions! +Resilion.Http.HttpStandardResilienceOptions.CircuitBreaker.set -> void +Resilion.Http.HttpStandardResilienceOptions.HttpStandardResilienceOptions() -> void +Resilion.Http.HttpStandardResilienceOptions.RateLimiter.get -> Resilion.RateLimiting.RateLimiterStrategyOptions? +Resilion.Http.HttpStandardResilienceOptions.RateLimiter.set -> void +Resilion.Http.HttpStandardResilienceOptions.RequestReplay.get -> Resilion.Http.HttpRequestReplay +Resilion.Http.HttpStandardResilienceOptions.RequestReplay.set -> void +Resilion.Http.HttpStandardResilienceOptions.Retry.get -> Resilion.RetryStrategyOptions! +Resilion.Http.HttpStandardResilienceOptions.Retry.set -> void +Resilion.Http.HttpStandardResilienceOptions.TimeProvider.get -> System.TimeProvider! +Resilion.Http.HttpStandardResilienceOptions.TimeProvider.set -> void +Resilion.Http.HttpStandardResilienceOptions.TotalRequestTimeout.get -> Resilion.TimeoutStrategyOptions! +Resilion.Http.HttpStandardResilienceOptions.TotalRequestTimeout.set -> void +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!, System.IServiceProvider!>! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!, System.IServiceProvider!>! configure, Resilion.Http.HttpRequestReplay requestReplay) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!>! configure, Resilion.Http.HttpRequestReplay requestReplay) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardHedgingHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardHedgingHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardHedgingHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! +static Resilion.Http.HttpResilienceContextExtensions.GetRequestMessage(this Resilion.ResilienceContext! context) -> System.Net.Http.HttpRequestMessage? +static Resilion.Http.HttpResiliencePredicates.IsTransient(Resilion.Outcome outcome) -> bool +static Resilion.Http.HttpResiliencePredicates.IsTransientException(System.Exception! exception) -> bool +static Resilion.Http.HttpResiliencePredicates.IsTransientStatusCode(System.Net.HttpStatusCode statusCode) -> bool diff --git a/src/Resilion.Http/PublicAPI.Unshipped.txt b/src/Resilion.Http/PublicAPI.Unshipped.txt index a351dbc..7dc5c58 100644 --- a/src/Resilion.Http/PublicAPI.Unshipped.txt +++ b/src/Resilion.Http/PublicAPI.Unshipped.txt @@ -1,51 +1 @@ #nullable enable -Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions -Resilion.Http.HttpRequestReplay -Resilion.Http.HttpRequestReplay.CloneRequest = 1 -> Resilion.Http.HttpRequestReplay -Resilion.Http.HttpRequestReplay.ReuseRequest = 0 -> Resilion.Http.HttpRequestReplay -Resilion.Http.HttpResilienceContextExtensions -Resilion.Http.HttpResiliencePredicates -Resilion.Http.HttpStandardHedgingOptions -Resilion.Http.HttpStandardHedgingOptions.AttemptTimeout.get -> Resilion.TimeoutStrategyOptions! -Resilion.Http.HttpStandardHedgingOptions.AttemptTimeout.set -> void -Resilion.Http.HttpStandardHedgingOptions.CircuitBreaker.get -> Resilion.CircuitBreakerStrategyOptions! -Resilion.Http.HttpStandardHedgingOptions.CircuitBreaker.set -> void -Resilion.Http.HttpStandardHedgingOptions.Hedging.get -> Resilion.HedgingStrategyOptions! -Resilion.Http.HttpStandardHedgingOptions.Hedging.set -> void -Resilion.Http.HttpStandardHedgingOptions.HttpStandardHedgingOptions() -> void -Resilion.Http.HttpStandardHedgingOptions.RateLimiter.get -> Resilion.RateLimiting.RateLimiterStrategyOptions? -Resilion.Http.HttpStandardHedgingOptions.RateLimiter.set -> void -Resilion.Http.HttpStandardHedgingOptions.TimeProvider.get -> System.TimeProvider! -Resilion.Http.HttpStandardHedgingOptions.TimeProvider.set -> void -Resilion.Http.HttpStandardHedgingOptions.TotalRequestTimeout.get -> Resilion.TimeoutStrategyOptions! -Resilion.Http.HttpStandardHedgingOptions.TotalRequestTimeout.set -> void -Resilion.Http.HttpStandardResilienceOptions -Resilion.Http.HttpStandardResilienceOptions.AttemptTimeout.get -> Resilion.TimeoutStrategyOptions! -Resilion.Http.HttpStandardResilienceOptions.AttemptTimeout.set -> void -Resilion.Http.HttpStandardResilienceOptions.CircuitBreaker.get -> Resilion.CircuitBreakerStrategyOptions! -Resilion.Http.HttpStandardResilienceOptions.CircuitBreaker.set -> void -Resilion.Http.HttpStandardResilienceOptions.HttpStandardResilienceOptions() -> void -Resilion.Http.HttpStandardResilienceOptions.RateLimiter.get -> Resilion.RateLimiting.RateLimiterStrategyOptions? -Resilion.Http.HttpStandardResilienceOptions.RateLimiter.set -> void -Resilion.Http.HttpStandardResilienceOptions.RequestReplay.get -> Resilion.Http.HttpRequestReplay -Resilion.Http.HttpStandardResilienceOptions.RequestReplay.set -> void -Resilion.Http.HttpStandardResilienceOptions.Retry.get -> Resilion.RetryStrategyOptions! -Resilion.Http.HttpStandardResilienceOptions.Retry.set -> void -Resilion.Http.HttpStandardResilienceOptions.TimeProvider.get -> System.TimeProvider! -Resilion.Http.HttpStandardResilienceOptions.TimeProvider.set -> void -Resilion.Http.HttpStandardResilienceOptions.TotalRequestTimeout.get -> Resilion.TimeoutStrategyOptions! -Resilion.Http.HttpStandardResilienceOptions.TotalRequestTimeout.set -> void -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!, System.IServiceProvider!>! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!, System.IServiceProvider!>! configure, Resilion.Http.HttpRequestReplay requestReplay) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!>! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, string! key, System.Action!>! configure, Resilion.Http.HttpRequestReplay requestReplay) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardHedgingHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardHedgingHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardHedgingHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Microsoft.Extensions.DependencyInjection.ResilionHttpClientBuilderExtensions.AddStandardResilienceHandler(this Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! builder, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IHttpClientBuilder! -static Resilion.Http.HttpResilienceContextExtensions.GetRequestMessage(this Resilion.ResilienceContext! context) -> System.Net.Http.HttpRequestMessage? -static Resilion.Http.HttpResiliencePredicates.IsTransient(Resilion.Outcome outcome) -> bool -static Resilion.Http.HttpResiliencePredicates.IsTransientException(System.Exception! exception) -> bool -static Resilion.Http.HttpResiliencePredicates.IsTransientStatusCode(System.Net.HttpStatusCode statusCode) -> bool diff --git a/src/Resilion/PublicAPI.Shipped.txt b/src/Resilion/PublicAPI.Shipped.txt index d6a0573..bfd7869 100644 --- a/src/Resilion/PublicAPI.Shipped.txt +++ b/src/Resilion/PublicAPI.Shipped.txt @@ -13,6 +13,9 @@ Resilion.CircuitBreakerManualControl Resilion.CircuitBreakerManualControl.CircuitBreakerManualControl() -> void Resilion.CircuitBreakerManualControl.IsolateAsync() -> System.Threading.Tasks.Task! Resilion.CircuitBreakerManualControl.ResetAsync() -> System.Threading.Tasks.Task! +Resilion.CircuitBreakerStateProvider +Resilion.CircuitBreakerStateProvider.CircuitBreakerStateProvider() -> void +Resilion.CircuitBreakerStateProvider.State.get -> Resilion.CircuitState Resilion.CircuitBreakerStrategyOptions Resilion.CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan Resilion.CircuitBreakerStrategyOptions.BreakDuration.init -> void @@ -34,6 +37,8 @@ Resilion.CircuitBreakerStrategyOptions.SamplingDuration.get -> System.TimeSpan Resilion.CircuitBreakerStrategyOptions.SamplingDuration.init -> void Resilion.CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func? Resilion.CircuitBreakerStrategyOptions.ShouldHandle.init -> void +Resilion.CircuitBreakerStrategyOptions.StateProvider.get -> Resilion.CircuitBreakerStateProvider? +Resilion.CircuitBreakerStrategyOptions.StateProvider.init -> void Resilion.CircuitBreakerStrategyOptions Resilion.CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan Resilion.CircuitBreakerStrategyOptions.BreakDuration.init -> void @@ -55,6 +60,8 @@ Resilion.CircuitBreakerStrategyOptions.SamplingDuration.get -> System.T Resilion.CircuitBreakerStrategyOptions.SamplingDuration.init -> void Resilion.CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func, bool>? Resilion.CircuitBreakerStrategyOptions.ShouldHandle.init -> void +Resilion.CircuitBreakerStrategyOptions.StateProvider.get -> Resilion.CircuitBreakerStateProvider? +Resilion.CircuitBreakerStrategyOptions.StateProvider.init -> void Resilion.CircuitBrokenException Resilion.CircuitBrokenException.CircuitState.get -> Resilion.CircuitState Resilion.CircuitBrokenException.RetryAfter.get -> System.TimeSpan @@ -96,12 +103,21 @@ Resilion.HedgingActionContext.AttemptNumber.get -> int Resilion.HedgingActionContext.AttemptNumber.init -> void Resilion.HedgingActionContext.HedgingActionContext() -> void Resilion.HedgingActionContext.HedgingActionContext(int AttemptNumber) -> void +Resilion.HedgingDelayGeneratorArgs +Resilion.HedgingDelayGeneratorArgs.AttemptNumber.get -> int +Resilion.HedgingDelayGeneratorArgs.AttemptNumber.init -> void +Resilion.HedgingDelayGeneratorArgs.Context.get -> Resilion.ResilienceContext! +Resilion.HedgingDelayGeneratorArgs.Context.init -> void +Resilion.HedgingDelayGeneratorArgs.HedgingDelayGeneratorArgs() -> void +Resilion.HedgingDelayGeneratorArgs.HedgingDelayGeneratorArgs(int AttemptNumber, Resilion.ResilienceContext! Context) -> void Resilion.HedgingExtensions Resilion.HedgingStrategyOptions Resilion.HedgingStrategyOptions.ActionGenerator.get -> System.Func>?>? Resilion.HedgingStrategyOptions.ActionGenerator.init -> void Resilion.HedgingStrategyOptions.HedgingDelay.get -> System.TimeSpan Resilion.HedgingStrategyOptions.HedgingDelay.init -> void +Resilion.HedgingStrategyOptions.HedgingDelayGenerator.get -> System.Func? +Resilion.HedgingStrategyOptions.HedgingDelayGenerator.init -> void Resilion.HedgingStrategyOptions.MaxHedgedAttempts.get -> int Resilion.HedgingStrategyOptions.MaxHedgedAttempts.init -> void Resilion.HedgingStrategyOptions.OnHedging.get -> Resilion.ResilienceEventHandler>? @@ -154,6 +170,7 @@ Resilion.Pipeline.ExecuteAsync(System.Func(System.Func>! action, TState state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Resilion.Pipeline.ExecuteAsync(System.Func>! action, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Resilion.Pipeline.ExecuteOutcomeAsync(System.Func>>! action, TState state, Resilion.ResilienceContext! context) -> System.Threading.Tasks.ValueTask> +Resilion.Pipeline.Name.get -> string? Resilion.Pipeline Resilion.Pipeline.Dispose() -> void Resilion.Pipeline.DisposeAsync() -> System.Threading.Tasks.ValueTask @@ -162,6 +179,7 @@ Resilion.Pipeline.Execute(System.Func.ExecuteAsync(System.Func>! action, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Resilion.Pipeline.ExecuteAsync(System.Func>! action, TState state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Resilion.Pipeline.ExecuteOutcomeAsync(System.Func>>! action, TState state, Resilion.ResilienceContext! context) -> System.Threading.Tasks.ValueTask> +Resilion.Pipeline.Name.get -> string? Resilion.PipelineBuilder Resilion.PipelineBuilder.AddPipeline(Resilion.Pipeline! pipeline) -> Resilion.PipelineBuilder! Resilion.PipelineBuilder.AddStrategy(Resilion.Strategy! strategy) -> Resilion.PipelineBuilder! @@ -187,6 +205,22 @@ Resilion.PipelineBuilderBase.ThrowOnOrderingErrors.get -> bool Resilion.PipelineBuilderBase.ThrowOnOrderingErrors.set -> void Resilion.PipelineBuilderBase.TimeProvider.get -> System.TimeProvider! Resilion.PipelineBuilderBase.TimeProvider.set -> void +Resilion.PredicateBuilder +Resilion.PredicateBuilder.Build() -> System.Func! +Resilion.PredicateBuilder.Handle() -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.Handle(System.Func! predicate) -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.HandleInner() -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.HandleInner(System.Func! predicate) -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.PredicateBuilder() -> void +Resilion.PredicateBuilder +Resilion.PredicateBuilder.Build() -> System.Func, bool>! +Resilion.PredicateBuilder.Handle() -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.Handle(System.Func! predicate) -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.HandleInner() -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.HandleInner(System.Func! predicate) -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.HandleResult(System.Func! predicate) -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.HandleResult(TResult result) -> Resilion.PredicateBuilder! +Resilion.PredicateBuilder.PredicateBuilder() -> void Resilion.ResilienceContext Resilion.ResilienceContext.CancellationToken.get -> System.Threading.CancellationToken Resilion.ResilienceContext.ContinueOnCapturedContext.get -> bool @@ -293,6 +327,7 @@ Resilion.TimeoutStrategyOptions.TimeoutGenerator.init -> void abstract Resilion.Strategy.ExecuteAsync(System.Func>>! callback, Resilion.ResilienceContext! context) -> System.Threading.Tasks.ValueTask> abstract Resilion.Strategy.ExecuteAsync(System.Func>>! callback, Resilion.ResilienceContext! context) -> System.Threading.Tasks.ValueTask> const Resilion.ResilionTelemetry.ActivitySourceName = "Resilion" -> string! +const Resilion.ResilionTelemetry.ChaosStrategyTag = "chaos.strategy" -> string! const Resilion.ResilionTelemetry.MeterName = "Resilion" -> string! const Resilion.ResilionTelemetry.OperationKeyTag = "operation.key" -> string! const Resilion.ResilionTelemetry.PipelineNameTag = "pipeline.name" -> string! @@ -319,6 +354,8 @@ static Resilion.Pipeline.Create(System.Action! config static Resilion.Pipeline.Create(System.Action!>! configure) -> Resilion.Pipeline! static Resilion.Pipeline.Empty.get -> Resilion.Pipeline! static Resilion.Pipeline.Empty.get -> Resilion.Pipeline! +static Resilion.PredicateBuilder.implicit operator System.Func!(Resilion.PredicateBuilder! builder) -> System.Func! +static Resilion.PredicateBuilder.implicit operator System.Func, bool>!(Resilion.PredicateBuilder! builder) -> System.Func, bool>! static Resilion.ResilienceContextPool.Shared.get -> Resilion.ResilienceContextPool! static Resilion.ResiliencePropertyKey.operator !=(Resilion.ResiliencePropertyKey left, Resilion.ResiliencePropertyKey right) -> bool static Resilion.ResiliencePropertyKey.operator ==(Resilion.ResiliencePropertyKey left, Resilion.ResiliencePropertyKey right) -> bool diff --git a/src/Resilion/PublicAPI.Unshipped.txt b/src/Resilion/PublicAPI.Unshipped.txt index a59f728..7dc5c58 100644 --- a/src/Resilion/PublicAPI.Unshipped.txt +++ b/src/Resilion/PublicAPI.Unshipped.txt @@ -1,38 +1 @@ #nullable enable -Resilion.CircuitBreakerStateProvider -Resilion.CircuitBreakerStateProvider.CircuitBreakerStateProvider() -> void -Resilion.CircuitBreakerStateProvider.State.get -> Resilion.CircuitState -Resilion.CircuitBreakerStrategyOptions.StateProvider.get -> Resilion.CircuitBreakerStateProvider? -Resilion.CircuitBreakerStrategyOptions.StateProvider.init -> void -Resilion.CircuitBreakerStrategyOptions.StateProvider.get -> Resilion.CircuitBreakerStateProvider? -Resilion.CircuitBreakerStrategyOptions.StateProvider.init -> void -Resilion.HedgingDelayGeneratorArgs -Resilion.HedgingDelayGeneratorArgs.AttemptNumber.get -> int -Resilion.HedgingDelayGeneratorArgs.AttemptNumber.init -> void -Resilion.HedgingDelayGeneratorArgs.Context.get -> Resilion.ResilienceContext! -Resilion.HedgingDelayGeneratorArgs.Context.init -> void -Resilion.HedgingDelayGeneratorArgs.HedgingDelayGeneratorArgs() -> void -Resilion.HedgingDelayGeneratorArgs.HedgingDelayGeneratorArgs(int AttemptNumber, Resilion.ResilienceContext! Context) -> void -Resilion.HedgingStrategyOptions.HedgingDelayGenerator.get -> System.Func? -Resilion.HedgingStrategyOptions.HedgingDelayGenerator.init -> void -Resilion.Pipeline.Name.get -> string? -Resilion.Pipeline.Name.get -> string? -Resilion.PredicateBuilder -Resilion.PredicateBuilder.Build() -> System.Func! -Resilion.PredicateBuilder.Handle() -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.Handle(System.Func! predicate) -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.HandleInner() -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.HandleInner(System.Func! predicate) -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.PredicateBuilder() -> void -Resilion.PredicateBuilder -Resilion.PredicateBuilder.Build() -> System.Func, bool>! -Resilion.PredicateBuilder.Handle() -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.Handle(System.Func! predicate) -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.HandleInner() -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.HandleInner(System.Func! predicate) -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.HandleResult(System.Func! predicate) -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.HandleResult(TResult result) -> Resilion.PredicateBuilder! -Resilion.PredicateBuilder.PredicateBuilder() -> void -const Resilion.ResilionTelemetry.ChaosStrategyTag = "chaos.strategy" -> string! -static Resilion.PredicateBuilder.implicit operator System.Func!(Resilion.PredicateBuilder! builder) -> System.Func! -static Resilion.PredicateBuilder.implicit operator System.Func, bool>!(Resilion.PredicateBuilder! builder) -> System.Func, bool>! From 67fcfc7e9c31bba8a6065b80448f6d995eebfd70 Mon Sep 17 00:00:00 2001 From: Ali-Tegna <31090457+FreakyAli@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:41:51 +0530 Subject: [PATCH 3/5] fix: valid coderabbit comments addressed --- CHANGELOG.md | 17 ++ CONTRIBUTING.md | 10 +- README.md | 3 +- benchmarks/results/README.md | 21 +- docs/configuration.md | 4 +- docs/future-plans.md | 271 +++++++++--------- docs/http.md | 2 +- docs/verification.md | 19 +- .../ResilionServiceCollectionExtensions.cs | 43 ++- .../HttpStandardResilienceOptions.cs | 4 +- .../Internal/ResilienceHandler.cs | 5 + .../ResilionHttpClientBuilderExtensions.cs | 10 +- src/Resilion/Hedging/HedgingStrategy.cs | 2 + .../StrategyConfigurationTests.cs | 24 ++ .../PipelineReloadTests.cs | 90 ++++++ .../ReviewRegressionTests.cs | 108 +++++++ .../Resilion.Tests/Telemetry/ActivityTests.cs | 23 ++ 17 files changed, 501 insertions(+), 155 deletions(-) create mode 100644 tests/Resilion.Http.Tests/ReviewRegressionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e4537..226663e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,23 @@ _No changes yet._ must not be lifted into production code ### Fixed +- **`CloneRequest` handed retry callbacks a disposed request.** The per-attempt clone is disposed + as soon as the attempt returns, but strategy callbacks run after that — so `OnRetry` calling + `context.GetRequestMessage()` got a disposed message and threw on touching its content. The + context now points back at the caller's live request before the clone is disposed +- **The HTTP replay mode was re-resolved on every handler rotation.** Working it out re-runs the + caller's `configure` delegate, and `IHttpClientFactory` rebuilds the handler chain roughly every + two minutes, so the delegate ran for the life of the application and took any side effect in it + along. Resolved once now +- **Hedging spans could end with no `outcome` tag.** The two early returns taken when an attempt + succeeds before the launch loop finishes skipped `SetOutcome`, so a successful hedged call + produced a span that was present in the trace but silent about what happened +- **A reloadable pipeline rebuilt on changes to unrelated named options.** + `IOptionsMonitor.OnChange` fires for every named instance of the options type, and the + subscription ignored which one changed — so a pipeline bound to `"billing"` rebuilt whenever + `"api"` changed +- `RegistryDispose_DisposesReloadSubscriptions` asserted nothing about disposal and passed with + the feature removed. It now uses a monitor that records whether its subscription was disposed - **Timeout cancellation classification is now race-free.** `WasCancelledByTimeout` read `linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested` as two separate reads, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 018d3a3..f88bca4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,15 +23,19 @@ dotnet build ```text src/ Resilion/ Core library (zero dependencies) - Resilion.Extensions/ DI, logging, telemetry integration + Resilion.Extensions/ DI, pipeline registry, reload, configuration binding Resilion.RateLimiting/ Rate limiting strategy + Resilion.Http/ HttpClient / IHttpClientFactory handlers + Resilion.Chaos/ Fault, outcome, latency and behavior injection tests/ Resilion.Tests/ Core strategy and pipeline tests - Resilion.Extensions.Tests/ DI and telemetry tests + Resilion.Extensions.Tests/ DI, reload and configuration tests + Resilion.Http.Tests/ HttpClient handler tests + Resilion.Chaos.Tests/ Chaos strategy tests benchmarks/ - Resilion.Benchmarks/ Performance benchmarks (not in .sln) + Resilion.Benchmarks/ Performance benchmarks (in the .sln, not run by dotnet test) samples/ Resilion.Samples/ Usage examples diff --git a/README.md b/README.md index e4a25c0..9bdb9f8 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,8 @@ var result = await pipeline.ExecuteAsync(async (state, ct) => ``` ### Outcome-Based Resilience -No `PredicateBuilder` or `PredicateResult` chains. Just `Func, bool>`: +A predicate is just `Func, bool>` — no `PredicateResult` chain to learn. `PredicateBuilder` +exists if you prefer composing clauses, but nothing requires it: ```csharp var pipeline = Pipeline.Create(b => b.AddRetry( diff --git a/benchmarks/results/README.md b/benchmarks/results/README.md index 89ff1ba..ad781da 100644 --- a/benchmarks/results/README.md +++ b/benchmarks/results/README.md @@ -37,12 +37,16 @@ chain — see [future-plans.md](../../docs/future-plans.md) item #4 and The composite figure moved from 393 ns / 976 B in the previous run. Two things changed, and it is worth separating them: -- **976 B → 1120 B is a real regression, and deliberate.** The Timeout strategy now records which - cause cancelled its linked token first, so user cancellation can no longer be misclassified as a - timeout under a race. That costs 32 B per timeout, plus a `CancellationToken` registration when - the token reaching that timeout can be cancelled. The outer timeout replaces the context token - with a cancellable one, so the **inner** timeout pays the registration too — hence 144 B on this - two-timeout shape rather than 64 B. Measured in isolation: timeout-only went 360 B → 392 B. +- **976 B → 1120 B is a real regression, and deliberate.** The composite total is measured; the + breakdown that follows is **attribution**, not a separate measurement, and no benchmark report + in this directory isolates it. The Timeout strategy now records which cause cancelled its linked + token first, so user cancellation can no longer be misclassified as a timeout under a race. That + is attributed as 32 B per timeout plus a `CancellationToken` registration when the token + reaching that timeout can be cancelled; the outer timeout replaces the context token with a + cancellable one, so the **inner** timeout is taken to pay the registration too, accounting for + 144 B on this two-timeout shape rather than 64 B. The 360 B → 392 B timeout-only figure comes + from an ad-hoc run that was not committed — treat it as indicative until a + `TimeoutAllocationBenchmarks` exists (future-plans #5). - **393 ns → 476 ns is mostly not that change.** Re-measuring the unmodified code on this machine today gives 431 ns, not 393 ns, so roughly half the apparent slowdown is run-to-run drift in the `ShortRun` configuration rather than the fix. Treat cross-run wall-clock deltas under `ShortRun` @@ -55,8 +59,9 @@ through a real `IHttpClientFactory` graph with an in-memory primary handler, so touched and what remains is pipeline plus per-request bookkeeping. Unlike the other tables on this page, this one is a **full run**, not `ShortRun` — at three -iterations the error bars were ±25% and could not separate the two libraries at all. These are -±0.4%. +iterations the error bars were ±25% and could not separate the two libraries at all. Here the +relative error ranges from **0.37% to 1.50%** per row (widest on Polly's retry arm), which is what +makes the differences below separable at all. | Benchmark | Mean | Allocated | |-----------|-----:|----------:| diff --git a/docs/configuration.md b/docs/configuration.md index 6c45a96..9d113ff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -137,7 +137,9 @@ That is well-defined: a one-minute constant delay, capped at ten seconds. ## Binding notes - **`TimeSpan` binds from a string** in `"[d.]hh:mm:ss"` form — `"00:00:05"`, `"00:01:30"`, - `"1.00:00:00"`. A bare number does not bind. + `"1.00:00:00"`. **Always write the full form.** A bare number *does* bind, but + `TimeSpan.Parse` reads it as **days**, so `"Timeout": "5"` is five days, not five seconds, and + nothing warns you. - **Enums bind from their member name**, case-insensitively: `"Kind": "exponential"` works. - **Validation happens at `Add*` time, not at bind time.** `ToOptions()` returns the record, and the record's own `Validate()` runs when the strategy is added — so a `MaxRetryAttempts` of `-1` diff --git a/docs/future-plans.md b/docs/future-plans.md index 153a01a..b182f7b 100644 --- a/docs/future-plans.md +++ b/docs/future-plans.md @@ -110,50 +110,6 @@ and get that approved separately. --- -### 71. `AddResiliencePipeline` Emits Trim Warnings Under Native AOT - -**Status:** Open -**Type:** Improvement -**Target:** post-1.0 -**Verified:** 2026-10-25 — `dotnet publish samples/Resilion.Samples/Resilion.Samples.csproj -c Release --r osx-arm64 -p:PublishAot=true` → two `IL2091` warnings, at -`ResilionServiceCollectionExtensions.cs:367` and `:402`. - -**Why** - -`MonitoredPipelineConfigurator` and `MonitoredTypedPipelineConfigurator` -pass their `TOptions` to `IOptionsMonitor`, which annotates its type parameter with -`DynamicallyAccessedMemberTypes.PublicParameterlessConstructor`. Ours carries no matching -annotation, so the trimmer cannot prove the constructor survives. - -**This does not fail CI**, because `IL2091` is not in the `/warnaserror` list `test.yml` uses -(`IL2026,IL2070,IL2072,IL3050,IL3053`), and the AOT publish succeeds. It is also consistent with -`Resilion.Extensions` declaring `IsAotCompatible=false` — the package does not claim to be -trim-safe. But an application that AOT-publishes while using the reload overloads sees two -warnings pointing into this library, which is a poor first impression. - -Introduced with the reload overloads in #41 (commit `c4437b4`), not by anything later. - -**Fix** - -Annotate the two type parameters: - -```csharp -internal sealed class MonitoredPipelineConfigurator< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> -``` - -and propagate the annotation up through the four public `AddResiliencePipeline` overloads. - -**Why it was not done before 1.0:** the annotation on a public generic parameter propagates the -requirement to callers, so a caller with its own generic wrapper would start seeing the warning at -its call site. That is source-compatible and can be added in any later version without breaking -anyone, so it did not need to beat the API freeze. Doing it reflexively minutes before the tag was -the worse option. - -**Effort:** Low. - ---- ### 70. `FallbackAction` `Task.Run` Missing `CancellationToken` @@ -317,55 +273,8 @@ delay without the core knowing anything about HTTP. --- -### 64. Composable Event Handlers - -**Status:** Open -**Type:** Improvement -**Target:** unscheduled -**Verified:** 2026-10-23 — `grep -n 'internal.*Invoke' src/Resilion/ResilienceEventHandler.cs`. - -**Why** - -`ResilienceEventHandler`'s invocation is internal, so an out-of-package strategy or handler -can only *replace* a user's `OnRetry`, never wrap it. `Resilion.Http` wanted to hook retry for -response disposal and could not, which is why it carries its own attempt tracker instead. - -Any future package wanting to observe a user-supplied callback hits the same wall. - ---- - -### 65. Registry Factory with IServiceProvider - -**Status:** Open -**Type:** Improvement -**Target:** unscheduled -**Verified:** 2026-10-23 — `ResiliencePipelineRegistry.RegisterPipeline` takes -`Action>` with no provider. - -**Why** - -A pipeline built lazily by the registry cannot resolve services, because the factory signature -carries no `IServiceProvider`. `Resilion.Http` works around it with a small accessor the handler -factory fills immediately before resolving the pipeline — deterministic, but indirection that an -`Action, IServiceProvider>` overload would delete outright. - ---- - -### 66. Synchronous ExecuteOutcome - -**Status:** Open -**Type:** Feature -**Target:** unscheduled -**Verified:** 2026-10-23 — `Pipeline` exposes `ExecuteOutcomeAsync` but no sync equivalent. - -**Why** -`ExecuteOutcomeAsync` is what lets a caller see the winning outcome without it being thrown, which -is how `Resilion.Http` identifies the response to keep and disposes the rest. There is no -synchronous counterpart, so `HttpClient.Send` cannot be supported without either leaking responses -or behaving differently from the async path — it throws `NotSupportedException` instead. ---- ### 4. Per-Call Delegate Allocation in Pipeline Chain @@ -562,42 +471,6 @@ usefully bound, since `Behavior` is required and is a delegate. --- -### 69. No Chaos Injection on the Standard HTTP Handler - -**Status:** Open -**Type:** Feature -**Target:** unscheduled -**Verified:** 2026-10-23 — `grep -n 'Chaos' src/Resilion.Http/*.cs src/Resilion.Http/Internal/*.cs` -→ no matches. `HttpStandardResilienceOptions` exposes `RateLimiter`, `TotalRequestTimeout`, -`Retry`, `CircuitBreaker`, `AttemptTimeout` and `RequestReplay`, with no chaos hook. - -**Why** - -`AddStandardResilienceHandler` builds a fixed five-strategy pipeline, so there is no way to inject -chaos into it. That is the pipeline most users will actually run, and therefore the one most worth -testing — "does my standard handler really retry a 503?" is answerable only by making a 503 happen. - -**There is a real workaround, which is why this is unscheduled rather than P1:** -`AddResilienceHandler(key, builder => …)` takes a pipeline you compose yourself, so you can -reproduce the standard shape and add chaos innermost. The cost is that you now maintain a copy of -the standard pipeline's defaults, which will drift from ours. - -**Options** - -1. **A `Chaos` property on `HttpStandardResilienceOptions`**, appended innermost when set. Smallest - surface, but it puts a `Resilion.Chaos` type on a `Resilion.Http` options class, which would - make `Resilion.Http` depend on `Resilion.Chaos` — a chaos dependency for every HTTP consumer. - Probably disqualifying on its own. -2. **A separate `AddStandardResilienceHandlerWithChaos` in a `Resilion.Http.Chaos` package**, which - depends on both and keeps the base package clean. More packaging for a testing-only feature. -3. **Expose the standard pipeline's configuration as a reusable builder action**, so a caller can - compose it with their own additions without copying the defaults. Solves the drift problem - generally, not just for chaos, and adds no cross-package dependency. - -(3) looks best and is the only one that helps anyone not doing chaos. Do not start any of them -without deciding which. - ---- ### 11. Hedging: O(N²) `Task.WhenAny` Continuation Registration @@ -675,8 +548,132 @@ cancellation. --- +### 64. Composable Event Handlers + +**Status:** Open +**Type:** Improvement +**Target:** unscheduled +**Verified:** 2026-10-23 — `grep -n 'internal.*Invoke' src/Resilion/ResilienceEventHandler.cs`. + +**Why** + +`ResilienceEventHandler`'s invocation is internal, so an out-of-package strategy or handler +can only *replace* a user's `OnRetry`, never wrap it. `Resilion.Http` wanted to hook retry for +response disposal and could not, which is why it carries its own attempt tracker instead. + +Any future package wanting to observe a user-supplied callback hits the same wall. + +--- + +--- + +### 65. Registry Factory with IServiceProvider + +**Status:** Open +**Type:** Improvement +**Target:** unscheduled +**Verified:** 2026-10-23 — `ResiliencePipelineRegistry.RegisterPipeline` takes +`Action>` with no provider. + +**Why** + +A pipeline built lazily by the registry cannot resolve services, because the factory signature +carries no `IServiceProvider`. `Resilion.Http` works around it with a small accessor the handler +factory fills immediately before resolving the pipeline — deterministic, but indirection that an +`Action, IServiceProvider>` overload would delete outright. + +--- + +--- + +### 71. `AddResiliencePipeline` Emits Trim Warnings Under Native AOT + +**Status:** Open +**Type:** Improvement +**Target:** post-1.0 +**Verified:** 2026-10-25 — `dotnet publish samples/Resilion.Samples/Resilion.Samples.csproj -c Release +-r osx-arm64 -p:PublishAot=true` → two `IL2091` warnings, at +`ResilionServiceCollectionExtensions.cs:367` and `:402`. + +**Why** + +`MonitoredPipelineConfigurator` and `MonitoredTypedPipelineConfigurator` +pass their `TOptions` to `IOptionsMonitor`, which annotates its type parameter with +`DynamicallyAccessedMemberTypes.PublicParameterlessConstructor`. Ours carries no matching +annotation, so the trimmer cannot prove the constructor survives. + +**This does not fail CI**, because `IL2091` is not in the `/warnaserror` list `test.yml` uses +(`IL2026,IL2070,IL2072,IL3050,IL3053`), and the AOT publish succeeds. It is also consistent with +`Resilion.Extensions` declaring `IsAotCompatible=false` — the package does not claim to be +trim-safe. But an application that AOT-publishes while using the reload overloads sees two +warnings pointing into this library, which is a poor first impression. + +Introduced with the reload overloads in #41 (commit `c4437b4`), not by anything later. + +**Fix** + +Annotate the two type parameters: + +```csharp +internal sealed class MonitoredPipelineConfigurator< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> +``` + +and propagate the annotation up through the four public `AddResiliencePipeline` overloads. + +**Why it was not done before 1.0:** the annotation on a public generic parameter propagates the +requirement to callers, so a caller with its own generic wrapper would start seeing the warning at +its call site. That is source-compatible and can be added in any later version without breaking +anyone, so it did not need to beat the API freeze. Doing it reflexively minutes before the tag was +the worse option. + +**Effort:** Low. + +--- + +--- + ## P3 — Blocked / needs external change +### 69. No Chaos Injection on the Standard HTTP Handler + +**Status:** Open +**Type:** Feature +**Target:** unscheduled +**Verified:** 2026-10-23 — `grep -n 'Chaos' src/Resilion.Http/*.cs src/Resilion.Http/Internal/*.cs` +→ no matches. `HttpStandardResilienceOptions` exposes `RateLimiter`, `TotalRequestTimeout`, +`Retry`, `CircuitBreaker`, `AttemptTimeout` and `RequestReplay`, with no chaos hook. + +**Why** + +`AddStandardResilienceHandler` builds a fixed five-strategy pipeline, so there is no way to inject +chaos into it. That is the pipeline most users will actually run, and therefore the one most worth +testing — "does my standard handler really retry a 503?" is answerable only by making a 503 happen. + +**There is a real workaround, which is why this is unscheduled rather than P1:** +`AddResilienceHandler(key, builder => …)` takes a pipeline you compose yourself, so you can +reproduce the standard shape and add chaos innermost. The cost is that you now maintain a copy of +the standard pipeline's defaults, which will drift from ours. + +**Options** + +1. **A `Chaos` property on `HttpStandardResilienceOptions`**, appended innermost when set. Smallest + surface, but it puts a `Resilion.Chaos` type on a `Resilion.Http` options class, which would + make `Resilion.Http` depend on `Resilion.Chaos` — a chaos dependency for every HTTP consumer. + Probably disqualifying on its own. +2. **A separate `AddStandardResilienceHandlerWithChaos` in a `Resilion.Http.Chaos` package**, which + depends on both and keeps the base package clean. More packaging for a testing-only feature. +3. **Expose the standard pipeline's configuration as a reusable builder action**, so a caller can + compose it with their own additions without copying the defaults. Solves the drift problem + generally, not just for chaos, and adds no cross-package dependency. + +(3) looks best and is the only one that helps anyone not doing chaos. Do not start any of them +without deciding which. + +--- + +--- + ### 48. Benchmark CI on PRs **Status:** Open @@ -773,14 +770,26 @@ a non-empty report, this item is solved without touching a single project file. **Decision:** `coverlet.collector` stays in place for now — a harmless no-op under MTP that at least doesn't break the build. -**Unrelated cleanup to fold in:** `Microsoft.Extensions.TimeProvider.Testing` is **8.0.0** in -`tests/Resilion.Tests` and **9.0.0** in `tests/Resilion.Extensions.Tests`. Standardize on 9.0.0 in -`tests/Directory.Build.props` and drop both per-project references. - **Effort:** Medium, mostly experimentation. --- +### 66. Synchronous ExecuteOutcome + +**Status:** Open +**Type:** Feature +**Target:** unscheduled +**Verified:** 2026-10-23 — `Pipeline` exposes `ExecuteOutcomeAsync` but no sync equivalent. + +**Why** + +`ExecuteOutcomeAsync` is what lets a caller see the winning outcome without it being thrown, which +is how `Resilion.Http` identifies the response to keep and disposes the rest. There is no +synchronous counterpart, so `HttpClient.Send` cannot be supported without either leaking responses +or behaving differently from the async path — it throws `NotSupportedException` instead. + +--- + ## Retired items Numbers that have left the active list. **Never reuse a number.** diff --git a/docs/http.md b/docs/http.md index 388aa02..e3bfc50 100644 --- a/docs/http.md +++ b/docs/http.md @@ -18,7 +18,7 @@ That gives the client, outermost first: |---|---|---| | 1 | Rate limiter | **off** — see [Rate limiting](#rate-limiting) | | 2 | Total request timeout | 30 seconds, across all attempts | -| 3 | Retry | 3 attempts, jittered exponential backoff from 2s | +| 3 | Retry | 3 retries — 4 sends including the initial one — jittered exponential backoff from 2s | | 4 | Circuit breaker | 10% failure ratio over 30s, minimum throughput 100, break 5s | | 5 | Per-attempt timeout | 10 seconds | diff --git a/docs/verification.md b/docs/verification.md index cc91f55..0713671 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -46,7 +46,9 @@ grep -rn '\.Add(1' src/ --include='*.cs' | grep -v '/obj/' | wc -l # Does hedging copy PipelineName to per-attempt contexts? (future-plans #59) grep -n 'PipelineName\|OperationKey\|ContinueOnCapturedContext' src/Resilion/Hedging/HedgingStrategy.cs -# Expected today: OperationKey and ContinueOnCapturedContext are copied; PipelineName is NOT. +# Expected: PipelineName, OperationKey and ContinueOnCapturedContext are all copied to the +# per-attempt contexts, on both the async and sync paths. (was: PipelineName was omitted, so every +# strategy nested inside hedging emitted a null pipeline.name tag.) ``` ## Public API surface @@ -94,8 +96,11 @@ unzip -l "$TMPDIR/rpack"/Resilion.*.snupkg | grep '\.pdb' # expect lib/net8 # How many packages does the release workflow expect? grep -n 'dotnet pack\|COUNT\|PROJECTS' .github/workflows/release-nuget.yml -# Today: three explicit pack lines and a hardcoded `-ne 3`. Every new package breaks -# every release until this is data-driven. +# Expected: one pack loop over a PROJECTS array, with EXPECTED_PACKAGE_COUNT derived from its +# length rather than hardcoded. The array must list every src package — as of 1.0.0: Resilion, +# Resilion.Extensions, Resilion.RateLimiting, Resilion.Http, Resilion.Chaos. A package missing +# from it is published by nobody and the count assert still passes, so check the array itself, +# not just the count. (was: three explicit pack lines and a hardcoded `-ne 3`.) # Version stamping grep -n 'Version\|VersionPrefix' src/Directory.Build.props @@ -171,10 +176,14 @@ grep -n 'coverlet\|CodeCoverage\|TestingPlatformDotnetTestSupport' tests/Directo # Test-dependency drift grep -rn 'TimeProvider.Testing' tests/ -# Expected today: 8.0.0 in Resilion.Tests, 9.0.0 in Resilion.Extensions.Tests — inconsistent. +# Expected: a single reference in tests/Directory.Build.props pinning 9.0.0 for every test +# project, and no per-project references. (was: 8.0.0 in Resilion.Tests and 9.0.0 in +# Resilion.Extensions.Tests, so the fake clock under test was not the same one everywhere.) # Vulnerability disclosure policy (future-plans #53) -ls SECURITY.md .github/SECURITY.md 2>&1 # expected today: neither exists +ls SECURITY.md .github/SECURITY.md 2>&1 +# Expected: SECURITY.md exists at the repository root; .github/SECURITY.md does not, and should +# not — GitHub reads either, and two copies drift. (was: neither existed.) ``` ## Self-consistency of `future-plans.md` diff --git a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs index 96b3347..d5bb27b 100644 --- a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs +++ b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs @@ -357,7 +357,17 @@ public void Configure(ResiliencePipelineRegistry registry, IServiceProvi // IOptionsMonitor.OnChange is declared as returning IDisposable?, and a custom monitor // implementation may legitimately return null. The registry owns whatever we get. - var subscription = monitor.OnChange((_, _) => registry.InvalidatePipeline(_name)); + // + // Filter on the changed name: OnChange fires for every named instance of TOptions, so a + // pipeline bound to "billing" would otherwise rebuild whenever "api" changed. The monitor + // reports the default instance as null. + var subscription = monitor.OnChange((_, changedName) => + { + if (Matches(changedName)) + { + registry.InvalidatePipeline(_name); + } + }); if (subscription is not null) { registry.AddReloadSubscription(subscription); @@ -366,6 +376,17 @@ public void Configure(ResiliencePipelineRegistry registry, IServiceProvi private TOptions CurrentValue(IOptionsMonitor monitor) => _optionsName is null ? monitor.CurrentValue : monitor.Get(_optionsName); + + /// + /// Whether a change notification for concerns this pipeline's + /// options instance. reports the default instance as + /// . + /// + private bool Matches(string? changedName) + => string.Equals( + changedName ?? Options.DefaultName, + _optionsName ?? Options.DefaultName, + StringComparison.Ordinal); } /// The typed counterpart of . @@ -392,7 +413,14 @@ public void Configure(ResiliencePipelineRegistry registry, IServiceProvi registry.RegisterPipeline(_name, builder => _configure(builder, CurrentValue(monitor))); - var subscription = monitor.OnChange((_, _) => registry.InvalidatePipeline(_name)); + // See MonitoredPipelineConfigurator for why the changed name is filtered. + var subscription = monitor.OnChange((_, changedName) => + { + if (Matches(changedName)) + { + registry.InvalidatePipeline(_name); + } + }); if (subscription is not null) { registry.AddReloadSubscription(subscription); @@ -401,6 +429,17 @@ public void Configure(ResiliencePipelineRegistry registry, IServiceProvi private TOptions CurrentValue(IOptionsMonitor monitor) => _optionsName is null ? monitor.CurrentValue : monitor.Get(_optionsName); + + /// + /// Whether a change notification for concerns this pipeline's + /// options instance. reports the default instance as + /// . + /// + private bool Matches(string? changedName) + => string.Equals( + changedName ?? Options.DefaultName, + _optionsName ?? Options.DefaultName, + StringComparison.Ordinal); } internal sealed class PipelineConfigurator : IPipelineConfigurator diff --git a/src/Resilion.Http/HttpStandardResilienceOptions.cs b/src/Resilion.Http/HttpStandardResilienceOptions.cs index 43f24cb..2bde1fd 100644 --- a/src/Resilion.Http/HttpStandardResilienceOptions.cs +++ b/src/Resilion.Http/HttpStandardResilienceOptions.cs @@ -66,8 +66,8 @@ public sealed class HttpStandardResilienceOptions new() { Timeout = TimeSpan.FromSeconds(30) }; /// - /// Gets or sets the retry options. Defaults to 3 attempts with jittered exponential backoff - /// from 2 seconds. + /// Gets or sets the retry options. Defaults to 3 retries — four sends in total, + /// counting the initial one — with jittered exponential backoff from 2 seconds. /// public RetryStrategyOptions Retry { get; set; } = new() { diff --git a/src/Resilion.Http/Internal/ResilienceHandler.cs b/src/Resilion.Http/Internal/ResilienceHandler.cs index 25431fb..ab43bf3 100644 --- a/src/Resilion.Http/Internal/ResilienceHandler.cs +++ b/src/Resilion.Http/Internal/ResilienceHandler.cs @@ -176,6 +176,11 @@ private async ValueTask> SendAttemptAsync( { if (!ReferenceEquals(message, state.Request)) { + // Point the context back at the caller's live request BEFORE disposing the clone. + // Strategy callbacks run after this returns — OnRetry fires between attempts — and + // GetRequestMessage() would otherwise hand them a disposed message. + context.Properties.Set(HttpResilienceKeys.RequestMessage, state.Request); + // Disposes the clone and its ByteArrayContent; the shared body array is untouched. message.Dispose(); } diff --git a/src/Resilion.Http/ResilionHttpClientBuilderExtensions.cs b/src/Resilion.Http/ResilionHttpClientBuilderExtensions.cs index f415189..fcf032c 100644 --- a/src/Resilion.Http/ResilionHttpClientBuilderExtensions.cs +++ b/src/Resilion.Http/ResilionHttpClientBuilderExtensions.cs @@ -281,6 +281,14 @@ private static IHttpClientBuilder AddHandler( bool sequentialAttempts, Func resolveReplay) { + // Resolved once, not per handler. Working out the replay mode means running the caller's + // configure delegate again, and IHttpClientFactory rebuilds the handler chain roughly + // every two minutes — so without this the delegate runs for the lifetime of the app, and + // any side effect in it runs with it. Lazy's default mode is publication-safe, so + // concurrent first resolutions agree on one value. + var replay = new Lazy( + () => resolveReplay(AccessorServices(accessor, pipelineKey))); + builder.AddHttpMessageHandler(sp => { // Assigned immediately before GetPipeline, which is the only call that can trigger the @@ -290,7 +298,7 @@ private static IHttpClientBuilder AddHandler( var pipeline = sp.GetRequiredService>() .GetPipeline(pipelineKey); - return new ResilienceHandler(pipeline, resolveReplay(accessor.Services), sequentialAttempts); + return new ResilienceHandler(pipeline, replay.Value, sequentialAttempts); }); return builder; diff --git a/src/Resilion/Hedging/HedgingStrategy.cs b/src/Resilion/Hedging/HedgingStrategy.cs index 12a6997..f15f38e 100644 --- a/src/Resilion/Hedging/HedgingStrategy.cs +++ b/src/Resilion/Hedging/HedgingStrategy.cs @@ -54,6 +54,7 @@ protected internal override async ValueTask> ExecuteAsync( if (!_options.ShouldHandleOutcome(completed)) { // Success — no need for more attempts. + StrategyActivity.SetOutcome(activity, "success"); return completed; } } @@ -74,6 +75,7 @@ protected internal override async ValueTask> ExecuteAsync( var completed = await completedTask.ConfigureAwait(false); if (!_options.ShouldHandleOutcome(completed)) { + StrategyActivity.SetOutcome(activity, "success"); return completed; } diff --git a/tests/Resilion.Extensions.Tests/Configuration/StrategyConfigurationTests.cs b/tests/Resilion.Extensions.Tests/Configuration/StrategyConfigurationTests.cs index 08f57db..6daa646 100644 --- a/tests/Resilion.Extensions.Tests/Configuration/StrategyConfigurationTests.cs +++ b/tests/Resilion.Extensions.Tests/Configuration/StrategyConfigurationTests.cs @@ -135,6 +135,30 @@ private static IConfigurationRoot Config(params (string Key, string Value)[] val .AddInMemoryCollection(values.ToDictionary(v => v.Key, v => (string?)v.Value)) .Build(); + [Fact] + public void BareNumber_BindsAsDays_NotSeconds() + { + // Pins the foot-gun documented in configuration.md: ConfigurationBinder goes through + // TimeSpan.Parse, which reads an unqualified number as days. "5" is five days, and + // nothing warns. If this ever starts throwing or binding as seconds, the doc is wrong. + var config = Config(("Timeout", "5")); + var dto = new TimeoutStrategyConfiguration(); + config.Bind(dto); + + Assert.Equal(TimeSpan.FromDays(5), dto.Timeout); + Assert.NotEqual(TimeSpan.FromSeconds(5), dto.Timeout); + } + + [Fact] + public void FullTimeSpanForm_BindsAsWritten() + { + var config = Config(("Timeout", "00:00:05")); + var dto = new TimeoutStrategyConfiguration(); + config.Bind(dto); + + Assert.Equal(TimeSpan.FromSeconds(5), dto.Timeout); + } + [Fact] public void BindsFromConfiguration_IncludingTimeSpanStrings() { diff --git a/tests/Resilion.Extensions.Tests/PipelineReloadTests.cs b/tests/Resilion.Extensions.Tests/PipelineReloadTests.cs index 6b570b8..3cc4fbf 100644 --- a/tests/Resilion.Extensions.Tests/PipelineReloadTests.cs +++ b/tests/Resilion.Extensions.Tests/PipelineReloadTests.cs @@ -298,8 +298,67 @@ public void NamedOptions_OptionsNameOverload_BindsTheNamedInstance() Assert.Equal(8, attempts); // 1 initial + 7 retries } + /// + /// A monitor whose change subscription reports whether it was disposed. + /// + private sealed class RecordingOptionsMonitor : IOptionsMonitor + { + private readonly List> _listeners = []; + + internal bool SubscriptionDisposed { get; private set; } + + public RetryOptions CurrentValue { get; } = new(); + + public RetryOptions Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) + { + _listeners.Add(listener); + return new Subscription(this); + } + + internal void Notify(string? name) + { + foreach (var listener in _listeners.ToArray()) + { + listener(CurrentValue, name); + } + } + + private sealed class Subscription : IDisposable + { + private readonly RecordingOptionsMonitor _owner; + + internal Subscription(RecordingOptionsMonitor owner) => _owner = owner; + + public void Dispose() => _owner.SubscriptionDisposed = true; + } + } + [Fact] public void RegistryDispose_DisposesReloadSubscriptions() + { + // Asserting the subscription is actually disposed, not merely that teardown doesn't throw: + // the previous version of this test passed with AddReloadSubscription deleted entirely. + var monitor = new RecordingOptionsMonitor(); + var services = new ServiceCollection(); + services.AddSingleton>(monitor); + services.AddResiliencePipeline("api", (b, o) => b.AddRetry( + new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts })); + + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService>(); + _ = registry.GetPipeline("api"); + + Assert.False(monitor.SubscriptionDisposed); + + registry.Dispose(); + + Assert.True(monitor.SubscriptionDisposed); + } + + [Fact] + public void RegistryDispose_ThenOptionsChange_DoesNotThrow() { var (provider, config) = BuildProvider(s => s.AddResiliencePipeline("api", (b, o) => b.AddRetry( @@ -315,6 +374,37 @@ public void RegistryDispose_DisposesReloadSubscriptions() provider.Dispose(); } + [Fact] + public void OptionsChange_ForADifferentNamedInstance_DoesNotInvalidate() + { + // OnChange fires for every named instance of TOptions, so without filtering on the + // changed name a pipeline bound to "fast" rebuilds whenever any other name changes. + var monitor = new RecordingOptionsMonitor(); + var builds = 0; + + var services = new ServiceCollection(); + services.AddSingleton>(monitor); + services.AddResiliencePipeline("api", "fast", (b, o) => + { + builds++; + b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts }); + }); + + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService>(); + + _ = registry.GetPipeline("api"); + Assert.Equal(1, builds); + + monitor.Notify("slow"); // a different named instance + _ = registry.GetPipeline("api"); + Assert.Equal(1, builds); // not rebuilt + + monitor.Notify("fast"); // ours + _ = registry.GetPipeline("api"); + Assert.Equal(2, builds); + } + [Fact] public async Task Async_FailedLookupStillDoesNotPoisonCache_AfterInvalidate() { diff --git a/tests/Resilion.Http.Tests/ReviewRegressionTests.cs b/tests/Resilion.Http.Tests/ReviewRegressionTests.cs new file mode 100644 index 0000000..d1ff7ae --- /dev/null +++ b/tests/Resilion.Http.Tests/ReviewRegressionTests.cs @@ -0,0 +1,108 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Resilion.Http.Tests; + +/// +/// Regressions found in review of the 1.0 branch. +/// +public class ReviewRegressionTests +{ + [Fact] + public async Task CloneRequest_OnRetryCallback_SeesALiveRequestNotTheDisposedClone() + { + // The per-attempt clone is disposed in SendAttemptAsync's finally, but callbacks run + // after that returns. Leaving the clone in context.Properties handed OnRetry a disposed + // message, so reading its content threw ObjectDisposedException. + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var observed = new List(); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.RequestReplay = HttpRequestReplay.CloneRequest; + o.Retry = o.Retry with + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + OnRetry = args => + { + try + { + var request = args.Context.GetRequestMessage(); + // Touching the content is what surfaces a disposed message. + _ = request?.Content?.Headers.ContentLength; + observed.Add(null); + } + catch (Exception ex) + { + observed.Add(ex); + } + + return default; + }, + }; + })); + + using var scope = provider; + + using var message = new HttpRequestMessage(HttpMethod.Post, "https://example.test/x") + { + Content = JsonContent.Create(new { value = 1 }), + }; + + (await client.SendAsync(message)).Dispose(); + + var failure = Assert.Single(observed); + Assert.Null(failure); + } + + [Fact] + public async Task ConfigureDelegate_IsNotReRunOnHandlerRotation() + { + // Resolving the replay mode re-runs the caller's configure delegate, and the handler + // factory re-runs on every IHttpClientFactory rotation — so without caching it ran for + // the life of the app, taking any side effect in the delegate with it. + // + // Deliberately slow, for the same reason as Async_CircuitBreakerState_SurvivesHandlerRotation: + // SetHandlerLifetime enforces a one-second minimum and rotation cannot be forced. + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK); + + var configureCalls = 0; + + var services = new ServiceCollection(); + services.AddHttpClient(TestClientFactory.ClientName) + .ConfigurePrimaryHttpMessageHandler(() => stub) + .AddStandardResilienceHandler(_ => Interlocked.Increment(ref configureCalls)) + .SetHandlerLifetime(TimeSpan.FromSeconds(1)); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + + (await factory.CreateClient(TestClientFactory.ClientName) + .GetAsync("https://example.test/x")).Dispose(); + + // The exact baseline is an implementation detail — the delegate runs while the pipeline + // is built and again while the replay mode is probed, and the factory may construct the + // chain more than once on first use. What matters is that it stops there. + var afterFirst = Volatile.Read(ref configureCalls); + Assert.True(afterFirst > 0, "configure should have run at least once"); + + // Let the handler chain expire so the next client builds a fresh one. + await Task.Delay(TimeSpan.FromMilliseconds(1300)); + + (await factory.CreateClient(TestClientFactory.ClientName) + .GetAsync("https://example.test/y")).Dispose(); + + // Unchanged: the rotation must not re-run the delegate. + Assert.Equal(afterFirst, Volatile.Read(ref configureCalls)); + } + + +} diff --git a/tests/Resilion.Tests/Telemetry/ActivityTests.cs b/tests/Resilion.Tests/Telemetry/ActivityTests.cs index ffb23d8..49f3d61 100644 --- a/tests/Resilion.Tests/Telemetry/ActivityTests.cs +++ b/tests/Resilion.Tests/Telemetry/ActivityTests.cs @@ -159,6 +159,29 @@ public void Sync_HedgingPipeline_EmitsHedgingSpan() Assert.Contains("Hedging", tracker.Names()); } + [Fact] + public async Task Async_HedgingSucceedsOnAnEarlyReturn_SpanStillRecordsTheOutcome() + { + // Sequential mode returns as soon as an attempt succeeds, before the loop finishes. Those + // early returns used to skip SetOutcome, so a hedged call that succeeded produced a span + // with no outcome tag — present in the trace, silent about what happened. + using var tracker = new ActivityTracker("async-hedging-early-return"); + var pipeline = Pipeline.Create(b => + { + b.Name = "async-hedging-early-return"; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + }); + }); + + Assert.Equal(1, await pipeline.ExecuteAsync(ct => new ValueTask(1))); + + var hedging = Assert.Single(tracker.Stopped.Where(a => a.OperationName == "Hedging")); + Assert.Equal("success", hedging.Tags.FirstOrDefault(t => t.Key == "outcome").Value); + } + // ─── Tag shape ────────────────────────────────────────────────────────────────────────── [Fact] From e4863107f511494b8628881e00d6981551d52484 Mon Sep 17 00:00:00 2001 From: Ali-Tegna <31090457+FreakyAli@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:21:22 +0530 Subject: [PATCH 4/5] fix: more coderabbit shit --- README.md | 4 ++-- SECURITY.md | 4 ++-- .../HttpResilienceHandlerBenchmarks.cs | 4 +++- benchmarks/results/README.md | 15 ++++++++++---- docs/migration-from-polly.md | 2 +- docs/telemetry.md | 2 +- docs/testing.md | 2 +- docs/verification.md | 7 ++++--- .../ResiliencePipelineRegistry.cs | 20 ++++++++++++++++--- .../ResilionServiceCollectionExtensions.cs | 13 ++++++------ .../Internal/ResilienceHandler.cs | 9 +++++++++ 11 files changed, 58 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9bdb9f8..3313acf 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,8 @@ var result = await pipeline.ExecuteAsync(async (state, ct) => ``` ### Outcome-Based Resilience -A predicate is just `Func, bool>` — no `PredicateResult` chain to learn. `PredicateBuilder` -exists if you prefer composing clauses, but nothing requires it: +A predicate is just `Func, bool>` — a plain lambda is always sufficient. +`PredicateBuilder` exists if you prefer composing clauses, but nothing requires it: ```csharp var pipeline = Pipeline.Create(b => b.AddRetry( diff --git a/SECURITY.md b/SECURITY.md index cd1d7fd..4187d4b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported Versions -Security fixes are applied to the current minor release and the one before it. Pre-release versions -are not supported — if you are on a `-pre` build, upgrade to the latest stable release first. +Security fixes are applied to the current stable release. Pre-release versions are not supported — +if you are on a `-pre` build, upgrade to the latest stable release first. | Version | Supported | |---------|-----------| diff --git a/benchmarks/Resilion.Benchmarks/HttpResilienceHandlerBenchmarks.cs b/benchmarks/Resilion.Benchmarks/HttpResilienceHandlerBenchmarks.cs index f60991a..965f1e3 100644 --- a/benchmarks/Resilion.Benchmarks/HttpResilienceHandlerBenchmarks.cs +++ b/benchmarks/Resilion.Benchmarks/HttpResilienceHandlerBenchmarks.cs @@ -113,7 +113,8 @@ private static ServiceProvider BuildBare() { var services = new ServiceCollection(); services.AddHttpClient("bench") - .ConfigurePrimaryHttpMessageHandler(() => new EchoHandler()); + .ConfigurePrimaryHttpMessageHandler(() => new EchoHandler()) + .ConfigureHttpClient(c => c.Timeout = System.Threading.Timeout.InfiniteTimeSpan); return services.BuildServiceProvider(); } @@ -127,6 +128,7 @@ private static ServiceProvider BuildResilion(bool retry) services.AddHttpClient("bench") .ConfigurePrimaryHttpMessageHandler(() => retry ? new FailOnceHandler() : new EchoHandler()) + .ConfigureHttpClient(c => c.Timeout = System.Threading.Timeout.InfiniteTimeSpan) .AddStandardResilienceHandler((o, sp) => { // Matched to Polly's default, which enables one. diff --git a/benchmarks/results/README.md b/benchmarks/results/README.md index ad781da..fcaf018 100644 --- a/benchmarks/results/README.md +++ b/benchmarks/results/README.md @@ -58,10 +58,17 @@ worth separating them: through a real `IHttpClientFactory` graph with an in-memory primary handler, so no socket is touched and what remains is pipeline plus per-request bookkeeping. -Unlike the other tables on this page, this one is a **full run**, not `ShortRun` — at three -iterations the error bars were ±25% and could not separate the two libraries at all. Here the -relative error ranges from **0.37% to 1.50%** per row (widest on Polly's retry arm), which is what -makes the differences below separable at all. +Unlike the other tables on this page, this one is a **full run** (BenchmarkDotNet default job — +full pilot/warmup/actual stages), not the `ShortRun` used above. To reproduce: + +```bash +dotnet run -c Release --project benchmarks/Resilion.Benchmarks -- --filter '*HttpResilience*' +``` + +Drop `-- --filter ...` to run the entire suite. At three iterations (`--job short`) the error bars +were ±25% and could not separate the two libraries; here the relative error ranges from **0.37% to +1.50%** per row (widest on Polly's retry arm), which is what makes the differences below separable +at all. | Benchmark | Mean | Allocated | |-----------|-----:|----------:| diff --git a/docs/migration-from-polly.md b/docs/migration-from-polly.md index 462d65e..b97c7db 100644 --- a/docs/migration-from-polly.md +++ b/docs/migration-from-polly.md @@ -165,7 +165,7 @@ dotnet add package Resilion.Http | `Microsoft.Extensions.Http.Resilience` | `Resilion.Http` | Notes | |-------|----------|-------| -| `AddStandardResilienceHandler()` | `AddStandardResilienceHandler()` | Same name, same receiver. Same five strategies, outermost first: rate limiter, total request timeout, retry, circuit breaker, per-attempt timeout. | +| `AddStandardResilienceHandler()` | `AddStandardResilienceHandler()` | Same name, same receiver. Up to five strategies, outermost first: rate limiter (disabled by default — opt in by setting `HttpStandardResilienceOptions.RateLimiter`), total request timeout, retry, circuit breaker, per-attempt timeout. | | `AddStandardHedgingHandler()` | `AddStandardHedgingHandler()` | Hedging in place of retry. Returns `IHttpClientBuilder` for chaining, not a routing builder — see the differences below. | | `AddResilienceHandler(name, builder => ...)` | `AddResilienceHandler(key, pb => ...)` | `pb` is a `PipelineBuilder`. The key must be unique within the client; registering it twice throws at registration time rather than on first resolve. | | `HttpStandardResilienceOptions.{RateLimiter, TotalRequestTimeout, Retry, CircuitBreaker, AttemptTimeout}` | `HttpStandardResilienceOptions` — the same five property names | The container is a mutable class in both libraries. Each *leg* is a Resilion options `record`, so you assign a new one instead of mutating it — see below. | diff --git a/docs/telemetry.md b/docs/telemetry.md index 99ce0d3..b498abb 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -24,7 +24,7 @@ instrument name itself (`resilion.retry.attempts`, `resilion.timeout.expirations | Tag | Example | Notes | |-----|---------|-------| -| `pipeline.name` | `"http-api"` | Name passed to `AddResiliencePipeline()` or the registry key. **Null** for unnamed pipelines, and null for any strategy nested inside hedging — see the gap note below | +| `pipeline.name` | `"http-api"` | Name passed to `AddResiliencePipeline()` or the registry key. **Null** for unnamed pipelines. | | `operation.key` | `"GET /users"` | Custom operation key from `ResilienceContext`. Null unless you set it | Example: A `resilion.retry.attempts` counter for a named pipeline emits as: diff --git a/docs/testing.md b/docs/testing.md index 309eab4..6fed8da 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -102,7 +102,7 @@ public async Task Circuit_Opens_After_Failures() } ``` -This is a test-only convenience for *observing* state. Production code must not branch on it — see +This is an observation-only convenience for *inspecting* state. Production code must not branch on it — see [circuit-breaker.md](circuit-breaker.md#do-not-gate-execution-on-it). Alternatively, trip the circuit and verify rejection, which also exercises the caller's path: diff --git a/docs/verification.md b/docs/verification.md index 0713671..e09bd37 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -83,7 +83,8 @@ dotnet build Resilion.sln -c Release -p:ContinuousIntegrationBuild=true --no-inc # Is SourceLink / are symbols configured? (future-plans #52) grep -rn 'PublishRepositoryUrl\|EmbedUntrackedSources\|IncludeSymbols\|SymbolPackageFormat\|ContinuousIntegrationBuild\|SourceLink\|DebugType' \ src/ .github/ --include='*.props' --include='*.csproj' --include='*.yml' -# Expected today: 0 hits. +# Expected: hits in src/Directory.Build.props and .github/workflows/release-nuget.yml confirming +# SourceLink and symbols are configured. # Do they actually end up in the package? Configuration alone proves nothing. dotnet pack src/Resilion/Resilion.csproj -c Release -p:ContinuousIntegrationBuild=true -o "$TMPDIR/rpack" @@ -100,7 +101,7 @@ grep -n 'dotnet pack\|COUNT\|PROJECTS' .github/workflows/release-nuget.yml # length rather than hardcoded. The array must list every src package — as of 1.0.0: Resilion, # Resilion.Extensions, Resilion.RateLimiting, Resilion.Http, Resilion.Chaos. A package missing # from it is published by nobody and the count assert still passes, so check the array itself, -# not just the count. (was: three explicit pack lines and a hardcoded `-ne 3`.) +# not just the count. # Version stamping grep -n 'Version\|VersionPrefix' src/Directory.Build.props @@ -183,7 +184,7 @@ grep -rn 'TimeProvider.Testing' tests/ # Vulnerability disclosure policy (future-plans #53) ls SECURITY.md .github/SECURITY.md 2>&1 # Expected: SECURITY.md exists at the repository root; .github/SECURITY.md does not, and should -# not — GitHub reads either, and two copies drift. (was: neither existed.) +# not — GitHub reads either, and two copies drift. ``` ## Self-consistency of `future-plans.md` diff --git a/src/Resilion.Extensions/ResiliencePipelineRegistry.cs b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs index 6109502..8454f50 100644 --- a/src/Resilion.Extensions/ResiliencePipelineRegistry.cs +++ b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs @@ -189,7 +189,7 @@ public bool InvalidatePipeline(TKey key) if (lazy.IsValueCreated) { - OnPipelineReplaced?.Invoke(new PipelineReplacedArgs(key, null, lazy.Value)); + RaisePipelineReplaced(new PipelineReplacedArgs(key, null, lazy.Value)); } return true; @@ -218,13 +218,27 @@ public bool InvalidatePipeline(TKey key) if (lazy.IsValueCreated && lazy.Value is IAsyncDisposable disposable) { - OnPipelineReplaced?.Invoke( - new PipelineReplacedArgs(key, typeof(TResult), disposable)); + RaisePipelineReplaced(new PipelineReplacedArgs(key, typeof(TResult), disposable)); } return true; } + private void RaisePipelineReplaced(PipelineReplacedArgs args) + { + try + { + OnPipelineReplaced?.Invoke(args); + } + catch (Exception ex) + { + // Prevent a callback exception from propagating into the options monitor's listener + // loop and interrupting pipeline reload. Report via Debug so it is visible in + // diagnostics without affecting production control flow. + System.Diagnostics.Debug.WriteLine($"[Resilion] OnPipelineReplaced callback threw: {ex}"); + } + } + /// /// Takes ownership of a change-notification subscription, disposing it with the registry. /// diff --git a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs index d5bb27b..8a69a73 100644 --- a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs +++ b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -121,7 +122,7 @@ public static IServiceCollection AddResiliencePipeline( /// dictionary lookup. /// /// - public static IServiceCollection AddResiliencePipeline( + public static IServiceCollection AddResiliencePipeline<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>( this IServiceCollection services, string name, Action configure) @@ -140,7 +141,7 @@ public static IServiceCollection AddResiliencePipeline( /// /// Configures the builder from the current options value. /// The service collection for chaining. - public static IServiceCollection AddResiliencePipeline( + public static IServiceCollection AddResiliencePipeline<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>( this IServiceCollection services, string name, string? optionsName, @@ -170,7 +171,7 @@ public static IServiceCollection AddResiliencePipeline( /// The pipeline name. /// Configures the builder from the current options value. /// The service collection for chaining. - public static IServiceCollection AddResiliencePipeline( + public static IServiceCollection AddResiliencePipeline( this IServiceCollection services, string name, Action, TOptions> configure) @@ -191,7 +192,7 @@ public static IServiceCollection AddResiliencePipeline( /// /// Configures the builder from the current options value. /// The service collection for chaining. - public static IServiceCollection AddResiliencePipeline( + public static IServiceCollection AddResiliencePipeline( this IServiceCollection services, string name, string? optionsName, @@ -330,7 +331,7 @@ public RegistryConfigurator(Action> configure /// Registers a pipeline factory that reads at build time, and /// invalidates the cached pipeline whenever those options change. /// -internal sealed class MonitoredPipelineConfigurator : IPipelineConfigurator +internal sealed class MonitoredPipelineConfigurator<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> : IPipelineConfigurator where TOptions : class { private readonly string _name; @@ -390,7 +391,7 @@ private bool Matches(string? changedName) } /// The typed counterpart of . -internal sealed class MonitoredTypedPipelineConfigurator : IPipelineConfigurator +internal sealed class MonitoredTypedPipelineConfigurator : IPipelineConfigurator where TOptions : class { private readonly string _name; diff --git a/src/Resilion.Http/Internal/ResilienceHandler.cs b/src/Resilion.Http/Internal/ResilienceHandler.cs index ab43bf3..cff0105 100644 --- a/src/Resilion.Http/Internal/ResilienceHandler.cs +++ b/src/Resilion.Http/Internal/ResilienceHandler.cs @@ -121,6 +121,15 @@ private async Task SendCoreAsync( context).ConfigureAwait(false); winner = outcome.IsSuccess ? outcome.GetResultOrDefault() : null; + + // Point the winning response back at the original caller request. A retry may have + // sent the last attempt via a clone, leaving RequestMessage referencing a disposed + // message object after SendAttemptAsync's finally block ran. + if (winner is not null && !ReferenceEquals(winner.RequestMessage, request)) + { + winner.RequestMessage = request; + } + return outcome.ThrowIfFailed(); } finally From 2c4f912a280b8ba05b85f32487a28b5f08582122 Mon Sep 17 00:00:00 2001 From: Ali-Tegna <31090457+FreakyAli@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:21:51 +0530 Subject: [PATCH 5/5] fix: coderabbit comments addressed --- src/Resilion.Chaos/ChaosBehaviorStrategy.cs | 4 ++-- src/Resilion.Chaos/ChaosLatencyStrategy.cs | 4 ++-- .../ResilionHttpClientBuilderExtensions.cs | 23 +++++++++++------- src/Resilion/Hedging/HedgingStrategy.cs | 8 +++---- .../ReviewRegressionTests.cs | 24 +++++++------------ 5 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/Resilion.Chaos/ChaosBehaviorStrategy.cs b/src/Resilion.Chaos/ChaosBehaviorStrategy.cs index 8a15dff..36f39fc 100644 --- a/src/Resilion.Chaos/ChaosBehaviorStrategy.cs +++ b/src/Resilion.Chaos/ChaosBehaviorStrategy.cs @@ -21,11 +21,11 @@ protected internal override async ValueTask> ExecuteAsync> ExecuteAsync resolveReplay) { - // Resolved once, not per handler. Working out the replay mode means running the caller's - // configure delegate again, and IHttpClientFactory rebuilds the handler chain roughly - // every two minutes — so without this the delegate runs for the lifetime of the app, and - // any side effect in it runs with it. Lazy's default mode is publication-safe, so - // concurrent first resolutions agree on one value. - var replay = new Lazy( - () => resolveReplay(AccessorServices(accessor, pipelineKey))); + // Resolved once per root provider, not per handler rotation. Working out the replay mode + // means running the caller's configure delegate again, and IHttpClientFactory rebuilds the + // handler chain roughly every two minutes — so without caching the delegate runs for the + // lifetime of the app. ConditionalWeakTable keys by root provider identity, so two + // ServiceProviders built from the same ServiceCollection each get their own Lazy and + // their own resolved replay mode. Lazy's default mode is publication-safe, so concurrent + // first resolutions on the same provider agree on one value. + var replayCache = new ConditionalWeakTable>(); builder.AddHttpMessageHandler(sp => { // Assigned immediately before GetPipeline, which is the only call that can trigger the - // lazy build that reads it. See RootProviderAccessor for why this indirection exists. - accessor.Services = sp.GetRequiredService().Services; + // pipeline's lazy build that reads it. See RootProviderAccessor for why this indirection exists. + var root = sp.GetRequiredService().Services; + accessor.Services = root; + + var replay = replayCache.GetValue(root, r => new Lazy(() => resolveReplay(r))); var pipeline = sp.GetRequiredService>() .GetPipeline(pipelineKey); diff --git a/src/Resilion/Hedging/HedgingStrategy.cs b/src/Resilion/Hedging/HedgingStrategy.cs index f15f38e..ecb65f4 100644 --- a/src/Resilion/Hedging/HedgingStrategy.cs +++ b/src/Resilion/Hedging/HedgingStrategy.cs @@ -53,8 +53,8 @@ protected internal override async ValueTask> ExecuteAsync( var completed = await attempts[^1].ConfigureAwait(false); if (!_options.ShouldHandleOutcome(completed)) { - // Success — no need for more attempts. - StrategyActivity.SetOutcome(activity, "success"); + // No need for more attempts. + StrategyActivity.SetOutcome(activity, completed.IsSuccess ? "success" : "failure"); return completed; } } @@ -75,7 +75,7 @@ protected internal override async ValueTask> ExecuteAsync( var completed = await completedTask.ConfigureAwait(false); if (!_options.ShouldHandleOutcome(completed)) { - StrategyActivity.SetOutcome(activity, "success"); + StrategyActivity.SetOutcome(activity, completed.IsSuccess ? "success" : "failure"); return completed; } @@ -196,7 +196,7 @@ protected internal override Outcome Execute( if (!_options.ShouldHandleOutcome(lastOutcome)) { - StrategyActivity.SetOutcome(activity, "success"); + StrategyActivity.SetOutcome(activity, lastOutcome.IsSuccess ? "success" : "failure"); return lastOutcome; } diff --git a/tests/Resilion.Http.Tests/ReviewRegressionTests.cs b/tests/Resilion.Http.Tests/ReviewRegressionTests.cs index d1ff7ae..9200b93 100644 --- a/tests/Resilion.Http.Tests/ReviewRegressionTests.cs +++ b/tests/Resilion.Http.Tests/ReviewRegressionTests.cs @@ -20,7 +20,9 @@ public async Task CloneRequest_OnRetryCallback_SeesALiveRequestNotTheDisposedClo var stub = StubHttpMessageHandler.WithStatuses( HttpStatusCode.InternalServerError, HttpStatusCode.OK); - var observed = new List(); + // Declared before the lambda so the closure can capture it; assigned before SendAsync. + HttpRequestMessage? originalMessage = null; + HttpRequestMessage? observedRequest = null; var (provider, client) = TestClientFactory.Build(stub, b => b.AddStandardResilienceHandler(o => @@ -32,18 +34,8 @@ public async Task CloneRequest_OnRetryCallback_SeesALiveRequestNotTheDisposedClo Delay = RetryDelay.None, OnRetry = args => { - try - { - var request = args.Context.GetRequestMessage(); - // Touching the content is what surfaces a disposed message. - _ = request?.Content?.Headers.ContentLength; - observed.Add(null); - } - catch (Exception ex) - { - observed.Add(ex); - } - + // Capture for identity check after the call completes. + observedRequest = args.Context.GetRequestMessage(); return default; }, }; @@ -55,11 +47,13 @@ public async Task CloneRequest_OnRetryCallback_SeesALiveRequestNotTheDisposedClo { Content = JsonContent.Create(new { value = 1 }), }; + originalMessage = message; (await client.SendAsync(message)).Dispose(); - var failure = Assert.Single(observed); - Assert.Null(failure); + // The context must expose the caller's live request, not the disposed per-attempt clone. + Assert.True(ReferenceEquals(observedRequest, originalMessage), + "OnRetry received a different HttpRequestMessage instance than the caller's original."); } [Fact]