diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 004596d..44f9629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,10 @@ on: branches: [master] env: - DOTNET_VERSION: '9.0.x' + # Guarantees global.json's 8.0.404 floor is present. This is NOT a pin: global.json requests + # rollForward: latestMajor, so the SDK actually used is the newest installed on the runner. + # The previous value (9.0.x) implied a pin that never took effect. + DOTNET_VERSION: '8.0.x' permissions: contents: read @@ -30,4 +33,4 @@ jobs: run: dotnet restore - name: Build - run: dotnet build -c Release --no-restore + run: dotnet build -c Release --no-restore -p:ContinuousIntegrationBuild=true diff --git a/.github/workflows/release-nuget.yml b/.github/workflows/release-nuget.yml index dc2d725..e90504d 100644 --- a/.github/workflows/release-nuget.yml +++ b/.github/workflows/release-nuget.yml @@ -43,7 +43,10 @@ jobs: - name: Build env: VERSION: ${{ steps.get_version.outputs.version_without_v }} - run: dotnet build -c Release -p:Version="${VERSION}" + # ContinuousIntegrationBuild must be set at COMPILE time: it is what normalizes the + # source paths embedded in the PDB for SourceLink, and it also promotes RS0016/RS0017 + # (public API tracking) to errors. Setting it only on pack would be a no-op for both. + run: dotnet build -c Release -p:Version="${VERSION}" -p:ContinuousIntegrationBuild=true - name: Test run: dotnet test -c Release --no-build @@ -52,9 +55,29 @@ jobs: env: VERSION: ${{ steps.get_version.outputs.version_without_v }} run: | - dotnet pack src/Resilion/Resilion.csproj -c Release --no-build -p:Version="${VERSION}" - dotnet pack src/Resilion.Extensions/Resilion.Extensions.csproj -c Release --no-build -p:Version="${VERSION}" - dotnet pack src/Resilion.RateLimiting/Resilion.RateLimiting.csproj -c Release --no-build -p:Version="${VERSION}" + # 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) + for p in "${PROJECTS[@]}"; do + dotnet pack "src/$p/$p.csproj" -c Release --no-build \ + -p:Version="${VERSION}" -p:ContinuousIntegrationBuild=true + done + echo "EXPECTED_PACKAGE_COUNT=${#PROJECTS[@]}" >> "$GITHUB_ENV" + + - name: Verify symbol packages were produced + env: + VERSION: ${{ steps.get_version.outputs.version_without_v }} + run: | + # `dotnet pack --no-build` produces the .snupkg only if the .pdb is already in the + # output from the Build step. That holds here, but it is the flakiest part of symbol + # publishing, so assert it rather than discovering it missing after a release. + SNUPKGS=$(find . -name "*.snupkg" -path "*${VERSION}*" | wc -l | tr -d ' ') + echo "Found $SNUPKGS symbol packages (expected $EXPECTED_PACKAGE_COUNT)" + if [ "$SNUPKGS" -ne "$EXPECTED_PACKAGE_COUNT" ]; then + echo "Symbol packages missing. Aborting rather than publishing without symbols." + exit 1 + fi - name: NuGet login (OIDC - trusted publishing) id: login @@ -65,10 +88,14 @@ jobs: - name: Push NuGet packages run: | VERSION="${{ steps.get_version.outputs.version_without_v }}" + # The ! -name "*.symbols.nupkg" filter is vestigial with SymbolPackageFormat=snupkg + # (find -name "*.nupkg" does not match a .snupkg), but is kept: if anyone drops that + # property the legacy name returns, and this filter is what keeps the count honest. NUPKGS=$(find . -name "*.nupkg" -path "*${VERSION}*" ! -name "*.symbols.nupkg") - COUNT=$(echo "$NUPKGS" | wc -l) - if [ "$COUNT" -ne 3 ]; then - echo "Expected exactly 3 matching .nupkg files, found $COUNT. Aborting." + # Note: `echo "" | wc -l` is 1, so count with find directly rather than echoing. + COUNT=$(find . -name "*.nupkg" -path "*${VERSION}*" ! -name "*.symbols.nupkg" | wc -l | tr -d ' ') + if [ "$COUNT" -ne "$EXPECTED_PACKAGE_COUNT" ]; then + echo "Expected exactly $EXPECTED_PACKAGE_COUNT matching .nupkg files, found $COUNT. Aborting." exit 1 fi echo "$NUPKGS" | while read -r nupkg; do diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c2793f..c6d5689 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,10 +24,30 @@ jobs: strategy: fail-fast: false matrix: - # The library targets net8.0 only; running the same net8.0 test binaries under both - # the 8.0.x and 9.0.x runtimes catches framework-specific regressions (e.g. runtime - # behavior differences the library depends on) that a single-SDK job would miss. - dotnet-version: ["8.0.x", "9.0.x"] + # The library targets net8.0 only, so the useful axis is which RUNTIME the net8.0 test + # binaries execute on. Varying setup-dotnet's version did not achieve that, for two + # reasons: global.json requests 8.0.404 with rollForward: latestMajor, so the SDK + # actually selected is the newest installed on the runner regardless of this input; and + # Resilion.Tests.runtimeconfig.json pins framework 8.0.0 with the default Minor + # roll-forward policy, so both jobs resolved the same 8.0.x runtime. The matrix ran the + # same configuration twice. + # + # The knob is the RollForward MSBuild property, which writes "rollForward" into the test + # app's runtimeconfig.json and so scopes to that app alone: + # LatestPatch -> stay on the 8.0 runtime, newest installed patch + # LatestMajor -> run on the newest runtime installed below + # + # Two values that look right and are not: + # Disable forbids roll-forward entirely, so it demands the exact 8.0.0 patch and fails + # on any real machine or image (which ship 8.0.x for x > 0). + # Setting the DOTNET_ROLL_FORWARD environment variable instead applies the policy to + # every .NET process in the step, including the dotnet CLI and the test host, + # so the step dies before a test runs. + include: + - label: net8.0-runtime + roll-forward: LatestPatch + - label: newest-runtime + roll-forward: LatestMajor steps: - uses: actions/checkout@v4 with: @@ -37,13 +57,31 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ matrix.dotnet-version }} + # Both runtimes are installed in both legs, so the matrix does not depend on whatever + # the runner image happens to ship: LatestPatch resolves 8.0.x, LatestMajor resolves + # the highest present. This also satisfies global.json's 8.0.404 floor — it does not + # pin the SDK, since rollForward: latestMajor still selects the newest installed. + dotnet-version: | + 8.0.x + 9.0.x - name: Restore dependencies run: dotnet restore - name: Build Solution - run: dotnet build --configuration Release --no-restore + run: dotnet build --configuration Release --no-restore -p:RollForward=${{ matrix.roll-forward }} + + - name: Verify the roll-forward policy reached the test app + # Without this the matrix is unfalsifiable: if the property ever stops landing, both legs + # silently collapse onto one runtime again — exactly the state this replaced. + run: | + set -eu + for f in tests/*/bin/Release/net8.0/*.runtimeconfig.json; do + echo "== $f" + grep -q '"rollForward": *"${{ matrix.roll-forward }}"' "$f" \ + || { echo "rollForward=${{ matrix.roll-forward }} missing from $f"; cat "$f"; exit 1; } + done + dotnet --list-runtimes | grep Microsoft.NETCore.App - name: Run Unit Tests run: dotnet test --configuration Release --no-build -- --report-trx --results-directory ${{ github.workspace }}/TestResults @@ -52,7 +90,7 @@ jobs: uses: actions/upload-artifact@v4 if: success() || failure() with: - name: test-results-${{ matrix.dotnet-version }}-${{ github.run_id }} + name: test-results-${{ matrix.label }}-${{ github.run_id }} path: TestResults/*.trx retention-days: 7 @@ -60,7 +98,7 @@ jobs: uses: dorny/test-reporter@v1 if: success() || failure() with: - name: Unit Test Results (${{ matrix.dotnet-version }}) + name: Unit Test Results (${{ matrix.label }}) path: TestResults/*.trx reporter: dotnet-trx fail-on-error: false diff --git a/CHANGELOG.md b/CHANGELOG.md index e11505a..29a80e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,219 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`Resilion.Http` — `HttpClient` and `IHttpClientFactory` integration.** The most common use for + a resilience library, and the one gap `comparison-with-polly.md` told readers to use Polly for. + `AddStandardResilienceHandler()` on `IHttpClientBuilder` gives rate limiting, a total timeout, + retry, a circuit breaker and a per-attempt timeout, retrying 5xx, 408, 429, + `HttpRequestException` and `TimeoutRejectedException`. `AddStandardHedgingHandler()` swaps retry + for hedging; `AddResilienceHandler(key, …)` takes a pipeline you build. Also + `HttpResiliencePredicates`, `HttpRequestReplay`, and `ResilienceContext.GetRequestMessage()`. + See [docs/http.md](docs/http.md) + - **The pipeline is built once and cached in the registry, never in the handler factory.** That + factory re-runs on every `IHttpClientFactory` handler rotation — two minutes by default — so a + pipeline built there would silently reset the circuit breaker's window on each one, leaving a + breaker that can essentially never trip, with no symptom a user could attribute to this + library. Pinned by a test that fails if the pipeline stops being cached + - **Responses from losing attempts are disposed.** The pipeline discards non-final outcomes + without disposing them, and an undisposed response holds its connection, so without this a + retry-heavy client exhausts its pool. Disposal is eager where attempts cannot overlap, and + covers hedged stragglers that answer after the handler has already returned + - **`HttpRequestReplay`** chooses whether each attempt sends your request or an independent + clone. `ReuseRequest` (default) matches Polly and costs nothing; `CloneRequest` buffers the + body once and is required for non-replayable content or header-mutating inner handlers. The + hedging handler always clones, since concurrent attempts cannot share one request + - **Rate limiting is off by default**, unlike `Microsoft.Extensions.Http.Resilience` 8.x (9.x + also removed it). A `RateLimiter`'s lifetime belongs to its creator, so enabling one by default + would mean this package silently owning a limiter and throttling any application above its + permit count + - **`HttpClient.Send` throws `NotSupportedException`.** `DelegatingHandler.Send` forwards + straight to the inner handler, so without the override a synchronous caller would bypass the + entire pipeline and believe it was protected + - Known limitations, all documented: no `Retry-After` honouring, no synchronous send, no + per-endpoint hedging routing. Tracked as #63 and #66 +- Benchmarks for the HTTP handler against `Microsoft.Extensions.Http.Resilience` 8.10.0 through a + real `IHttpClientFactory` graph, at full iteration count rather than `ShortRun` because three + iterations could not separate the two libraries. Resilion is 2.3% faster on the happy path and + 10.6% faster with a retry — both real, both irrelevant beside real I/O — while allocating 2.1× + the total, or 6.3× the marginal cost over a bare client. Both arms were equalised deliberately — five strategies + each, retry present in both (Polly validates `MaxRetryAttempts >= 1`, Resilion permits 0, and + disabling it would have compared four strategies against five), and a breaker present but with + its threshold raised so a benchmark's sustained failure rate cannot trip it + +- `PredicateBuilder` and `PredicateBuilder` — compose a strategy's `ShouldHandle` from + `Handle()`, `HandleInner()` and `HandleResult(...)` clauses instead of + writing the delegate by hand, with an implicit conversion so a builder can be assigned directly. + Clauses combine with OR. Two deliberate behaviours: `Build()` **throws on an empty builder**, so + it can never become a second route to the default predicate (reachable only by leaving + `ShouldHandle` null); and it does **not** inherit the default's cancellation carve-out, so + `.Handle()` matches `OperationCanceledException`. Unlike `PipelineBuilder` it is not + single-use — it produces a value rather than configuring one thing +- `HedgingStrategyOptions.HedgingDelayGenerator` and `HedgingDelayGeneratorArgs` — compute + the delay before each hedged attempt. Because the delay *is* the mode selector, a generator + selects the mode **per attempt**: `InfiniteTimeSpan` makes that attempt wait for its predecessor, + `Zero` launches it immediately. `AttemptNumber` is the attempt being launched, so `1` for the + first hedge, matching the existing hedging args records. Unsupported on the synchronous path, + which throws, as it already does for parallel and latency modes +- `CircuitBreakerStateProvider`, plus `StateProvider` on both circuit breaker options records — + the first public way to read a circuit's state. Previously `CircuitBreakerManualControl` could + isolate and reset a circuit but not report it, forcing callers into indirect assertions like + "the next call threw `CircuitBrokenException`, therefore it must be open". Bind one instance per + breaker; a second binding throws. **Do not branch on it** — besides being a point-in-time read, + gating execution on it skips the half-open trial call and can leave a circuit open after the + dependency recovers. Documented on the property and in `docs/circuit-breaker.md` +- `ResiliencePipelineRegistry.InvalidatePipeline(key)` and `InvalidatePipeline(key)`, + with an optional `OnPipelineReplaced` callback and `PipelineReplacedArgs` — drop a cached + pipeline so the next resolve rebuilds it. The registry never disposes a replaced pipeline, + because it cannot know when in-flight executions on it have finished; the callback is how the + caller learns there is something to decide about +- `AddResiliencePipeline` / `` overloads (with and without a named + options instance) — bind a pipeline to `IOptionsMonitor` and rebuild it automatically + when those options change. This is what finally uses the `Microsoft.Extensions.Options` + reference that had been declared but unused since the package was created. **Resolve such a + pipeline per call** through `IPipelineProvider`; a consumer that caches it in a field keeps + executing the old one +- `ConfigureResiliencePipelineRegistry` — configure the registry itself. Runs before any pipeline + factory is registered, so registry-level settings are in place before a pipeline can be built +- `ResiliencePipelineRegistry.TryGetPipeline` — fills the asymmetry with the + existing non-generic `TryGetPipeline`. Deliberately not added to `IPipelineProvider`, since + adding a member to a public interface breaks external implementers; that waits for a major version +- Keyed DI resolution of named pipelines: `[FromKeyedServices("name")] Pipeline` now works, with no + new Resilion API — the consumer-facing surface is the BCL attribute. Registered for every + `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.Extensions.Configuration` — five bindable DTOs (`RetryStrategyConfiguration`, + `RetryDelayConfiguration`, `TimeoutStrategyConfiguration`, `CircuitBreakerStrategyConfiguration`, + `HedgingStrategyConfiguration`) that project into the real options records with `ToOptions()`. + The options records are `sealed record` types with `init`-only members and `RetryDelay` is a + closed union with a private constructor, so neither is bindable — and making the union bindable + would reintroduce exactly the contradictory-delay-properties ambiguity it exists to prevent. + See [docs/configuration.md](docs/configuration.md) + - **Every DTO default mirrors its options record exactly**, so binding nothing is identical to + passing no options. Drift would be a silent behaviour change, so each pair is asserted field by + field + - **The delay cap lives on the strategy, not the delay.** `RetryStrategyConfiguration.MaxDelay` + applies to every backoff kind including `Constant`, whose factory takes no maximum at all; + a second cap on `RetryDelayConfiguration` would be two knobs for one thing + - **`RetryDelayKind` has no `Custom` member** and the DTOs carry no delegate properties — + `ShouldHandle`, `OnRetry` and the generators cannot come from a file, and a property that + silently does nothing when bound is worse than no property. Layer them on with a record `with` + expression, which needs no extra API + - Whole pipelines from JSON remain deliberately out of scope: it would make `OrderingValidator`'s + guarantees a property of a configuration file while still requiring C# for every predicate +- `docs/dynamic-reload.md` and `docs/configuration.md`. Reload and keyed DI shipped above with + their contracts recorded only in this changelog, which is not documentation — a reader of `docs/` + had no way to learn that a reloadable pipeline must be resolved per call, or that keyed injection + never observes a reload. Both rules are now written down where they are looked for + +### Changed +- `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 + that did not exist. It returns when telemetry enrichment ships an `ILogger` bridge (#45) +- The test projects no longer reference `coverlet.collector` or `xunit.runner.visualstudio`. Both + are VSTest-era packages and this repo runs Microsoft.Testing.Platform, under which they do + nothing; verified by removing them and confirming all 430 tests still discover, run and emit TRX +- `Microsoft.Extensions.TimeProvider.Testing` moved to `tests/Directory.Build.props` at a single + version. It had drifted to 8.0.0 in one test project and 9.0.0 in the others, so the fake clock + under test was not the same one everywhere +- README and `docs/installation.md` no longer describe `Resilion.Extensions` as providing + "structured logging", and the dependency graph now lists the real per-package dependencies and + includes `Resilion.Http`. There has never been an `ILogger` anywhere in the library + +- `Pipeline.Name` and `Pipeline.Name` are public. Useful for logging on its own, and it + removes the only `InternalsVisibleTo` a future testing package would have needed +- `Pipeline.Dispose`/`DisposeAsync` and the typed equivalents are **idempotent**; the second and + later calls are no-ops. Required by keyed DI, where the container disposes the keyed singleton + *and* the registry disposes every pipeline it created, so the same instance is reached twice. + Harmless while no strategy holds a resource, and a real double-dispose bug the day one does +- **`README.md`'s callback example did not compile.** `OnRetry = ctx => { … }` fails with CS1660, + and a method group fails with CS0428: `ResilienceEventHandler` converts implicitly from + `Action` but is not itself a delegate type, so a lambda has no target type to bind to. The + example now compiles and states the constraint, and `comparison-with-polly.md` keeps the + performance claim (a sync handler runs inline, with no `ValueTask` allocation) while conceding + brevity to Polly's plain-delegate properties. Tracked for a real fix as #62 +- `docs/testing.md` asserts circuit state directly with `CircuitBreakerStateProvider` instead of + teaching the indirect "it threw, so it must be open" idiom, with a pointer to why that idiom + must not be lifted into production code + +### Fixed + +- **Timeout cancellation classification is now race-free.** `WasCancelledByTimeout` read + `linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested` as two separate reads, + so a user cancellation landing between them was reported as `TimeoutRejectedException` instead of + `OperationCanceledException`. The strategy now records *which* cause cancelled its linked token + first via a compare-and-swap at the moment cancellation happens, so classification is a single + read. Callers branch on that distinction to decide whether to retry, so the old behaviour could + turn a deliberate shutdown into a retry storm. Costs 32 B per timeout plus a token registration + when the incoming token is cancellable — see [docs/tradeoffs.md](docs/tradeoffs.md) +- **A typed strategy reached by an execution of a different result type now throws** instead of + silently skipping itself. A skipped strategy is a retry that never retries or a circuit breaker + that never breaks, on an execution that looks successful. The previous `Debug.WriteLine` warning + was `[Conditional("DEBUG")]`, so it was compiled out of the shipped Release assembly and no + consumer ever saw it. No public API can currently produce a mismatch; the guard exists so a + future composition surface cannot reintroduce the silent skip +- **`resilion.hedging.attempts` is now incremented on the synchronous path.** It previously reported + zero for sequential sync hedging even though the attempts ran and `OnHedging` fired, so the + counter and the event handler disagreed +- **Strategies nested inside a hedging strategy now carry the enclosing pipeline's name.** Hedging + copied `OperationKey`, `ContinueOnCapturedContext` and `Properties` onto its per-attempt contexts + but not `PipelineName`, so every nested circuit breaker and attempt timeout emitted + `pipeline.name = null` +- **Typed strategies now emit `ActivitySource` spans.** `RetryStrategy`, + `CircuitBreakerTypedStrategy` and hedging's synchronous path emitted none, so any pipeline + built with `Pipeline.Create` produced no retry or circuit-breaker spans at all while the + docs promised one per strategy execution + +### Added + +- `SECURITY.md` — supported versions, private vulnerability reporting, response targets and scope +- SourceLink, symbol packages (`.snupkg`) and deterministic release builds, so consumers can step + into Resilion's source while debugging. Verified end to end: repository metadata with the commit + SHA in the nuspec, a source map in the PDB, and the PDB present in the symbol package +- `docs/verification.md` — the command behind every factual claim the docs make about current + behaviour, so no write-up has to be trusted from memory +- CONTRIBUTING.md § "Future-plans hygiene" — an item leaves `future-plans.md` in the same PR that + implements it, claims about current behaviour must be re-verified, and an issue lives in exactly + one of `future-plans.md` or `tradeoffs.md` + +### Changed + +- **Public API tracking now actually works.** The `PublicAPI.*.txt` files were hand-written prose — + `namespace X;` grouping lines, unqualified member names, `#` comments the analyzer treats as API + declarations — so RS0016 fired for essentially the whole surface and RS0017 for most declared + lines (742 and 508 warnings respectively). They are regenerated in the analyzer's canonical + format, and `ci.yml` builds with `-p:ContinuousIntegrationBuild=true`, which promotes RS0016 and + RS0017 to errors. The gate is verified to fail: a stray public member errors, and removing a + declared line errors. The previous claim that "CI builds will fail if public surface changes" was + not true as configured +- Span tag emission consolidated into an internal `StrategyActivity` helper, so the tag set is + written once and every strategy entry point — typed and non-generic, async and sync — emits an + identical shape +- `release-nuget.yml` packs from a project array with a derived package count, instead of three + hardcoded `dotnet pack` lines plus a hardcoded `-ne 3` assertion that would have broken every + release the moment a fourth package landed. Adds an explicit symbol-package check before pushing +- `src/Directory.Build.props` uses `VersionPrefix` (1.0.0) rather than `Version` (0.1.0): setting + `Version` explicitly suppresses `VersionSuffix` handling, so `-p:VersionSuffix=pre` was silently + ignored +- README's synchronous-execution claim is scoped to what the code does. Every built-in strategy has + a real synchronous implementation, but async fallback factories and async event handlers still + thread-hop under a `SynchronizationContext`, sync hedging is sequential-only, sync retry delays + are not `TimeProvider`-driven, and custom strategies must override `Execute`. That claim is why + some users choose this library, so it states the exceptions +- `docs/telemetry.md` documents the four span tags the code actually sets (`strategy.name`, + `pipeline.name`, `operation.key`, `outcome`) and the `outcome` vocabulary. It previously listed + `strategy`, `attempt` and `duration_ms`, none of which exist +- CONTRIBUTING.md documents the Microsoft.Testing.Platform test filter syntax. The documented + `dotnet test --filter "FullyQualifiedName~..."` is VSTest syntax that MTP **silently ignores** + while running the entire suite +- Benchmark figures in README and `benchmarks/results/` re-measured after the timeout fix + + +## [1.0.0-pre] - 2026-09-02 + +### Added + - Initial project structure and build configuration - Core abstractions: `Outcome`, `ResilienceContext`, `Pipeline`, `Strategy` - `IPipelineProvider` — a read-only view over `ResiliencePipelineRegistry` for consumers that only retrieve pipelines, never register them; registered in DI alongside the registry @@ -21,7 +234,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New benchmarks: circuit breaker under mixed Closed-state traffic, `ResilienceContextPool` Rent/Return vs allocation, and a 100k-execution GC pressure comparison against Polly — see [benchmarks/results](benchmarks/results/README.md) for numbers - `ThrowOnOrderingErrors` on `PipelineBuilderBase` (default `true`) — dangerous strategy misorderings (CircuitBreaker outside Retry, Fallback not outermost) now throw `InvalidOperationException` at `Build()` time instead of only warning via `Debug.WriteLine`; situational issues (3+ Timeouts, Hedging+Retry) remain advisory-only regardless - `IAsyncDisposable` on `Pipeline`, `Pipeline`, `Strategy`, `Strategy`, and the internal `PipelineComponent` chain — `await using` now works; the default `DisposeAsync()` falls back to the existing sync `Dispose()` so custom strategies need no changes -- CI `aot-verify` job — publishes the samples project with Native AOT on every PR/push and runs the resulting binary, so the `IsAotCompatible=true` claim stays continuously verified +- `test.yml` `aot-verify` job — publishes the samples project with Native AOT on every PR/push and runs the resulting binary, so the `IsAotCompatible=true` claim stays continuously verified ### Changed @@ -58,3 +271,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `BreakDurationGenerator` / `MaxDelay` rows to the circuit breaker and retry options reference tables - 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 +[1.0.0-pre]: https://github.com/FreakyAli/Resilion/releases/tag/v1.0.0-pre diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08c93ca..018d3a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,8 @@ Thank you for your interest in contributing to Resilion! ### Prerequisites -- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) or later +- .NET SDK **8.0.404 or later**. `global.json` pins that floor with `rollForward: latestMajor`, so + a newer installed major (9.0, 10.0) satisfies it and will be used automatically. ### Local Setup @@ -47,20 +48,30 @@ samples/ ### Running Tests +The test projects run on **Microsoft.Testing.Platform** (MTP) via xunit.v3, not classic VSTest. +That changes the command line: anything after `--` goes to the test platform. + ```bash # Run all tests dotnet test -# Run specific test project +# Run a specific test project dotnet test tests/Resilion.Tests -# Run with filter -dotnet test --filter "FullyQualifiedName~RetryTests" +# Filter by class or method — note the `--` separator +dotnet test tests/Resilion.Tests -- --filter-class '*RetryStrategyTests' +dotnet test tests/Resilion.Tests -- --filter-method '*Async_RetriesOnException*' ``` +> **Do not use `dotnet test --filter "FullyQualifiedName~..."`.** That is VSTest syntax. Under MTP it +> is **silently ignored and the entire suite runs** — the only signal is a +> `warning MTP0001: VSTest-specific properties are set but will be ignored` buried in the build +> output. Verified 2026-09-30: the VSTest form ran all 223 tests, the `--filter-class` form ran 18. + ### Running Benchmarks -Benchmarks are not part of the solution file. Run them directly: +Benchmarks are part of the solution (so CI keeps them compiling), but are not run by +`dotnet test`. Run them directly: ```bash cd benchmarks/Resilion.Benchmarks @@ -85,6 +96,59 @@ This project uses an `.editorconfig` file for consistent formatting: - Update documentation where applicable - Ensure CI passes +## Future-plans hygiene + +[`docs/future-plans.md`](docs/future-plans.md) is a **todo list**, not a status log. Three rules, +no exceptions. + +1. **An item leaves `future-plans.md` in the same PR that implements it.** + Delete the `### . …` entry, delete its Priority Matrix row, add a row to `## Retired items`, + and add the corresponding `CHANGELOG.md` entry under `[Unreleased]`. Never mark an entry + "✅ IMPLEMENTED", never set `Status: Complete` — those are `CHANGELOG.md`'s job. A PR that adds a + completion marker instead of removing the entry will be asked to do the removal. + +2. **If a PR only partially closes an item, file the remainder under a NEW number and retire the + old one.** Do not leave a half-true entry in place, and do not reuse or redefine a number. The + next free number is the highest in `## Retired items` or the matrix, plus one. + +3. **The entries are the source of truth; the Priority Matrix is an index.** + Every matrix row must have an entry and every entry a matrix row. This is machine-checkable: + + ```bash + diff <(sed '/^## Retired items/,$d' docs/future-plans.md \ + | grep -oE '^\| [0-9]+ \|' | tr -cd '0-9\n' | sort -n) \ + <(grep -oE '^### [0-9]+\.' docs/future-plans.md | tr -cd '0-9\n' | sort -n) + ``` + + No output means consistent. + +**Why these rules exist:** `future-plans.md` once documented three items *twice, with opposite +verdicts* — a "✅ IMPLEMENTED" block was prepended without removing the original open write-ups or +updating the matrix. One of those stale entries was load-bearing: it claimed public API tracking was +working and that "CI builds will fail if public surface changes". It wasn't, and they didn't. The +1.0 API freeze was completely unenforced for as long as that claim stood unchallenged. + +### Claims about current behaviour must be re-verified, not remembered + +Any sentence asserting what the code does today — "grep confirms zero tag usage", "confirmed via +grep", "targets net8.0 only" — must be backed by a command in +[`docs/verification.md`](docs/verification.md), re-run at the time of writing, with the date and +command recorded in the entry's `**Verified:**` field. **Paste the command and its output into the +PR description.** + +`future-plans.md` item #47 is the standard to match: it names the SDK version, the exact error code, +and says how many times the failure was reproduced. + +### Moving an item between docs + +[`docs/tradeoffs.md`](docs/tradeoffs.md) holds accepted imperfections; `future-plans.md` holds +planned fixes. **An issue must be in exactly one of them** — being in both means the repo claims +simultaneously that the fix isn't worth doing and that it's planned. When an item moves, leave a +struck-through `— MOVED` stub behind; see the "Per-call delegate allocation in pipeline chain" +example in `tradeoffs.md`. Once the moved item is actually resolved, the stub says so — see +"Timeout cancellation classification" in the same file — so the stub never implies pending work +that no longer exists. + ## Reporting Issues - Use the [bug report](https://github.com/FreakyAli/Resilion/issues/new?template=bug_report.md) template for bugs diff --git a/README.md b/README.md index a0276a7..ea95855 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,24 @@ var pipeline = Pipeline.Create(b => b ``` ### Sync and Async -Both `Execute` and `ExecuteAsync` with **true sync implementations** — not sync-over-async. No blocking calls, no artificial overhead. +Every built-in strategy has a **real synchronous implementation** on its own execution path — +`Thread.Sleep` and `WaitHandle`, not `.GetAwaiter().GetResult()` over the async path. This matters +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. +- **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)). +- A **custom** strategy must override `Execute`; the base implementation throws under a + `SynchronizationContext` rather than risking a deadlock on your behalf. ```csharp // Synchronous execution @@ -85,16 +102,30 @@ 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: + ```csharp new RetryStrategyOptions { - OnRetry = (context) => - { - logger.LogWarning($"Retry attempt {context.AttemptNumber}"); - } -} + OnRetry = new Action(context => + logger.LogWarning("Retry attempt {Attempt}", context.AttemptNumber)), +}; ``` +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. + ### Composable Pipelines Combine pre-built pipelines into larger ones: @@ -131,13 +162,19 @@ dotnet add package Resilion.Extensions dotnet add package Resilion.RateLimiting ``` +### HttpClient Integration +```bash +dotnet add package Resilion.Http +``` + ## Packages & Dependencies | Package | Purpose | Dependencies | |---------|---------|-------------| -| **Resilion** | Core library with all built-in strategies | None | -| **Resilion.Extensions** | DI registration, telemetry (Meter/ActivitySource), structured logging | Microsoft.Extensions.* | +| **Resilion** | Core library with all built-in strategies, metrics and spans | None | +| **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 | ## Strategies @@ -348,6 +385,37 @@ var result = await pipeline.ExecuteAsync( httpClient); ``` +Keyed resolution works too, with no Resilion-specific API: + +```csharp +public sealed class ApiClient([FromKeyedServices("http-api")] Pipeline pipeline) { } +``` + +Pipelines can also be bound to `IOptionsMonitor` and rebuilt when their configuration changes. +Two rules come with that, both load-bearing: **resolve a reloadable pipeline per call** rather than +caching it in a field, and **keyed injection never observes a reload**, because a keyed singleton +resolves once for the container's lifetime. See +[docs/dynamic-reload.md](docs/dynamic-reload.md), and +[docs/configuration.md](docs/configuration.md) for binding strategy options straight from +`appsettings.json`. + +## HttpClient Integration + +Wrapping `HttpClient` is the most common use for a resilience library, so it is one line: + +```csharp +services.AddHttpClient("api").AddStandardResilienceHandler(); +``` + +That adds rate limiting (off by default), a total timeout, retry, a circuit breaker and a +per-attempt timeout, retrying 5xx, 408, 429, `HttpRequestException` and `TimeoutRejectedException`. +`AddStandardHedgingHandler()` swaps retry for hedging, and `AddResilienceHandler(key, …)` takes a +pipeline you build yourself. + +Discarded responses are disposed for you, so a retry-heavy client does not exhaust its connection +pool. See [docs/http.md](docs/http.md) for request replay, hedging, what to catch, and the known +limitations. + ## Telemetry Resilion emits metrics on a `"Resilion"` `Meter` — retry attempts, circuit breaker state @@ -356,7 +424,8 @@ rejections — with zero overhead until something subscribes. See [docs/telemetry.md](docs/telemetry.md) for the full instrument list and how to subscribe with `MeterListener`, `dotnet-counters`, or OpenTelemetry. -Structured logging is also available through callbacks on strategy options. +There is no `ILogger` integration: Resilion emits metrics and spans, and anything you want +logged goes through a callback on the strategy options, so the logger stays yours. ## Supported .NET Versions @@ -373,11 +442,13 @@ Resilion is built for modern .NET with full support for: ```text src/ Resilion/ Core library (zero dependencies) - Resilion.Extensions/ DI, telemetry, structured logging + Resilion.Extensions/ DI, pipeline registry, reload, configuration binding Resilion.RateLimiting/ Rate limiting strategy + Resilion.Http/ HttpClient integration (DelegatingHandler) tests/ Resilion.Tests/ Core strategy and pipeline tests Resilion.Extensions.Tests/ DI and telemetry tests + Resilion.Http.Tests/ HttpClient handler tests benchmarks/ Resilion.Benchmarks/ Performance benchmarks (BenchmarkDotNet) samples/ @@ -401,10 +472,16 @@ Measured on an Apple M4 Pro against Polly.Core 8.5.2, same pipeline shapes both | Scenario | Resilion | Polly.Core | Notes | |----------|---------:|-----------:|-------| -| Empty pipeline | 69 ns / 96 B | 59 ns / 0 B | Resilion allocates per-strategy closures; Polly's happy path is allocation-free | -| Single retry (happy path) | 114 ns / 192 B | 165 ns / 0 B | Resilion faster in wall-clock time despite allocating | -| Composite (Timeout+Retry+CB+Timeout) | 393 ns / 976 B | 734 ns / 0 B | The realistic multi-strategy shape most apps run | -| Same pipeline, **sync** execution | 61 ns / 192 B | — | True sync — no `Task` machinery | +| Empty pipeline | 70 ns / 96 B | 60 ns / 0 B | Resilion allocates per-strategy closures; Polly's happy path is allocation-free | +| Single retry (happy path) | 117 ns / 192 B | 169 ns / 0 B | Resilion faster in wall-clock time despite allocating | +| Composite (Timeout+Retry+CB+Timeout) | 488 ns / 1120 B | 740 ns / 0 B | The realistic multi-strategy shape most apps run | +| Same pipeline, **sync** execution | 53 ns / 192 B | — | Real sync path — no `Task` machinery | + +The composite figure includes the cost of race-free timeout cancellation classification: 32 B per +timeout strategy, plus a `CancellationToken` registration whenever the token reaching that timeout +can be cancelled. A timeout nested inside another timeout always sees a cancellable token, so the +canonical total-plus-per-attempt shape above pays 144 B more than a version that misclassifies user +cancellation as a timeout under a race. See [docs/tradeoffs.md](docs/tradeoffs.md). Resilion is consistently faster wall-clock, at the cost of small per-call allocations from the middleware chain's closures (tracked in [future-plans.md](docs/future-plans.md) item #4). If diff --git a/Resilion.sln b/Resilion.sln index d777650..7f538e7 100644 --- a/Resilion.sln +++ b/Resilion.sln @@ -11,12 +11,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Extensions", "src\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.RateLimiting", "src\Resilion.RateLimiting\Resilion.RateLimiting.csproj", "{49A634E0-7EA3-48D6-B15D-93E84738A282}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Http", "src\Resilion.Http\Resilion.Http.csproj", "{B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Tests", "tests\Resilion.Tests\Resilion.Tests.csproj", "{7C800530-ADA9-48CF-95B0-2E4ECF7A5041}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Extensions.Tests", "tests\Resilion.Extensions.Tests\Resilion.Extensions.Tests.csproj", "{A0EC64D8-078D-43A7-AA73-74AF5319CABB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Http.Tests", "tests\Resilion.Http.Tests\Resilion.Http.Tests.csproj", "{C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resilion.Samples", "samples\Resilion.Samples\Resilion.Samples.csproj", "{65299870-8203-4726-B350-B4C85586D162}" @@ -119,6 +123,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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -131,5 +159,7 @@ Global {A0EC64D8-078D-43A7-AA73-74AF5319CABB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {65299870-8203-4726-B350-B4C85586D162} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {7A9E5C6B-4D3F-4E2B-1F5D-8C7B6A9E4D3C} = {9C7D7E4D-8F4E-4B5A-9E6D-1F8C3B2A5D6E} + {B3D1F7A2-5C84-4E19-9A72-6D0F4C1E8B35} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C4E2A8B3-6D95-4F2A-8B83-7E1A5D2F9C46} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cd1d7fd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# Security Policy + +## 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. + +| Version | Supported | +|---------|-----------| +| 1.0.x | ✅ | +| < 1.0 | ❌ (pre-release; upgrade to 1.0.x) | + +## Reporting a Vulnerability + +**Please do not open a public issue for a security vulnerability.** A public report tells everyone +about the problem, including people who would exploit it, before a fix exists. + +Instead, report it privately through GitHub's security advisories: + +**https://github.com/FreakyAli/Resilion/security/advisories/new** + +That creates a private thread visible only to you and the maintainers. + +Please include, as far as you can: + +- The affected package and version (`Resilion`, `Resilion.Extensions`, `Resilion.RateLimiting`). +- What an attacker can achieve — the impact, not just the mechanism. +- A minimal reproduction: the pipeline configuration and the calling pattern that triggers it. +- Whether it is already public anywhere. + +## What to Expect + +| Stage | Target | +|-------|--------| +| Acknowledgement that we've seen the report | 3 days | +| Initial assessment, with a severity and a plan | 7 days | +| Fix released for a critical issue | 7 days from assessment | +| Fix released for a lower-severity issue | next scheduled release | + +We will keep you updated in the advisory thread, credit you in the release notes and the advisory +unless you'd rather stay anonymous, and publish the advisory once a fixed version is available. + +If you don't hear anything within 7 days, please bump the advisory thread — it means the +notification was missed, not that the report was dismissed. + +## Scope + +Resilion is a library: it runs inside your process, has no network listeners of its own, and the +core package has no external dependencies. In practice that makes the plausible issue classes: + +- A resilience strategy failing to enforce its own contract in a way an attacker can drive — for + example a rate limiter or circuit breaker that can be made not to trip. +- Unbounded resource growth reachable from untrusted input (memory, threads, timers, sockets). +- Sensitive data leaking into telemetry tags, spans, or exception messages. +- A denial of service achievable through configuration a caller controls. + +Reports that amount to "you can configure this library badly" are not vulnerabilities, but we do +want to hear about them as ordinary issues if the bad configuration is easy to reach by accident or +the documentation encourages it. diff --git a/benchmarks/Resilion.Benchmarks/HttpResilienceHandlerBenchmarks.cs b/benchmarks/Resilion.Benchmarks/HttpResilienceHandlerBenchmarks.cs new file mode 100644 index 0000000..f60991a --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/HttpResilienceHandlerBenchmarks.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Net.Http; +using System.Threading.RateLimiting; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Resilion.RateLimiting; + +namespace Resilion.Benchmarks; + +/// +/// Resilion.Http's standard handler against Microsoft.Extensions.Http.Resilience's, through +/// a real graph. +/// +/// +/// Measures handler overhead only: the primary handler answers from memory and never touches a +/// socket, so what is left is the pipeline plus the per-request bookkeeping each library adds. +/// +/// Fairness notes, because the defaults differ and a silent mismatch would make this meaningless: +/// +/// +/// +/// Polly 8.x's standard handler enables a 1000-permit concurrency limiter by default and +/// Resilion's does not (Polly 9.x removed it too). Both arms enable one here, so +/// the comparison is five strategies against five. +/// +/// Retry delay is zero on both arms, so the retry benchmark measures work, not sleeping. +/// +/// Every benchmark disposes the response it receives, so response handling is symmetric — and +/// Resilion's handler additionally disposes the discarded ones, which is work Polly's does too. +/// +/// +/// +[MemoryDiagnoser] +public class HttpResilienceHandlerBenchmarks +{ + private const string Endpoint = "https://benchmark.invalid/data"; + + private ServiceProvider _bareProvider = null!; + private ServiceProvider _resilionProvider = null!; + private ServiceProvider _pollyProvider = null!; + + private HttpClient _bare = null!; + private HttpClient _resilion = null!; + private HttpClient _polly = null!; + + private ServiceProvider _resilionRetryProvider = null!; + private ServiceProvider _pollyRetryProvider = null!; + private HttpClient _resilionRetry = null!; + private HttpClient _pollyRetry = null!; + + /// Answers from memory, allocating one response per call in every arm alike. + private sealed class EchoHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + /// Fails once per request, then succeeds, so exactly one retry happens. + private sealed class FailOnceHandler : HttpMessageHandler + { + private readonly System.Collections.Concurrent.ConcurrentDictionary _seen = new(); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Keyed on the request instance so each benchmark iteration fails exactly once. + // The entry is removed on the second call, otherwise the dictionary grows by one + // request per iteration for millions of iterations — benchmark-induced Gen1/Gen2 + // pressure that lands on both arms and swamps the signal being measured. + if (_seen.TryAdd(request, true)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + + _seen.TryRemove(request, out _); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } + + [GlobalSetup] + public void Setup() + { + _bareProvider = BuildBare(); + _bare = _bareProvider.GetRequiredService().CreateClient("bench"); + + _resilionProvider = BuildResilion(retry: false); + _resilion = _resilionProvider.GetRequiredService().CreateClient("bench"); + + _pollyProvider = BuildPolly(retry: false); + _polly = _pollyProvider.GetRequiredService().CreateClient("bench"); + + _resilionRetryProvider = BuildResilion(retry: true); + _resilionRetry = _resilionRetryProvider.GetRequiredService().CreateClient("bench"); + + _pollyRetryProvider = BuildPolly(retry: true); + _pollyRetry = _pollyRetryProvider.GetRequiredService().CreateClient("bench"); + } + + [GlobalCleanup] + public void Cleanup() + { + _bareProvider.Dispose(); + _resilionProvider.Dispose(); + _pollyProvider.Dispose(); + _resilionRetryProvider.Dispose(); + _pollyRetryProvider.Dispose(); + } + + private static ServiceProvider BuildBare() + { + var services = new ServiceCollection(); + services.AddHttpClient("bench") + .ConfigurePrimaryHttpMessageHandler(() => new EchoHandler()); + return services.BuildServiceProvider(); + } + + /// Selects the primary handler; the pipeline shape is identical either way. + private static ServiceProvider BuildResilion(bool retry) + { + var services = new ServiceCollection(); + services.AddSingleton(new ConcurrencyLimiter( + new ConcurrencyLimiterOptions { PermitLimit = 1000, QueueLimit = 0 })); + + services.AddHttpClient("bench") + .ConfigurePrimaryHttpMessageHandler(() => + retry ? new FailOnceHandler() : new EchoHandler()) + .AddStandardResilienceHandler((o, sp) => + { + // Matched to Polly's default, which enables one. + o.RateLimiter = new RateLimiterStrategyOptions + { + RateLimiter = sp.GetRequiredService(), + }; + // Always 1, never 0, on BOTH arms. Polly's options validate MaxRetryAttempts >= 1 + // and Resilion's allow 0, so disabling retry on the happy path would have given + // Resilion a four-strategy pipeline against Polly's five — a structural advantage + // masquerading as a faster one. Here the retry is present in both and simply does + // not fire when the response is a 200. + o.Retry = o.Retry with + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + UseJitter = false, + }; + + // The retry arm fails every request once, a sustained ~50% failure rate over + // thousands of iterations, which trips both libraries' default breakers (10% over + // 100 samples) and aborts the run. Raising the threshold to 1.0 keeps the breaker + // in the pipeline — so it is still measured — without it ever opening. + o.CircuitBreaker = o.CircuitBreaker with { FailureRatioThreshold = 1.0 }; + }); + + return services.BuildServiceProvider(); + } + + /// + private static ServiceProvider BuildPolly(bool retry) + { + var services = new ServiceCollection(); + services.AddHttpClient("bench") + .ConfigurePrimaryHttpMessageHandler(() => + retry ? new FailOnceHandler() : new EchoHandler()) + .AddStandardResilienceHandler(o => + { + o.Retry.MaxRetryAttempts = 1; + o.Retry.Delay = TimeSpan.Zero; + o.Retry.UseJitter = false; + o.Retry.BackoffType = Polly.DelayBackoffType.Constant; + + // Matched to the Resilion arm, and for the same reason. + o.CircuitBreaker.FailureRatio = 1.0; + }); + + return services.BuildServiceProvider(); + } + + private static async Task SendAsync(HttpClient client) + { + using var response = await client.GetAsync(Endpoint, HttpCompletionOption.ResponseHeadersRead); + return (int)response.StatusCode; + } + + // ── Happy path ─────────────────────────────────────────────── + + [Benchmark(Baseline = true)] + public Task Bare_HappyPath() => SendAsync(_bare); + + [Benchmark] + public Task Resilion_StandardHandler_HappyPath() => SendAsync(_resilion); + + [Benchmark] + public Task Polly_StandardHandler_HappyPath() => SendAsync(_polly); + + // ── One retry ──────────────────────────────────────────────── + + [Benchmark] + public Task Resilion_StandardHandler_OneRetry() => SendAsync(_resilionRetry); + + [Benchmark] + public Task Polly_StandardHandler_OneRetry() => SendAsync(_pollyRetry); +} diff --git a/benchmarks/Resilion.Benchmarks/Resilion.Benchmarks.csproj b/benchmarks/Resilion.Benchmarks/Resilion.Benchmarks.csproj index 80311e7..77fcc29 100644 --- a/benchmarks/Resilion.Benchmarks/Resilion.Benchmarks.csproj +++ b/benchmarks/Resilion.Benchmarks/Resilion.Benchmarks.csproj @@ -2,7 +2,16 @@ Exe - net8.0;net10.0 + + net8.0 + net8.0;net10.0 latest enable enable @@ -12,12 +21,17 @@ + + + diff --git a/benchmarks/results/README.md b/benchmarks/results/README.md index 01dbc4e..89ff1ba 100644 --- a/benchmarks/results/README.md +++ b/benchmarks/results/README.md @@ -20,13 +20,13 @@ Full per-class output (including method docs) is in the sibling `.md` files in t | Benchmark | Mean | Allocated | |-----------|-----:|----------:| | Direct call (no pipeline) | ~0 ns | 0 B | -| Resilion — empty pipeline | 69 ns | 96 B | -| Polly — empty pipeline | 59 ns | 0 B | -| Resilion — single retry | 114 ns | 192 B | -| Polly — single retry | 165 ns | 0 B | -| Resilion — composite (Timeout+Retry+CB+Timeout) | 393 ns | 976 B | -| Polly — composite (same shape) | 734 ns | 0 B | -| Resilion — single retry, **sync** | 61 ns | 192 B | +| Resilion — empty pipeline | 70 ns | 96 B | +| Polly — empty pipeline | 60 ns | 0 B | +| Resilion — single retry | 116 ns | 192 B | +| Polly — single retry | 167 ns | 0 B | +| Resilion — composite (Timeout+Retry+CB+Timeout) | 476 ns | 1120 B | +| Polly — composite (same shape) | 745 ns | 0 B | +| Resilion — single retry, **sync** | 53 ns | 192 B | Resilion's happy-path allocation is non-zero (Polly.Core's is zero here) but its wall-clock time is consistently lower across every shape tested, including the composite pipeline most @@ -34,22 +34,96 @@ applications actually run. The allocations come from per-strategy closures in th chain — see [future-plans.md](../../docs/future-plans.md) item #4 and [tradeoffs.md](../../docs/tradeoffs.md). +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. +- **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` + (3 iterations) as indicative only; allocation figures are deterministic and comparable. + +## HttpClient handler overhead + +`Resilion.Http`'s standard handler against `Microsoft.Extensions.Http.Resilience` 8.10.0, both +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. These are +±0.4%. + +| Benchmark | Mean | Allocated | +|-----------|-----:|----------:| +| Bare `HttpClient` (no resilience) | 287 ns ± 1 | 960 B | +| Resilion — standard handler, happy path | 1,102 ns ± 4 | 2,624 B | +| Polly — standard handler, happy path | 1,127 ns ± 8 | 1,224 B | +| Resilion — standard handler, one retry | 1,552 ns ± 7 | 3,288 B | +| Polly — standard handler, one retry | 1,735 ns ± 26 | 1,576 B | + +**Time:** Resilion is 2.3% faster on the happy path and 10.6% faster when a retry fires. Both +differences are real — the confidence intervals do not overlap — and both are irrelevant in +practice: the whole handler costs ~815 ns on a call that in production does 1–100 ms of I/O. + +**Allocation:** the number worth looking at. Resilion allocates 2.1× Polly's total, but the +*marginal* cost of the resilience layer is the sharper figure: over the 960 B a bare client already +allocates, Resilion's handler adds **1,664 B against Polly's 264 B — 6.3×**. A faint Gen1 collection +appears on Resilion's retry arm and none on Polly's. + +That comes from the per-strategy closures of [future-plans.md](../../docs/future-plans.md) item #4 +— five strategies, one closure each, per call — plus one attempt-tracker per request, which is what +disposes discarded responses. + +**What this does not measure, and it matters:** there is no I/O and no concurrency here. Item #4's +decision rule calls for a benchmark with ~1 ms of simulated per-call latency, measuring Gen0 +collections per 10,000 operations, and that benchmark has not been written. Until it is, this table +says Resilion allocates substantially more per HTTP call and says nothing about whether that costs +a real service anything. + +Both arms were equalised deliberately, and the comparison is worthless without this: + +- **Five strategies against five.** Polly 8.x's standard handler enables a 1000-permit concurrency + limiter by default and Resilion's does not, so the Resilion arm enables one explicitly. +- **Retry present in both, in every benchmark.** Polly validates `MaxRetryAttempts >= 1` and + Resilion permits 0; disabling it on the happy-path arm would have given Resilion a four-strategy + pipeline against Polly's five — a structural advantage reported as a speed one. +- **Circuit breaker present but never opening.** The retry arm fails every request once, a + sustained ~50% failure rate that trips both libraries' 10%-over-100-samples defaults and aborts + the run, so the threshold is raised to 1.0 on both rather than removing the strategy. +- Zero retry delay on both; each benchmark disposes the response it receives. + ## Real-world scenario patterns | Benchmark | Mean | Allocated | |-----------|-----:|----------:| -| Resilion — HTTP client pattern (Timeout+Retry+CB) | 267 ns | 552 B | -| Polly — HTTP client pattern (same shape) | 391 ns | 0 B | -| Resilion — DB query pattern (Fallback+Timeout+Retry) | 277 ns | 552 B | -| Polly — DB query pattern (same shape) | 254 ns | 0 B | -| Resilion — DB query, fallback triggered | 48.0 ms | 2988 B | -| Resilion — Hedging, fast response | 10.7 μs | 2838 B | -| Resilion — HTTP client, **sync** | 146 ns | 552 B | -| Resilion — DB query, **sync** | 146 ns | 552 B | -| Resilion — DB query, **sync**, fallback triggered | 54.4 ms | 1849 B | +| Resilion — HTTP client pattern (Timeout+Retry+CB) | 324 ns | 584 B | +| Polly — HTTP client pattern (same shape) | 397 ns | 0 B | +| Resilion — DB query pattern (Fallback+Timeout+Retry) | 278 ns | 584 B | +| Polly — DB query pattern (same shape) | 256 ns | 0 B | +| Resilion — DB query, fallback triggered | 50.3 ms | 2990 B | +| Resilion — Hedging, fast response | 10.8 μs | 2909 B | +| Resilion — HTTP client, **sync** | 195 ns | 584 B | +| Resilion — DB query, **sync** | 176 ns | 584 B | +| Resilion — DB query, **sync**, fallback triggered | 51.5 ms | 1867 B | Sync execution is consistently *faster* than async for the same pipeline shape — the payoff of -true sync (`Thread.Sleep`/no `Task` machinery) rather than sync-over-async. +a real sync path (`Thread.Sleep`, no `Task` machinery) rather than sync-over-async. + +Both timeout-bearing shapes moved from 552 B to 584 B: exactly +32 B, one per Timeout strategy, +from race-free cancellation classification. These pipelines contain a single timeout and are +executed without a caller token, so they pay the recording object and not the token registration — +see the two-timeout composite in the section above for the shape that pays both, and +[tradeoffs.md](../../docs/tradeoffs.md) for why. + +Wall-clock figures here shifted more than allocation did, and should not be read as a like-for-like +regression: re-measuring *unmodified* code on this machine produced up to ~10% run-to-run variation +under `ShortRun` (3 iterations). Allocation is deterministic and comparable across runs; timing at +this configuration is indicative only. **Note on the "fallback triggered" rows:** these jumped from microseconds to tens of milliseconds partway through this project. Not a performance regression — the DB-query diff --git a/benchmarks/results/Resilion.Benchmarks.HttpResilienceHandlerBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.HttpResilienceHandlerBenchmarks-report-github.md new file mode 100644 index 0000000..7aeb44c --- /dev/null +++ b/benchmarks/results/Resilion.Benchmarks.HttpResilienceHandlerBenchmarks-report-github.md @@ -0,0 +1,17 @@ +``` + +BenchmarkDotNet v0.14.0, macOS 26.6.2 (25G83) [Darwin 25.6.0] +Apple M4 Pro, 1 CPU, 14 logical and 14 physical cores +.NET SDK 10.0.100 + [Host] : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | +|----------------------------------- |-----------:|---------:|---------:|------:|--------:|-------:|-------:|----------:|------------:| +| Bare_HappyPath | 287.4 ns | 1.37 ns | 1.28 ns | 1.00 | 0.01 | 0.1144 | - | 960 B | 1.00 | +| Resilion_StandardHandler_HappyPath | 1,101.7 ns | 4.05 ns | 3.59 ns | 3.83 | 0.02 | 0.3128 | - | 2624 B | 2.73 | +| Polly_StandardHandler_HappyPath | 1,127.1 ns | 7.53 ns | 6.68 ns | 3.92 | 0.03 | 0.1450 | - | 1224 B | 1.27 | +| Resilion_StandardHandler_OneRetry | 1,551.5 ns | 7.11 ns | 6.31 ns | 5.40 | 0.03 | 0.3929 | 0.0019 | 3288 B | 3.42 | +| Polly_StandardHandler_OneRetry | 1,734.8 ns | 26.01 ns | 24.33 ns | 6.04 | 0.09 | 0.1869 | - | 1576 B | 1.64 | diff --git a/benchmarks/results/Resilion.Benchmarks.PipelineOverheadBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.PipelineOverheadBenchmarks-report-github.md index 5fc2194..987ab87 100644 --- a/benchmarks/results/Resilion.Benchmarks.PipelineOverheadBenchmarks-report-github.md +++ b/benchmarks/results/Resilion.Benchmarks.PipelineOverheadBenchmarks-report-github.md @@ -10,13 +10,13 @@ Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3 ``` -| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | -|----------------------------- |------------:|-----------:|----------:|----------:|---------:|-------:|----------:|------------:| -| DirectCall | 0.0770 ns | 0.7340 ns | 0.0402 ns | 1.26 | 0.93 | - | - | NA | -| Resilion_Empty | 69.4926 ns | 6.6621 ns | 0.3652 ns | 1,137.25 | 609.03 | 0.0114 | 96 B | NA | -| Polly_Empty | 58.5517 ns | 3.0302 ns | 0.1661 ns | 958.20 | 513.13 | - | - | NA | -| Resilion_Retry_HappyPath | 113.8271 ns | 7.8695 ns | 0.4314 ns | 1,862.78 | 997.55 | 0.0229 | 192 B | NA | -| Polly_Retry_HappyPath | 165.4505 ns | 18.3020 ns | 1.0032 ns | 2,707.60 | 1,450.02 | - | - | NA | -| Resilion_Composite_HappyPath | 392.8716 ns | 43.2298 ns | 2.3696 ns | 6,429.34 | 3,443.15 | 0.1163 | 976 B | NA | -| Polly_Composite_HappyPath | 733.9476 ns | 20.2942 ns | 1.1124 ns | 12,011.05 | 6,432.00 | - | - | NA | -| Resilion_Retry_Sync | 61.1025 ns | 31.5703 ns | 1.7305 ns | 999.94 | 536.18 | 0.0229 | 192 B | NA | +| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|----------------------------- |------------:|------------:|-----------:|------------:|------:|--------:|-------:|----------:|------------:| +| DirectCall | 0.0064 ns | 0.2014 ns | 0.0110 ns | 0.0000 ns | ? | ? | - | - | ? | +| Resilion_Empty | 69.7421 ns | 3.3161 ns | 0.1818 ns | 69.6444 ns | ? | ? | 0.0114 | 96 B | ? | +| Polly_Empty | 59.9433 ns | 0.3970 ns | 0.0218 ns | 59.9489 ns | ? | ? | - | - | ? | +| Resilion_Retry_HappyPath | 115.7802 ns | 4.0554 ns | 0.2223 ns | 115.8675 ns | ? | ? | 0.0229 | 192 B | ? | +| Polly_Retry_HappyPath | 166.6076 ns | 5.7315 ns | 0.3142 ns | 166.4953 ns | ? | ? | - | - | ? | +| Resilion_Composite_HappyPath | 476.2153 ns | 292.6871 ns | 16.0432 ns | 485.1351 ns | ? | ? | 0.1335 | 1120 B | ? | +| Polly_Composite_HappyPath | 744.6206 ns | 270.1220 ns | 14.8063 ns | 737.3242 ns | ? | ? | - | - | ? | +| Resilion_Retry_Sync | 53.1058 ns | 7.1679 ns | 0.3929 ns | 52.9346 ns | ? | ? | 0.0229 | 192 B | ? | diff --git a/benchmarks/results/Resilion.Benchmarks.RealWorldScenarioBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.RealWorldScenarioBenchmarks-report-github.md index 9e662f1..30625fe 100644 --- a/benchmarks/results/Resilion.Benchmarks.RealWorldScenarioBenchmarks-report-github.md +++ b/benchmarks/results/Resilion.Benchmarks.RealWorldScenarioBenchmarks-report-github.md @@ -12,12 +12,12 @@ WarmupCount=3 ``` | Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | |----------------------------------- |----------------:|-----------------:|----------------:|-------:|-------:|-------:|----------:| -| Resilion_HttpClient_HappyPath | 266.6 ns | 24.49 ns | 1.34 ns | 0.0658 | - | - | 552 B | -| Polly_HttpClient_HappyPath | 390.5 ns | 20.09 ns | 1.10 ns | - | - | - | - | -| Resilion_DbQuery_HappyPath | 277.2 ns | 50.27 ns | 2.76 ns | 0.0658 | - | - | 552 B | -| Polly_DbQuery_HappyPath | 253.9 ns | 28.25 ns | 1.55 ns | - | - | - | - | -| Resilion_DbQuery_WithFallback | 48,003,929.3 ns | 22,548,513.09 ns | 1,235,960.09 ns | - | - | - | 2988 B | -| Resilion_Hedging_FastResponse | 10,672.8 ns | 22,537.33 ns | 1,235.35 ns | 0.3662 | 0.1221 | 0.0458 | 2838 B | -| Resilion_HttpClient_Sync_HappyPath | 146.0 ns | 1.23 ns | 0.07 ns | 0.0658 | - | - | 552 B | -| Resilion_DbQuery_Sync_HappyPath | 145.8 ns | 77.91 ns | 4.27 ns | 0.0658 | - | - | 552 B | -| Resilion_DbQuery_Sync_WithFallback | 54,443,839.5 ns | 54,422,093.11 ns | 2,983,058.57 ns | - | - | - | 1849 B | +| Resilion_HttpClient_HappyPath | 323.8 ns | 18.79 ns | 1.03 ns | 0.0696 | - | - | 584 B | +| Polly_HttpClient_HappyPath | 397.2 ns | 245.68 ns | 13.47 ns | - | - | - | - | +| Resilion_DbQuery_HappyPath | 278.4 ns | 42.13 ns | 2.31 ns | 0.0696 | - | - | 584 B | +| Polly_DbQuery_HappyPath | 256.1 ns | 218.12 ns | 11.96 ns | - | - | - | - | +| Resilion_DbQuery_WithFallback | 50,289,262.5 ns | 21,563,630.63 ns | 1,181,975.36 ns | - | - | - | 2990 B | +| Resilion_Hedging_FastResponse | 10,816.8 ns | 1,978.22 ns | 108.43 ns | 0.3510 | 0.0916 | 0.0153 | 2909 B | +| Resilion_HttpClient_Sync_HappyPath | 194.8 ns | 7.93 ns | 0.43 ns | 0.0696 | - | - | 584 B | +| Resilion_DbQuery_Sync_HappyPath | 176.2 ns | 20.24 ns | 1.11 ns | 0.0696 | - | - | 584 B | +| Resilion_DbQuery_Sync_WithFallback | 51,463,693.0 ns | 19,411,586.55 ns | 1,064,014.56 ns | - | - | - | 1867 B | diff --git a/docs/architecture.md b/docs/architecture.md index 85334ed..105b9b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,7 +119,7 @@ See [benchmarks/results](../benchmarks/results/README.md) for measured numbers a ### Per-call pipeline overhead -Each `StrategyComponent` in the chain creates a closure `ctx => _next.ExecuteAsync(callback, ctx)`. In a pipeline with N strategies this means N small closure allocations per call. This is inherent to the middleware pattern — documented in [future-plans.md](future-plans.md#per-call-delegate-allocation-in-pipeline-chain). +Each `StrategyComponent` in the chain creates a closure `ctx => _next.ExecuteAsync(callback, ctx)`. In a pipeline with N strategies this means N small closure allocations per call. This is inherent to the middleware pattern — documented in [future-plans.md](future-plans.md#4-per-call-delegate-allocation-in-pipeline-chain) as item #4. ### Zero-cost telemetry diff --git a/docs/cancellation.md b/docs/cancellation.md index 6a76d5b..866ee77 100644 --- a/docs/cancellation.md +++ b/docs/cancellation.md @@ -75,7 +75,13 @@ The Timeout strategy distinguishes the two by checking which token fired: | Yes | Yes (linked) | `OperationCanceledException` | | No | No | Success (completed in time) | -There is a narrow TOCTOU race where user cancellation between checks could be misclassified as timeout. This is nanoseconds wide and not observable in practice. Tracked as a fix in [future-plans.md](future-plans.md#46-timeout-cancellation-toctou-race-fix). +Cancellation cause is classified without a race. The Timeout strategy records which cause — +its timer, or the caller's token — cancelled the linked token *first*, via a compare-and-swap at +the moment cancellation happens, so classification is a single read. A user cancellation arriving +after the timeout has already fired cannot retroactively turn a `TimeoutRejectedException` into an +`OperationCanceledException`, or vice versa. See [tradeoffs.md](tradeoffs.md) for the allocation +this costs. + ## Cooperative cancellation diff --git a/docs/circuit-breaker.md b/docs/circuit-breaker.md index f6c69f6..d9daee5 100644 --- a/docs/circuit-breaker.md +++ b/docs/circuit-breaker.md @@ -38,6 +38,7 @@ catch (CircuitBrokenException ex) | `OnClosed` | `ResilienceEventHandler` | null | Fired on HalfOpen → Closed | | `OnHalfOpened` | `ResilienceEventHandler` | null | Fired on Open → HalfOpen | | `ManualControl` | `CircuitBreakerManualControl` | null | Manual isolate/reset | +| `StateProvider` | `CircuitBreakerStateProvider` | null | Observe the current state | ## State machine @@ -100,6 +101,64 @@ await control.ResetAsync(); Each `CircuitBreakerManualControl` instance can only be bound to one circuit breaker. Reusing across multiple breakers throws `InvalidOperationException`. +## Observing state + +`CircuitBreakerStateProvider` reports a circuit's current state. Bind one instance per breaker — +a second binding throws, like `CircuitBreakerManualControl`. + +```csharp +var state = new CircuitBreakerStateProvider(); + +var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions +{ + FailureRatioThreshold = 0.5, + MinimumThroughput = 10, + StateProvider = state, +})); + +// Health endpoint, metric, log line, or test assertion: +app.MapGet("/health/upstream", () => state.State switch +{ + CircuitState.Closed => Results.Ok("healthy"), + CircuitState.HalfOpen => Results.Ok("recovering"), + _ => Results.StatusCode(503), +}); +``` + +Reading `State` before it is bound to a strategy throws `InvalidOperationException`. + +### Do not gate execution on it + +`State` is a point-in-time read of a value other threads change concurrently. Using it to decide +whether to call is both racy and actively harmful: + +```csharp +// WRONG +if (state.State == CircuitState.Closed) +{ + await pipeline.ExecuteAsync(CallDependencyAsync); +} +``` + +The circuit can open between the check and the call — but the worse problem is that skipping the +call while the circuit is open also skips the **half-open trial call** that lets the circuit close +again. A circuit gated this way can stay open after the dependency has recovered. + +Execute and let the strategy reject. It holds the lock that makes the decision correct, and an open +circuit already fails fast: + +```csharp +// RIGHT +try +{ + await pipeline.ExecuteAsync(CallDependencyAsync); +} +catch (CircuitBrokenException) +{ + // shed load, serve cached data, return 503 — whatever degradation suits +} +``` + ## Result-based circuit breaker On typed pipelines, specific result values count as failures: diff --git a/docs/comparison-with-polly.md b/docs/comparison-with-polly.md index b9a4693..b805fb4 100644 --- a/docs/comparison-with-polly.md +++ b/docs/comparison-with-polly.md @@ -12,11 +12,19 @@ lean, but the moment you want DI integration or HTTP resilience, you're pulling `Resilion.RateLimiting` are opt-in, separate packages — the core stays dependency-free no matter what you add. -**True synchronous execution, not sync-over-async.** Polly's synchronous surface exists, but -under the hood several code paths still route through `Task`/`ValueTask` machinery. -Resilion's `Execute()` path uses real synchronous primitives — `Thread.Sleep`, `WaitHandle` — for -every built-in strategy. This matters for ASP.NET Framework, WinForms/WPF UI threads, and any -code that can't safely call `.GetAwaiter().GetResult()` on an async path without risking deadlock. +**Real synchronous execution for every built-in strategy.** Polly's synchronous surface exists, but +under the hood several code paths still route through `Task`/`ValueTask` machinery. Resilion's +`Execute()` path uses real synchronous primitives — `Thread.Sleep`, `WaitHandle` — in every +built-in strategy. This matters for ASP.NET Framework, WinForms/WPF UI threads, and any code that +can't safely call `.GetAwaiter().GetResult()` on an async path without risking deadlock. + +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. **`RetryDelay` as a discriminated union.** Polly's retry options have `Delay`, `BackoffType`, `UseJitter`, and `DelayGenerator` as independent properties — nothing stops you from setting @@ -24,14 +32,22 @@ code that can't safely call `.GetAwaiter().GetResult()` on an async path without `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 callback ergonomics.** 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 pays nothing -for `ValueTask` wrapping. +**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). **Simpler API surface.** One static factory (`Pipeline.Create`), one builder per pipeline -kind, options classes that read the same way every time. No `PredicateBuilder`, no +kind, options classes that read the same way every time. No mandatory `PredicateBuilder` (there +is one if you want it, but a plain `Func, bool>` is always enough), no `ResiliencePropertyKey` juggling beyond what you actually need. Less to learn before you're productive. @@ -39,19 +55,16 @@ productive. ## Where Polly is better today -**`IHttpClientFactory` integration.** Polly's `Microsoft.Extensions.Http.Resilience` package -gives you `AddStandardResilienceHandler()` — a pre-configured 5-strategy pipeline wired -directly into `HttpClient` with one line. This is the single most common resilience use case, -and Resilion doesn't have an equivalent yet. If you need this today, use Polly (or wrap -`Resilion` in your own `DelegatingHandler` in the meantime). +**HTTP specifics Polly's handler still does better.** Both libraries now give you +`AddStandardResilienceHandler()` on `IHttpClientBuilder` (see [http.md](http.md)), but Polly's +package additionally honours `Retry-After` on 429 and 503 responses, supports per-endpoint hedging +routing, works with synchronous `HttpClient.Send`, and binds its standard options from +`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. -**Dynamic reload.** Polly pipelines can auto-recreate when bound `IOptionsMonitor` options -change. Resilion's pipelines are immutable once built; changing configuration means building a -new one and swapping it in yourself. - **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 @@ -61,24 +74,40 @@ fine but isn't packaged up for you. number of .NET codebases since well before Resilion existed. If your organization needs that track record as a prerequisite, Polly is the safer choice right now. -**`PredicateBuilder` fluent API.** Polly's `new PredicateBuilder().Handle().HandleResult(...)` -composes predicates without writing the lambda by hand. Resilion requires the full -`Func, bool>` — more explicit, more verbose for complex predicates. +## Gaps that have closed + +These were listed here as reasons to choose Polly and no longer are: + +| Was | Now | +|-----|-----| +| `IHttpClientFactory` integration | `Resilion.Http` — see [http.md](http.md) | +| `PredicateBuilder` fluent API | `PredicateBuilder` and `PredicateBuilder` ship in core | +| 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()` | ## Roadmap to close the gap Every item above that Resilion doesn't have yet is tracked with a concrete design in [future-plans.md](future-plans.md): -| Feature | Priority | future-plans.md item | -|---------|----------|----------------------| -| `Resilion.Http` — `IHttpClientFactory` integration | Highest | #39 | -| `Resilion.Testing` — test doubles, assertions | High | #10 | -| Dynamic reload via `IOptionsMonitor` | Medium | #41 | -| `Resilion.Chaos` — chaos engineering | Medium | #40 | -| `PredicateBuilder` fluent API | Lower | #43 | -| `IConfiguration` binding for strategy options | Lower | #44 | -| Telemetry enrichment | Lower | #45 | +| 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 +true — see the P0 section of [future-plans.md](future-plans.md). These are capabilities Resilion +*lacks*, which you can plan around; a 1.0 should not ship promises it doesn't keep. + +Numbers refer to entries in [future-plans.md](future-plans.md). If a number here has no entry +there, that is a bug in the docs — see [verification.md](verification.md). + +A `Resilion.Testing` package is **not** on this list any more. It was deferred rather than +scheduled: the two things it would actually have provided that nothing else could — a way to read +circuit state, and a public pipeline name — shipped in the core library instead, and the rest +(`FakeTimeProvider`, `ResilienceContextPool.Shared.Rent`, `MeterListener`) already works without a +package. See [testing.md](testing.md). If one of these is a hard blocker for you today, Polly remains the right choice until Resilion catches up. If it isn't, Resilion is a smaller, simpler, equally-capable core for everything diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..5bcde1a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,176 @@ +# Configuration binding + +Strategy options bound from `appsettings.json`, `IConfiguration`, environment variables, or any +other configuration source. Lives in the `Resilion.Extensions` package, namespace +`Resilion.Extensions.Configuration`. + +The feature is a small one by design: a layer of bindable POCOs that project into the real options +records. It does **not** build pipelines from JSON — see [What is not supported](#what-is-not-supported-and-why). + +## Why there is a separate DTO layer + +The options records are `sealed record` types with `init`-only members, and `RetryDelay` is a +closed union with a private constructor. Neither shape is bindable: `ConfigurationBinder` needs a +parameterless constructor and settable properties. + +That is not an accident to work around. `RetryDelay` exists *because* Polly exposes four +independently-settable delay properties that can contradict each other +(see [comparison-with-polly.md](comparison-with-polly.md)). Making the union itself bindable would +put the contradiction straight back. + +So the binding types are a separate, one-way funnel: + +| Bindable DTO | Projects into | +|---|---| +| `RetryStrategyConfiguration` | `RetryStrategyOptions`, `RetryStrategyOptions` | +| `RetryDelayConfiguration` | `RetryDelay` | +| `TimeoutStrategyConfiguration` | `TimeoutStrategyOptions` | +| `CircuitBreakerStrategyConfiguration` | `CircuitBreakerStrategyOptions`, `CircuitBreakerStrategyOptions` | +| `HedgingStrategyConfiguration` | `HedgingStrategyOptions` | + +Each DTO's defaults mirror the options record it projects into, exactly. **Binding nothing is +identical to passing no options at all.** Drift between the two would be a silent behaviour change, +so `StrategyConfigurationDefaultsTests` asserts each pair field by field. + +## A complete example + +```json +{ + "Resilience": { + "Api": { + "Retry": { + "MaxRetryAttempts": 5, + "UseJitter": true, + "MaxDelay": "00:00:30", + "Delay": { + "Kind": "Exponential", + "BaseDelay": "00:00:02" + } + }, + "Timeout": { + "Timeout": "00:00:10" + } + } + } +} +``` + +```csharp +using Resilion; +using Resilion.Extensions; +using Resilion.Extensions.Configuration; + +services.Configure(config.GetSection("Resilience:Api:Retry")); + +services.AddResiliencePipeline("api", (builder, retry) => +{ + builder.AddRetry(retry.ToOptions()); + builder.AddTimeout(TimeSpan.FromSeconds(10)); +}); +``` + +`AddResiliencePipeline` is the DI overload added alongside dynamic reload — it resolves +`IOptionsMonitor` and re-reads `CurrentValue` on every rebuild, so **a configuration +change rebuilds the pipeline**. See [dynamic-reload.md](dynamic-reload.md) for the resolve-per-call +contract that makes that work. + +To bind two sections of the same DTO type, use named options and the `optionsName` overload: + +```csharp +services.Configure("api", config.GetSection("Resilience:Api:Retry")); +services.Configure("billing", config.GetSection("Resilience:Billing:Retry")); + +services.AddResiliencePipeline("api", "api", (b, c) => b.AddRetry(c.ToOptions())); +services.AddResiliencePipeline("billing", "billing", (b, c) => b.AddRetry(c.ToOptions())); +``` + +## Delegates are not bindable, and are layered afterwards + +`ShouldHandle`, `OnRetry`, `FallbackAction`, `RateLimiter` and the generators are absent from the +DTOs. A delegate cannot come from a configuration file, and a property that silently does nothing +when bound is worse than no property. + +Layer them on with a record `with` expression. This needs no extra API, and it is the intended +pattern rather than a workaround: + +```csharp +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)), + })); +``` + +> 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. + +## The backoff shape + +`RetryDelayConfiguration.Kind` selects the variant explicitly: + +| `Kind` | Produces | Meaning | +|---|---|---| +| `Constant` | `RetryDelay.Constant(BaseDelay)` | the same delay between every attempt | +| `Linear` | `RetryDelay.Linear(BaseDelay)` | grows linearly with the attempt number | +| `Exponential` *(default)* | `RetryDelay.Exponential(BaseDelay)` | doubles with each attempt | + +**There is no `Custom` member.** `RetryDelay.Custom` takes a delegate, so offering it here would be +the same silently-does-nothing trap as a bindable `ShouldHandle`. `RetryDelayKind_HasNoCustomMember` +pins that. + +**The cap lives on the strategy, not the delay.** `RetryStrategyConfiguration.MaxDelay` applies +after the backoff computes its value, for every kind — including `Constant`, whose factory takes no +maximum at all. Exposing a cap on `RetryDelayConfiguration` as well would be two knobs for one +thing, which is the ambiguity this library exists not to have. + +```json +{ "Delay": { "Kind": "Constant", "BaseDelay": "00:01:00" }, "MaxDelay": "00:00:10" } +``` + +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. +- **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` + throws when the pipeline is built, naming the property, rather than producing a quietly wrong + pipeline. The one thing the DTO validates itself is an out-of-range `RetryDelayKind`. +- **Reflection binding is a trim and AOT hazard.** `Resilion.Extensions` is already + `IsAotCompatible=false`, but if you publish trimmed, use the source-generated configuration + binder (`true`), which + these DTOs are shaped for: plain POCOs, parameterless constructors, settable properties. +- **No new package dependency.** The DTOs are plain classes; your own + `Microsoft.Extensions.Options.ConfigurationExtensions` does the binding. + +## What is not supported, and why + +**Whole pipelines from JSON** — a strategy array with type discriminators and ordering read from +configuration — is deliberately out of scope. + +It sounds like the natural next step and it is a trap: + +- `OrderingValidator`'s guarantees would become a property of a JSON file. A pipeline whose + circuit breaker ends up outside its retry still *builds*; it just stops doing what the operator + thought it did. Today that misordering is caught against a literal you can read in one place. +- It does not remove the code. Every predicate, every callback, every rate limiter instance still + has to be written in C# and attached by name, so you end up maintaining a pipeline in two files + instead of one, with the configuration half unable to express half of it. +- The failure diagnostics get strictly worse: a typo in a strategy name is a runtime lookup + failure at first use, where today it is a compiler error. + +What you can configure is each strategy's *values*, which is where the operational knobs actually +are — timeouts, attempt counts, thresholds, durations. The pipeline's *shape* stays in code. + +## See also + +- [retry.md](retry.md) — the retry strategy and `RetryDelay` in full +- [dynamic-reload.md](dynamic-reload.md) — rebuilding a pipeline when configuration changes +- [comparison-with-polly.md](comparison-with-polly.md) — why `RetryDelay` is a closed union diff --git a/docs/dynamic-reload.md b/docs/dynamic-reload.md new file mode 100644 index 0000000..9657041 --- /dev/null +++ b/docs/dynamic-reload.md @@ -0,0 +1,187 @@ +# Dynamic reload + +Rebuild a named pipeline when its configuration changes, without restarting the process. Lives in +the `Resilion.Extensions` package. + +> **The one rule that makes this work:** with reload enabled, resolve the pipeline **per call**. +> Never cache a `Pipeline` in a field. Everything below follows from that. + +## Basic usage + +Bind a pipeline to an options type and it rebuilds whenever `IOptionsMonitor` reports a +change — which includes `appsettings.json` edits, since the default configuration providers watch +the file. + +```csharp +using Resilion; +using Resilion.Extensions; + +services.Configure(config.GetSection("Resilience:Api")); + +services.AddResiliencePipeline("api", (builder, options) => +{ + builder.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = options.MaxRetries }); + builder.AddTimeout(options.Timeout); +}); +``` + +The `configure` delegate is stored, not its result. Every rebuild re-runs it against +`IOptionsMonitor.CurrentValue`, so it reads **fresh** options each time — it must not capture a +snapshot of them. + +See [configuration.md](configuration.md) for binding the built-in strategy options straight from +`IConfiguration`. + +### Named options + +Use the `optionsName` overload to bind several pipelines to different sections of the same options +type: + +```csharp +services.Configure("api", config.GetSection("Resilience:Api")); +services.Configure("billing", config.GetSection("Resilience:Billing")); + +services.AddResiliencePipeline("api", "api", (b, o) => ...); +services.AddResiliencePipeline("billing", "billing", (b, o) => ...); +``` + +## Consuming a reloadable pipeline + +**Correct** — resolve on each call, so you always get the current instance: + +```csharp +public sealed class ApiClient(IPipelineProvider pipelines) +{ + public ValueTask GetAsync(CancellationToken ct) => + pipelines.GetPipeline("api").ExecuteAsync(FetchAsync, ct); +} +``` + +**Wrong** — this caches the pipeline that existed when the class was constructed, and will use it +forever: + +```csharp +public sealed class ApiClient +{ + private readonly Pipeline _pipeline; // never updates + + public ApiClient(IPipelineProvider pipelines) => + _pipeline = pipelines.GetPipeline("api"); +} +``` + +`GetPipeline` is a dictionary read plus — after an invalidation — one build. It is not a call worth +caching around. + +## Reload and keyed injection are mutually exclusive + +Keyed DI resolution works: + +```csharp +public sealed class ApiClient([FromKeyedServices("api")] Pipeline pipeline) { ... } +``` + +but **a keyed pipeline never observes a reload.** The keyed registration is a singleton, so the +container resolves it once and caches it for its own lifetime, and your constructor parameter +caches it again. Neither ever asks the registry a second time. + +This is not a bug to be fixed later; it is what "singleton" means. The two features serve different +cases: + +| | Use | +|---|---| +| `[FromKeyedServices("name")] Pipeline` | a pipeline whose configuration is fixed for the process lifetime | +| `IPipelineProvider`, resolved per call | a pipeline that can be reloaded | + +Transient keyed registration is **not** an escape hatch: DI tracks and disposes transient +`IDisposable`s resolved from a scope, which would dispose the registry's cached pipeline out from +under every other consumer. + +## Reloading from something other than `IOptionsMonitor` + +`InvalidatePipeline` is the primitive; the options binding above is one caller of it. + +```csharp +public bool InvalidatePipeline(TKey key); +public bool InvalidatePipeline(TKey key); +``` + +It drops the **cached** pipeline and keeps the registered factory, so the next `GetPipeline` +rebuilds. It returns `true` if a cache entry was removed — including one holding a cached build +failure — and `false` for an unknown key or a key registered but never resolved, since in both of +those cases the next `GetPipeline` already builds a current pipeline. Any `IChangeToken` source +works with no extra API: + +```csharp +ChangeToken.OnChange( + () => config.GetReloadToken(), + () => registry.InvalidatePipeline("api")); +``` + +The typed and non-generic stores are separate — `InvalidatePipeline("api")` +does not affect `InvalidatePipeline("api")`. + +## What happens to the old pipeline + +**The registry does not dispose a replaced pipeline, deliberately.** It cannot know when the last +execution on it finishes without ref-counting every execute path. + +- An execution already running continues on the old pipeline and completes normally. `Pipeline` is + immutable, and the executing frame holds its own reference, so there is no tearing. +- New calls get the new pipeline. +- The old one is collected once nothing references it. That is safe while no strategy owns an + unmanaged resource, which is true of every built-in strategy. + +If you need to know — to dispose it yourself after a grace period, or to log it — subscribe: + +```csharp +services.ConfigureResiliencePipelineRegistry(registry => +{ + registry.OnPipelineReplaced = args => + logger.LogInformation("Rebuilt pipeline {Key} ({ResultType})", args.Key, args.ResultType); +}); +``` + +`PipelineReplacedArgs` carries the `Key`, the `ResultType` (`null` for a non-generic +pipeline) and the evicted instance as `IAsyncDisposable`. The callback fires only when a +successfully built pipeline was evicted — evicting a cached build failure returns `true` without +invoking it, because there is no instance to hand over. + +Disposing the old pipeline immediately inside that callback is usually wrong, for the in-flight +reason above. + +## State is lost on reload, by definition + +A rebuilt pipeline is a **new** pipeline. A circuit breaker starts closed with an empty window; a +rate limiter is whatever instance the factory supplies. + +This matters most for the circuit breaker: reloading a pipeline resets its memory of recent +failures, so a config change during an incident re-opens traffic to a failing dependency. Reload +the pipelines whose values operators genuinely need to tune at runtime, not every pipeline by +default. + +A rate limiter is the exception, and only if you supply the same instance — `RateLimiterStrategyOptions.RateLimiter` +is a caller-owned instance, so resolving a DI singleton inside the factory preserves it across +rebuilds. + +## Consistency + +Invalidation and retrieval are not serialized against each other. A `GetPipeline` racing a +concurrent `InvalidatePipeline` can return a pipeline built from options that were current +microseconds earlier. This is eventual consistency by design — the alternative is a lock on the +hot path to make a configuration change land a few microseconds sooner. + +**A build that throws is cached, exception and all.** If the `configure` delegate throws — bad +values from a reload, a service that was not ready — the `Lazy` holds that exception and every +later `GetPipeline` for that key rethrows *the same* exception without re-running the factory. The +pipeline stays broken until something invalidates it. + +With reload wired up that resolves itself: the next options change invalidates the key and the +following resolve rebuilds. Without reload it is permanent for the process lifetime, so validate +configuration where it is bound rather than inside the factory. Tracked as +[#67](future-plans.md). + +## See also + +- [configuration.md](configuration.md) — binding strategy options from `IConfiguration` +- [pipelines.md](pipelines.md) — pipeline construction and composition diff --git a/docs/future-plans.md b/docs/future-plans.md index 21fccfb..612f136 100644 --- a/docs/future-plans.md +++ b/docs/future-plans.md @@ -2,550 +2,778 @@ Features, improvements, and known tradeoffs still open for future versions of Resilion. This is a todo list, not a changelog — once something here is implemented, it comes out of this file -(see [CHANGELOG.md](../CHANGELOG.md) for what's already shipped). Each section includes enough -detail to serve as a starting point for implementation. +in the same PR (see [CONTRIBUTING.md](../CONTRIBUTING.md) § Future-plans hygiene for the rule, and +[CHANGELOG.md](../CHANGELOG.md) for what's already shipped). Each entry includes enough detail to +serve as a starting point for implementation. ## Type Classification | Type | Meaning | Impact | Examples | |------|---------|--------|----------| | **Feature** | New capability that extends Resilion's functionality beyond current scope | Additive — users gain new resilience options or integrations | Rate Limiter, Hedging, DI integration, Telemetry | -| **Fix** | Correctness issue, performance bug, or missing validation in existing features | Blocking/Correctness — existing code may behave incorrectly or sub-optimally | Per-call closure allocation, WaitHandle allocation | +| **Fix** | Correctness issue, performance bug, or missing validation in existing features | Blocking/Correctness — existing code may behave incorrectly or sub-optimally | Timeout cancellation race, missing telemetry increments | | **Documentation** | Gaps in docs, missing behavioral explanations, or unclear semantics | Clarity — improves developer experience and correctness of usage | Strategy ordering guide, cancellation semantics | -| **Improvement** | Enhancement to an existing feature that doesn't change its API contract | Quality — existing code works but could work better | CTS pooling, delegate caching | -| **Infrastructure** | CI/CD, packaging, tooling, or repository quality improvements | Adoption — removes friction for users and contributors | Benchmarks, icon, README | +| **Improvement** | Enhancement to an existing feature that doesn't change its API contract | Quality — existing code works but could work better | Retry loop deduplication, CTS pooling | +| **Infrastructure** | CI/CD, packaging, tooling, or repository quality improvements | Adoption — removes friction for users and contributors | Benchmarks, SourceLink, public API enforcement | -**How to use this guide:** -- **Fixes** should be prioritized over Features if they affect correctness -- **Documentation** should accompany every Feature (document what it does and why) -- New contributors reading this can quickly understand priority and scope +**How to read this file** -## Priority Matrix - -| # | Item | Priority | Type | Impact | Effort | Notes | -|---|------|----------|------|--------|--------|-------| -| 39 | Resilion.Http — HttpClient integration | P1 | Feature | Critical | High | AddStandardResilienceHandler() for IHttpClientFactory. #1 missing feature for adoption | -| 49 | Telemetry counters carry no dimensions/tags | P1 | Fix | Critical | Medium | Add pipeline.name, operation.key tags to all counter increments | -| 50 | ActivitySource is declared but never used | P1 | Fix | Medium | Medium | Wrap strategy execution in StartActivity() or remove promise | -| 51 | No public API surface tracking | P1 | Infrastructure | Critical | Low | PublicApiAnalyzers, PublicAPI.Shipped.txt, CI enforcement | -| 4 | Per-call delegate allocation in pipeline chain | P2 | Fix | Medium | High | Middleware pattern inherent cost | -| 5 | CancellationTokenSource pooling for Timeout/Hedging | P2 | Improvement | Medium | Medium | Reduce alloc on hot path | -| 11 | Hedging: Task.WhenAny memory leak | P2 | Fix | Medium | Medium | Continuations not deregistered on losing tasks | -| 27 | Retry typed/non-generic strategy duplication (~100 lines) | P2 | Improvement | Medium | Medium | Extract shared retry loop | -| 40 | Resilion.Chaos — Chaos engineering | P2 | Feature | Medium | High | Fault/outcome/latency/behavior injection (Simmy equivalent) | -| 41 | Pipeline dynamic reload via IOptionsMonitor | P2 | Feature | Medium | High | Auto-recreate pipeline when bound options change | -| 42 | Keyed services support | P2 | Feature | Medium | Medium | [FromKeyedServices("key")] for direct DI injection (.NET 8+) | -| 46 | Timeout cancellation TOCTOU race fix | P2 | Fix | Low | Low | Eliminate narrow race in WasCancelledByTimeout — moved from tradeoffs.md | -| 52 | No SourceLink, symbol packages, or deterministic builds | P2 | Infrastructure | Medium | Low | PublishRepositoryUrl, IncludeSymbols, SymbolPackageFormat, ContinuousIntegrationBuild | -| 9 | WaitHandle allocation in sync retry | P3 | Fix | Low | Low | CancellationToken.WaitHandle lazy alloc | -| 10 | Resilion.Testing package | P3 | Feature | Medium | Low | Test doubles, assertion helpers | -| 12 | Hedging: HedgingDelayGenerator | P3 | Feature | Low | Low | Dynamic delay per attempt | -| 30 | FallbackAction/ResilienceEventHandler Task.Run missing CancellationToken | P3 | Fix | Low | Low | Sync-over-async wrappers can't be interrupted by cancellation | -| 43 | PredicateBuilder<T> fluent API | P3 | Feature | Low | Medium | .Handle<TException>().HandleResult(r => ...) with implicit conversion | -| 44 | IConfiguration binding for strategy options | P3 | Feature | Low | Medium | Strategy options from appsettings.json | -| 45 | Telemetry enrichment (MeteringEnricher, listeners) | P3 | Feature | Low | Medium | Custom tags, raw event listeners, severity control | -| 47 | CI code coverage collection | P3 | Infrastructure | Low | Medium | Blocked — verified incompatibility, see item #47 below | -| 48 | Benchmark CI on PRs | P3 | Infrastructure | Low | Low | Catch perf regressions automatically instead of only on manual runs | -| 53 | No SECURITY.md | P3 | Documentation | Low | Low | Vulnerability disclosure policy for OSS infrastructure library | -| 54 | Evaluate multi-targeting net9.0 for System.Threading.Lock | P3 | Improvement | Low | Medium | Benchmark vs object lock, conditional compile if faster | - ---- +- **Every entry carries front-matter** stating its `Status`, `Type`, `Target` release, and the date + and command last used to **verify** its factual claims. `Status: Complete` is not a legal value — + a completed item leaves this file. +- **`Target` answers "is this a release blocker?"**; `Priority` answers "how much does it matter?". + Conflating the two is what let three already-implemented items sit in this file as open work. +- **Fixes** are prioritized over Features when they affect correctness. +- **Documentation** accompanies every Feature — document what it does and why. +- **No claim about current behaviour is written from memory.** Re-run the command from + [verification.md](verification.md) and record it in the entry's `Verified` field. -## P1 — Pre-v1.0 or Shortly After - -### ✅ 49. Telemetry Counters Carry No Dimensions/Tags — IMPLEMENTED - -**Status:** Complete -**What was done:** -1. Added `pipeline.name` and `operation.key` tag constants to `ResilionTelemetry` -2. Extended `ResilienceContext` with `PipelineName` property -3. Modified `Pipeline` and `Pipeline` to store and pass pipeline name through context -4. Updated `PipelineBuilder.Build()` to pass name to Pipeline constructor -5. Updated `ResiliencePipelineRegistry` to set builder.Name from the pipeline key -6. Updated all counter.Add() calls in 6 strategy files to include tags: - - RetryStrategy.cs (4 locations) - - CircuitBreakerStateMachine.cs (2 locations) - - TimeoutStrategy.cs (2 locations) - - FallbackStrategy.cs (2 locations) - - HedgingStrategy.cs (1 location) - - RateLimiterStrategy.cs (2 locations) +## Priority Matrix -**Files modified:** 11 core files + bug fixes -**Verification:** All counters now emit with pipeline.name and operation.key tags +Generated from the entries below. A row here without a `### .` entry below — or an +entry without a row here — is a bug in this file; see CONTRIBUTING.md § Future-plans hygiene. + +| # | 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 | +| 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 | +| 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 | +| 47 | CI code coverage collection | P3 | Infrastructure | unscheduled | Medium | **Blocked** | --- -### ✅ 50. ActivitySource Is Declared But Never Used — IMPLEMENTED - -**Status:** Complete -**What was done:** -1. Added `using System.Diagnostics` to 6 strategy files -2. Wrapped strategy execution in `ActivitySource.StartActivity()` calls -3. Set tags for strategy name, pipeline name, and operation key on each activity -4. Tagged outcomes with strategy-specific values (success, failure, timeout, rejected, etc.) -5. Implemented spans in: - - RetryStrategy (both async and sync) - - TimeoutStrategy (both async and sync) - - FallbackStrategy (both async and sync) - - HedgingStrategy (async path) - - CircuitBreakerStrategy (both async and sync) - - RateLimiterStrategy (both async and sync) - -**Files modified:** 6 strategy files -**Verification:** Spans now emitted with distributed tracing support enabled +## P0 — Must land before 1.0.0 ---- - -### ✅ 51. No Public API Surface Tracking — IMPLEMENTED +_Empty._ Everything that was blocking 1.0.0 has shipped — see `## Retired items` and +[CHANGELOG.md](../CHANGELOG.md). A new entry belongs here only if it makes something the library +already claims untrue, or produces a silently wrong answer. -**Status:** Complete -**What was done:** -1. Added `Microsoft.CodeAnalysis.PublicApiAnalyzers` (v3.3.4) to `src/Directory.Build.props` -2. Created PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt files for all 3 public packages: - - Resilion/PublicAPI.Shipped.txt - - Resilion/PublicAPI.Unshipped.txt - - Resilion.Extensions/PublicAPI.Shipped.txt - - Resilion.Extensions/PublicAPI.Unshipped.txt - - Resilion.RateLimiting/PublicAPI.Shipped.txt - - Resilion.RateLimiting/PublicAPI.Unshipped.txt -3. Analyzer now enforces that all public APIs must be explicitly tracked -4. CI builds will fail if public surface changes without updating the .Unshipped.txt files +## P1 — Next (post-1.0) -**Files modified:** 1 (Directory.Build.props) + 6 new API tracking files -**Verification:** Build succeeds, analyzer correctly identifies undocumented public APIs +### 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. -## P1 — Pre-v1.0 or Shortly After +**Why** -### 39. Resilion.Http — HttpClient Integration +`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: -**Type:** Feature — Critical for Adoption +```csharp +// CS1660 +new RetryStrategyOptions { OnRetry = ctx => logger.LogWarning("retry {N}", ctx.AttemptNumber) }; -**Why** +// CS0428 +new RetryStrategyOptions { OnRetry = LogRetry }; +``` -The most common use case for a resilience library is wrapping `HttpClient` calls. Polly's `Microsoft.Extensions.Http.Resilience` package provides `AddStandardResilienceHandler()` which adds a pre-configured 5-strategy pipeline to an `HttpClient` via `IHttpClientFactory`. Without this, Resilion misses the #1 onboarding path. +and the caller must write `new Action(ctx => ...)`, an inline cast, or a typed +local instead. -**Design** +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. -New package `Resilion.Http` with: -- `AddStandardResilienceHandler()` extension on `IHttpClientBuilder` — pre-configured pipeline: RateLimiter → TotalTimeout → Retry → CircuitBreaker → AttemptTimeout, with defaults matching Polly's (retry on 5xx, 429, 408, `HttpRequestException`, `TimeoutRejectedException`) -- `AddStandardHedgingHandler()` — replaces retry with hedging for latency-sensitive scenarios -- `AddResilienceHandler(key, builder => ...)` — custom pipeline on a named HttpClient +**Possible fixes** -Implementation: `DelegatingHandler` wrapping the Resilion pipeline. References `Resilion.Extensions` and `Microsoft.Extensions.Http`. +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. -**Performance note:** HttpClient pipelines are a hot path in high-throughput services (potentially millions of calls/sec). The per-call delegate allocation in the pipeline chain (#4 below) is acceptable for general use but may start showing up in profiles once this package ships. Benchmark the standard resilience handler under load and revisit #4 if closure allocations become a measurable cost at that scale. +Decide between (1) and (2) before 2.0 rather than drifting into (3) permanently. --- -### 49. Telemetry Counters Carry No Dimensions/Tags +### 27. Retry Typed/Non-Generic Strategy Duplication -**Type:** Fix — Correctness +**Status:** Open +**Type:** Improvement +**Target:** post-1.0 +**Blocks:** nothing, but see #56 — the span-scaffolding extraction there is the part #56 needed. +**Verified:** 2026-09-30 — `grep -c 'for (var attempt = 0' src/Resilion/Retry/RetryStrategy.cs` → 4; +`wc -l src/Resilion/Retry/RetryStrategy.cs` → 302. **Why** -Every counter increment in the codebase fires with zero tags. In `RetryStrategy.cs`, `CircuitBreakerStateMachine.cs`, `TimeoutStrategy.cs`, `FallbackStrategy.cs`, `HedgingStrategy.cs`, and `RateLimiterStrategy.cs`, every call to `.Add(1)` on counters like `ResilionTelemetry.RetryAttempts`, `CircuitBreakerStateChanges`, `TimeoutExpirations`, `FallbackActivations`, `HedgingAttempts`, and `RateLimiterRejections` passes zero tags. +`RetryStrategy` / `RetryStrategy` hold **four independent retry loops** — `:19-103` (~85 lines), +`:105-181` (~77), `:198-250` (~53), `:252-301` (~50). Each carries its own `for`, predicate check, +`MaxDelay` clamp, telemetry increment, `OnRetry` invocation and delay wait. The equivalent circuit +breaker duplication was already extracted into a shared `CircuitBreakerStateMachine` — and that +extraction is exactly what let the CB race-condition fix apply to both variants automatically instead +of being kept in sync by hand. -In an application with two named pipelines both using Retry, the `resilion.retry.attempts` counter cannot distinguish which pipeline is retrying. The counter becomes nearly useless for observability in multi-pipeline scenarios. +**Risk:** a bug fix applied to one copy but not the other causes silent behavioural divergence between +the typed and non-generic variants. This already happened once: the untyped pair is ~30 lines longer +than the typed pair *because* it carries span code the typed pair lacks (#56). **Fix** -Thread the following tags to every `.Add()` call: -- `pipeline.name` — sourced from `PipelineBuilderBase.Name` (already available at build time, must be passed through `ResilienceContext` at execution time) -- `operation.key` — sourced from `ResilienceContext.OperationKey` (already exists but is never read for telemetry) +Extract a shared retry loop. It must be parameterized over two things that genuinely differ: the +predicate (untyped: an `outcome.IsSuccess` early return plus `ShouldHandleException(outcome.Exception!)`; +typed: `ShouldHandleOutcome(outcome)`) and the `OnRetry` argument type (`RetryAttemptEvent` vs +`RetryAttemptEvent`). -Grep confirms zero tag usage across all strategy files. This is a correctness gap in the telemetry contract, not a nice-to-have. Untagged counters in multi-instance deployments are close to useless for debugging and alerting. +**Do it only with the benchmark numbers in hand.** Delegate-based parameterization adds per-execution +closure allocations to the library's most-advertised hot path (114 ns / 192 B for a single retry, 61 ns +/ 192 B sync). An allocation-free version needs a generic-struct-constrained-interface shim so the JIT +specializes. Either way, commit the re-run benchmark results with the change. -**Effort:** Medium. Requires threading pipeline name through context initialization and reading `OperationKey` at each counter site. +**Safe to attempt once #55 and #56 have landed** — their parity tests +(`HedgingAttempts_SyncAndAsyncAgreeOnCount`, `TypedAndUntypedPipelines_EmitTheSameSpanNames`) pin the +observable behaviour of all four paths before the loops merge. **Public API: none**, if done as an +internal shared loop or internal shim. If the design starts wanting a public extension point, stop +and get that approved separately. + +**Effort:** Medium. --- -### 50. ActivitySource Is Declared But Never Used +### 30. FallbackAction/ResilienceEventHandler `Task.Run` Missing `CancellationToken` -**Type:** Fix — Correctness +**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. **Why** -`ResilionTelemetry.ActivitySource` is declared as `new ActivitySource("Resilion")` and its XML documentation explicitly instructs consumers to `.AddSource("Resilion")` for distributed tracing support. Grep across all of `src/` finds zero calls to `.StartActivity()` anywhere in the codebase. +Both `FallbackAction.Execute` and `ResilienceEventHandler.Invoke` call +`Task.Run(() => …).GetAwaiter().GetResult()` for sync-over-async without passing a +`CancellationToken`. -This is the same class of problem as the dead `StrategyExecutions` and `StrategyDuration` counters that were already removed from this file — an unverified telemetry promise is worse than no promise. The ActivitySource is technically "used" (publicly declared, referenced in XML docs) but never actually emits a span. Any consumer configuring their tracing stack to listen for "Resilion" will receive nothing. +**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. **Fix** -Either: - -1. **Implement spans:** Wrap each strategy's `ExecuteAsync` and `Execute` entry point in a `StartActivity()` call, tagged with strategy name, pipeline name, and outcome (success/failure/cancelled). Wire the resulting `Activity` through to telemetry for correlation. This is the preferred path — spans are valuable for latency profiling and distributed tracing in high-scale systems. +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. -2. **Remove the ActivitySource:** Delete the declaration and the misleading XML docs if tracing is not a v1.0 priority. This is acceptable; the promise can be reinstated when the feature is actually implemented. +**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. -The current state (declared but unused) is untenable — it signals a feature that doesn't work. - -**Effort:** Medium for implementation, trivial for removal. +**Effort:** Low. --- -### 51. No Public API Surface Tracking +### 9. Sync Retry Delay Bypasses `TimeProvider` (Untestable) -**Type:** Infrastructure — Correctness +**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)`. **Why** -Nothing in this repository guards against accidental breaking changes after v1.0 ships. Confirmed via repo-wide grep: zero matches for "PublicAPI", "ApiCompat", or "PublicApiAnalyzers". No `Microsoft.CodeAnalysis.PublicApiAnalyzers` in dependencies. No `PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt` files. No `Microsoft.DotNet.ApiCompat` CI check. +This entry previously claimed that `CancellationToken.WaitHandle` "lazily allocates a +`ManualResetEvent` that is never explicitly disposed … on every retry delay". **That is wrong.** The +handle is allocated **once per `CancellationTokenSource`**, is disposed with the CTS, and for a +default (`CanBeCanceled == false`) token the runtime uses a static never-signalled event with no +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. -Once the API is frozen at v1.0, there is no automated enforcement preventing a later PR from silently changing a public method signature, removing a member, or adding an unintended public API. This is standard practice in shipping libraries (Polly, NUnit, xunit all use PublicApiAnalyzers). +**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.** **Fix** -1. Add `Microsoft.CodeAnalysis.PublicApiAnalyzers` NuGet package to `src/Directory.Build.props`. -2. After v1.0 ships, generate `PublicAPI.Shipped.txt` files for each public project per the tool's documentation. -3. Add a CI check in `.github/workflows/` that fails any PR changing public surface without updating the corresponding `.Unshipped.txt` file. +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. -**Effort:** Low. The package is zero-configuration after initial setup. The CI check is a single MSBuild step. +**Effort:** Low to write, medium to design well. --- -## P2 — Do Next / Post-v1.0 - -### 4. Per-Call Delegate Allocation in Pipeline Chain +### 54. Evaluate Multi-Targeting net9.0 for `System.Threading.Lock` -**Type:** Fix — Performance +**Status:** Open +**Type:** Improvement +**Target:** post-1.0 +**Verified:** 2026-09-30 — `grep -n 'TargetFramework' src/Directory.Build.props` → `net8.0` (singular); +`grep -rn 'System.Threading.Lock\|NET9_0_OR_GREATER' src/ --include='*.cs'` → 0 hits. **Why** -In `StrategyComponent.ExecuteAsync`, the lambda `ctx => _next.ExecuteAsync(callback, ctx)` creates a closure capturing `_next` and `callback` on every call. In a pipeline with N strategies, this means N small closure allocations per execution. - -**Current State** - -This is inherent to the middleware/chain-of-responsibility pattern. Polly v8 has the same cost. The allocations are small (one closure object + one delegate per strategy per call) and negligible compared to real I/O costs. Confirmed empirically in [benchmarks/results](../benchmarks/results/README.md): Resilion's happy-path pipelines allocate a few hundred bytes where Polly's are allocation-free, but Resilion is still faster wall-clock time across every shape benchmarked. - -**Potential Fix** - -Pre-compose the delegate chain at build time into a single `Func, ValueTask>>` that doesn't allocate per call. This requires complex generic type threading and may not be feasible without sacrificing API simplicity. - -**Decision:** Accept for now. Revisit if profiling on a real high-throughput workload (see #39's performance note) shows it matters. - ---- +.NET 9 introduced `System.Threading.Lock`, a purpose-built synchronization primitive faster than +`lock(object)` under contention. `CircuitBreakerStateMachine.cs:23` and `SlidingWindow.cs:20` both hold +a lock on every call while in the Closed state — the most common state in healthy systems. -### 5. CancellationTokenSource Pooling for Timeout +**Plan the experiment, not the change.** -**Type:** Improvement — Performance +The existing `CircuitBreakerLoadBenchmarks` **cannot answer this question** — it is single-threaded, +and `System.Threading.Lock`'s advantage is a contention-path property. Two new benchmarks are required: -**Why** +1. `LockPrimitiveBenchmarks` — an isolated micro-benchmark that does not reference the library. Two + standalone copies of `SlidingWindow`'s hot path (record a sample + compute the ratio, inside the + lock), one per lock type behind `#if NET9_0_OR_GREATER`, with `[Params(1, 4, 16, 64)] int Threads` + driving concurrent callers against one instance. The benchmarks project already multi-targets + `net8.0;net10.0`, so this compiles as-is. +2. `CircuitBreakerLoadBenchmarks.Closed_MixedTraffic_Concurrent` with + `[Params(1, 4, 16, 64)] int Concurrency`. **Needed regardless of this item** — the suite currently + has no contention coverage for the library's only lock-per-call path. -The Timeout strategy allocates one `CancellationTokenSource` per execution. `CancellationTokenSource.TryReset()` (available since .NET 6) enables pooling — reset and reuse instead of allocating. +**Decision rule — agree it before benchmarking, so the outcome isn't negotiated after the numbers +land.** Proceed only if all three hold: -**Design** +- **(a)** At `Threads >= 16`, `System.Threading.Lock` is **>= 5% faster in mean wall-clock** in the + micro-benchmark, with non-overlapping confidence intervals. +- **(b)** The same **>= 5%** shows up end-to-end at `Concurrency >= 16`. A micro-benchmark win that + doesn't survive the full pipeline is not a user-visible win. +- **(c)** At 1 and 4 threads, the new lock is **not more than 2% slower**. The uncontended case is the + common one; a regression there is a **veto**. -Use `ObjectPool` with a custom policy that calls `TryReset()` on return. If `TryReset()` returns false (the CTS was cancelled), discard instead of pooling. +**If it passes**, the code change is trivial but the cost is not: `net8.0;net9.0` +affects every src project; `test.yml`'s matrix varies the *SDK*, not the TFM, so it needs a TFM +dimension or the net9.0 assemblies are built but never tested; `ci.yml`'s `aot-verify` hardcodes a +`net8.0` publish path; and `release-nuget.yml` must pack both TFMs. **Keep the change purely +internal** so `PublicAPI.*.txt` stays TFM-invariant. -**Complexity:** Medium. `TryReset()` has edge cases around timer state and concurrent cancellation. +**Expected outcome: it fails its own gate.** The documented win is concentrated in the uncontended +fast path and in avoiding `Monitor`'s thread-static lookups; clearing 5% at the full-pipeline level, +where an execution is already hundreds of nanoseconds, is a high bar. **If it fails, commit the +numbers to `benchmarks/results/` and rewrite this entry as "Evaluated and rejected", with a link** — +so it stops reading as pending work. -**Decision:** Implement after benchmarks confirm the CTS allocation is a meaningful cost. +**Effort:** Medium, almost entirely benchmarking time. --- -### 11. Hedging: Task.WhenAny Memory Leak +### 63. Outcome-Aware Retry Delay -**Type:** Fix — Performance/Correctness +**Status:** Open +**Type:** Feature +**Target:** post-1.0 +**Verified:** 2026-10-23 — `RetryDelayContext` carries `AttemptNumber` only; no overload receives +the outcome. **Why** -`WaitForBestOutcome` uses `Task.WhenAny(remaining)` in a loop, removing completed tasks from the list. The standard `Task.WhenAny` implementation registers a continuation on every task in the list. When a task completes, the continuations on the *other* tasks are not deregistered — they hold references to the `Task.WhenAny` result task and its captured state, preventing garbage collection until all tasks in the list complete. +`RetryDelay.Custom` receives `RetryDelayContext(AttemptNumber)`, so a delay cannot depend on what +came back. That makes honouring a `Retry-After` header impossible, and `OnRetry` cannot help — +it observes the attempt but cannot change the delay. -In a pipeline with 3+ hedged attempts where the first wins quickly, the losing tasks' continuations hold memory until they eventually complete or are cancelled. +This is the largest remaining functional gap against `Microsoft.Extensions.Http.Resilience`, and it +bites by default: both libraries retry 429 out of the box, and Resilion does so on blind +exponential backoff while the server is telling it exactly how long to wait. -**Potential Fix** - -Implement a custom `FirstCompletedAsync` that uses `TaskCompletionSource` + per-task continuations that deregister themselves on completion. Or use `WaitAsync` chaining. - -**Impact:** Medium. Only affects memory under hedging with many concurrent attempts. The cleanup in `finally` (awaiting all tasks) limits the leak duration. +**Fix:** give the delay context the outcome — `RetryDelayContext` carrying +`Outcome` alongside the attempt number. `Resilion.Http` can then map `Retry-After` to a +delay without the core knowing anything about HTTP. --- -### 27. Retry Typed/Non-Generic Strategy Duplication +### 64. Composable Event Handlers -**Type:** Improvement — Maintainability +**Status:** Open +**Type:** Improvement +**Target:** unscheduled +**Verified:** 2026-10-23 — `grep -n 'internal.*Invoke' src/Resilion/ResilienceEventHandler.cs`. **Why** -`RetryStrategy` / `RetryStrategy` duplicate ~100 lines of identical retry loop (4 copies: async+sync × typed+untyped). The equivalent circuit breaker duplication was already extracted into a shared `CircuitBreakerStateMachine` — that extraction is exactly what let the CB race-condition fix apply to both variants automatically instead of needing to be kept in sync by hand. Retry doesn't have shared mutable state the way CB does, so the risk of silent divergence is lower, but the duplication itself is the same shape of problem. +`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. -**Fix** - -Extract a shared retry loop parameterized on `Func, bool>` for the predicate check, following the same pattern used for the CB state machine. - -**Risk:** A bug fix applied to one copy but not the other causes silent behavioral divergence between typed and non-generic variants of the same strategy. +Any future package wanting to observe a user-supplied callback hits the same wall. --- -### 40. Resilion.Chaos — Chaos Engineering +### 65. Registry Factory with IServiceProvider -**Type:** Feature +**Status:** Open +**Type:** Improvement +**Target:** unscheduled +**Verified:** 2026-10-23 — `ResiliencePipelineRegistry.RegisterPipeline` takes +`Action>` with no provider. **Why** -Polly v8.3+ includes Simmy for chaos engineering — fault injection, outcome injection, latency injection, and behavior injection. This is table stakes for production resilience testing. - -**Design** - -New package `Resilion.Chaos` with four strategies: `AddChaosFault`, `AddChaosOutcome`, `AddChaosLatency`, `AddChaosBehavior`. Common options: `InjectionRate` (0–1), `InjectionRateGenerator` (dynamic), `Enabled` (bool), `EnabledGenerator` (dynamic enable/disable). Generators take precedence over static values. Chaos strategies go last in the pipeline so outer resilience strategies react to injected chaos. +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. --- -### 41. Pipeline Dynamic Reload via IOptionsMonitor +### 66. Synchronous ExecuteOutcome +**Status:** Open **Type:** Feature +**Target:** unscheduled +**Verified:** 2026-10-23 — `Pipeline` exposes `ExecuteOutcomeAsync` but no sync equivalent. **Why** -Polly supports `context.EnableReloads()` which auto-recreates pipelines when `IOptionsMonitor` detects a configuration change. Without this, changing retry counts or timeout durations requires an app restart. - -**Design** - -`ResiliencePipelineRegistry` gains `IChangeToken` support. When a watched `IOptionsMonitor` fires, the registry invalidates the cached `Lazy` for the affected key, creates a new one from the updated options, and atomically swaps it. In-flight executions on the old pipeline complete normally; new calls get the new pipeline. `OnPipelineDisposed` callback fires after the old pipeline has no outstanding executions. +`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. --- -### 42. Keyed Services Support +### 4. Per-Call Delegate Allocation in Pipeline Chain -**Type:** Feature +**Status:** Open +**Type:** Improvement +**Target:** post-1.0 +**Verified:** 2026-10-23 — `benchmarks/results/Resilion.Benchmarks.HttpResilienceHandlerBenchmarks-report-github.md`, +full run, not ShortRun. **Why** -.NET 8 introduced keyed DI services (`[FromKeyedServices("key")]`). Polly v8.3+ supports this for direct injection of named pipelines without going through the registry. - -**Fix:** Register each named pipeline as a keyed singleton alongside the registry entry. Consumers can inject `[FromKeyedServices("http-api")] Pipeline pipeline` directly. +`StrategyComponent.ExecuteAsync` creates the closure `ctx => _next.ExecuteAsync(callback, ctx)` per +execution, so an N-strategy pipeline allocates N closures per call. + +**This is now the largest measured gap against Polly**, which is why it has moved from "accepted +tradeoff, revisit if profiling shows it matters" to a priority. `Resilion.Http` was the workload +the earlier write-up predicted would surface it: + +| | Total alloc | Over a bare client | Gen0 per 10k ops | +|---|---:|---:|---:| +| Bare `HttpClient` | 960 B | — | 1.14 | +| Polly standard handler | 1,224 B | **264 B** | 1.45 | +| Resilion standard handler | 2,624 B | **1,664 B** | 3.13 | + +The headline "2.1× Polly" understates it. The marginal cost of the resilience layer — what the +library actually adds — is **6.3×** Polly's. A faint Gen1 collection appears on Resilion's retry +arm and none on Polly's. + +**The previous decision rule was broken. Do not use it.** + +It said: act if "Gen0 collections exceed 1 per 10,000 operations". Measured against a baseline, +that threshold fires on a **bare `HttpClient` with no resilience library at all** (1.14), and on +Polly (1.45). A criterion that condemns the control group measures nothing, and running the +benchmark under it would have produced a guaranteed "yes, redesign" carrying no information. + +**Corrected decision rule — relative to a control, not absolute.** Pursue the pre-composed +delegate chain only if, at ~1 ms simulated per-call latency: + +1. **Throughput:** Resilion's handler is **>= 3% slower** than Polly's on the same pipeline shape. + Today Resilion is *faster* at zero latency, so this asks whether allocation reverses that once + real work exists. +2. **GC pressure:** Resilion's Gen0 rate exceeds the **bare-client baseline** by more than **2×**. + Today it is 2.7× (3.13 vs 1.14), so this would currently trigger — and meaningfully, because + the baseline is subtracted out. +3. **Promotion:** any sustained Gen1 or Gen2 activity attributable to the pipeline. Per-call + closures should die in Gen0; promotion means something is surviving and is worth understanding + whatever the throughput numbers say. + +**Open question before writing the benchmark: should it be concurrent?** Every benchmark in this +repo is single-threaded, and allocation pressure is a concurrent phenomenon — one thread allocating +1.6 KB/ms is nothing, two hundred in-flight requests doing the same is ~320 MB/s. Single-threaded +measurement may be the reason this item looked harmless for as long as it did. + +**Note on scope.** The fix "requires complex generic type threading and may not be feasible without +sacrificing API simplicity" — in a library whose pitch is simplicity, that remains close to +disqualifying. The realistic outcome may be to accept the cost with the numbers documented, rather +than to redesign. Deciding that honestly still needs the benchmark above. --- -### 46. Timeout Cancellation TOCTOU Race Fix - -**Type:** Fix — Correctness (moved from [tradeoffs.md](tradeoffs.md)) - -**Why** - -`WasCancelledByTimeout` in `TimeoutStrategy.cs` checks `linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested`. Between the two reads, the user token could become cancelled, causing user cancellation to be misclassified as a timeout (`TimeoutRejectedException` instead of `OperationCanceledException`). The window is nanoseconds wide but it exists, and "we know about the race but chose not to fix it" is a bad look for a library claiming production readiness. - -**Fix** - -Capture the user token's cancellation state **before** awaiting the callback: +## P2 — Backlog -1. Read `bool userWasCancelledBefore = userToken.IsCancellationRequested` before the `await callback(context)` call. -2. After the callback throws `OperationCanceledException`, classify as timeout if: `linkedCts.IsCancellationRequested && !userWasCancelledBefore && !userToken.IsCancellationRequested`. -3. The pre-capture eliminates the race: if the user token was already cancelling before the call, it's always user cancellation. If it wasn't, and the linked CTS fired, it's a timeout. - -Alternative (even tighter): register a `CancellationTokenRegistration` on the user token that sets a `volatile bool _userCancelled` flag. Check that flag instead of re-reading the token. Dispose the registration in `finally`. This is ~5 lines and eliminates the race entirely. +### 45. Telemetry Enrichment -**Effort:** Low. The fix is a few lines in `TimeoutStrategy.cs`. +**Status:** Open +**Type:** Feature +**Target:** unscheduled +**Depends on:** #56 (span coverage must be complete first) +**Verified:** 2026-09-30 — `grep -n 'static readonly' src/Resilion/Telemetry/ResilionTelemetry.cs` +→ `Meter`, `ActivitySource` and all six counters are static; no per-pipeline hook exists. ---- +**Why** -### 52. No SourceLink, Symbol Packages, or Deterministic Builds +Polly supports `MeteringEnricher` (custom tags on all telemetry events), `TelemetryListeners` (raw +event listeners), and `SeverityProvider` (adjust/suppress severity). Resilion's telemetry is static +counters only. -**Type:** Infrastructure — Developer Experience +**Design** -**Why** +Enrichment needs per-pipeline state, but the `Meter` and `ActivitySource` are static. The channel +already exists: **`ResilienceContext`**, which every execute entry point already stamps with +`PipelineName`. Add `internal ResilionTelemetryOptions? Telemetry` to the context (cleared in +`Reset()`), a `public ResilionTelemetryOptions? TelemetryOptions` on `PipelineBuilderBase`, and thread +it through `Build()` into the `Pipeline` constructor — **including the two +`Name is not null ? … : Pipeline.Empty` early-return branches**, which otherwise silently drop the +options for empty pipelines. -Confirmed via grep of `src/Directory.Build.props`: zero instances of `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSymbols`, `SymbolPackageFormat`, or `ContinuousIntegrationBuild` properties anywhere in the build configuration. +Then replace every `counter.Add(…)` with an `internal static TelemetryEmitter.Emit(instrument, in resilienceEvent, context)` +that resolves severity, returns early on `None` (suppression), emits, and invokes listeners. -Consumers of the Resilion NuGet packages cannot step into Resilion's source code while debugging — SourceLink metadata is missing. No `.snupkg` symbol package is published. This is low-effort but high-trust: Polly, most Microsoft.Extensions libraries, and serious .NET OSS projects enable this by default. +**Preserving the zero-cost path is mandatory.** When `context.Telemetry` is null or carries no +enrichers, `Emit` must use the existing two-tag `counter.Add(1, tag1, tag2)` overload — +byte-for-byte the current behaviour. Only with enrichers present does it build a `TagList` (a struct +with inline storage for 8 tags, so even the enriched path stays allocation-free for typical tag +counts). [telemetry.md](telemetry.md)'s "zero-cost when unused" claim must remain true, and a +benchmark should prove it. -**Fix** +**The plumbing goes in core, not `Resilion.Extensions`** (an earlier draft of this entry said +otherwise). The emit sites are in core and core cannot depend on Extensions. Extensions adds only the +`ILogger` bridge, which means re-adding the `Microsoft.Extensions.Logging.Abstractions` reference +to `Resilion.Extensions`. It was removed before 1.0 rather than shipped dead: it had no +references in any source file, so it made consumers restore a package for nothing and implied a +logging integration that did not exist. -1. Add `Microsoft.SourceLink.GitHub` package reference to `src/Directory.Build.props`. -2. Add the following properties to `src/Directory.Build.props` (or per-project as needed): - - `true` - - `true` - - `true` - - `snupkg` -3. In the release CI job (`.github/workflows/`), set `true` only during the release build (not in local dev builds — deterministic builds interact poorly with incremental local builds). +**Be honest about severity:** core has no logger, so severity is only meaningful for suppression via +`None` and for user listeners. Say that rather than implying log-level integration. -**Impact:** Users can now debug into Resilion. Symbol packages enable IDE integration and offline symbol resolution. +**Hard prerequisite:** ship #56 first. Enrichment layered on top of partial span coverage gives users +enrichment that silently doesn't apply on typed pipelines — worse than no enrichment. -**Effort:** Low. These are standard MSBuild properties. No code changes required. +**Effort:** High. This is the most invasive item in the backlog: every emit site in every strategy. --- -## P3 — Backlog -### 9. WaitHandle Allocation in Sync Retry +### 67. Registry Caches a Failed Pipeline Build Until Invalidated -**Type:** Fix — Performance (minor) +**Status:** Open +**Type:** Improvement +**Target:** unscheduled +**Verified:** 2026-10-23 — a probe registering a factory that throws on its first two builds, then +resolving five times with invalidations in between. Builds 1–3 all rethrew *the same* exception +instance from build 1 (`builds` counter stayed at 1); only `InvalidatePipeline` re-ran the factory. **Why** -The sync retry path uses `context.CancellationToken.WaitHandle.WaitOne(delay)` for cancellation-aware sleep. Accessing `CancellationToken.WaitHandle` lazily allocates a `ManualResetEvent` that is never explicitly disposed. This happens on every retry delay in the sync path. - -**Potential Fix** +`ResiliencePipelineRegistry.GetPipeline` caches each pipeline in a `Lazy` built with the +default `LazyThreadSafetyMode.ExecutionAndPublication`, which **caches the exception** when the +factory throws. So a `configure` delegate that fails — bad values from a reload, a dependency that +was not ready at first resolve — leaves that key permanently broken: every later `GetPipeline` +rethrows the stored exception without re-running the factory. -Use `SpinWait` with periodic `IsCancellationRequested` checks for short delays, and `ManualResetEventSlim` with explicit disposal for longer delays. Or use `TimeProvider.CreateTimer` with a `ManualResetEventSlim` signal. +The lookup guard above the `Lazy` only covers an *unknown key*; it does nothing for a factory that +throws. -**Decision:** Low priority. The sync retry path is not the hot path in most applications. Revisit if sync usage proves significant. - ---- +With reload wired up this self-heals, because the next options change invalidates the key. Without +it, the key is broken for the process lifetime. The failure is also confusing to diagnose: the +exception's stack trace points at the first resolve, which may be long past. -### 10. Resilion.Testing Package - -**Type:** Feature — Test Infrastructure - -**Why** +**Current mitigation:** documented in [dynamic-reload.md](dynamic-reload.md), and +`InvalidatePipeline` returns `true` for a cached failure, so a caller can clear one deliberately. -Users testing code that uses Resilion need test doubles and assertion helpers. Currently `Microsoft.Extensions.TimeProvider.Testing.FakeTimeProvider` covers the primary scenario, but a dedicated package could provide: -- `TestPipeline` / `TestPipeline` that record all executions -- Circuit breaker state assertion helpers -- Deterministic event capture -- Pre-configured pipelines for common test scenarios +**Possible fixes** -**Decision:** Defer until real usage patterns emerge from production users. +1. **Do not cache the failure** — catch inside the `Lazy` factory, remove the entry from + `_pipelines`, rethrow. The next resolve then retries. Risk: a factory that always throws is now + re-run on every call, which under load turns one cached exception into a hot loop of failing + builds. +2. **`LazyThreadSafetyMode.PublicationOnly`**, which does not cache exceptions. Same retry-storm + risk, and it permits concurrent factory execution, so two callers could build two pipelines and + one gets discarded — unacceptable while a pipeline may own a circuit breaker. +3. **Keep the caching, add a documented escape hatch.** Current state, plus the `InvalidatePipeline` + route above. ---- +(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. -### 12. Hedging: HedgingDelayGenerator +### 40. Resilion.Chaos — Chaos Engineering -**Type:** Feature — Strategy Enhancement +**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. **Why** -The current `HedgingDelay` is a static `TimeSpan` — the same delay for every hedged attempt. Some scenarios benefit from dynamic delays (e.g., shorter delays for later attempts, or delays computed from recent latency percentiles). +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`. **Design** -Add `Func? HedgingDelayGenerator` to `HedgingStrategyOptions`. When set, `HedgingDelay` is ignored for that attempt. +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. -```csharp -public readonly record struct HedgingDelayGeneratorArgs(int AttemptNumber, ResilienceContext Context); -``` - -**Complexity:** Low. Thread the generator call into the delay logic in the for-loop. +**`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. ---- +**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`. -### 30. FallbackAction/ResilienceEventHandler Task.Run Missing CancellationToken +**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. -**Type:** Fix — Minor correctness +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. -**Why** +**Production safety:** with defaults of `Enabled = true` and a non-zero `InjectionRate`, lead the docs +with `EnabledGenerator = _ => !env.IsProduction()`. -Both `FallbackAction.Execute` and `ResilienceEventHandler.Invoke` call `Task.Run(() => ...).GetAwaiter().GetResult()` for sync-over-async without passing `CancellationToken` to `Task.Run`. The blocking call can't be interrupted by cancellation. +**Release note:** as with #39, `release-nuget.yml`'s hardcoded package count must be data-driven +first. -**Impact:** Low. Only affects sync path with async handlers/factories, and the underlying async operation would need to observe its own token anyway. +**Effort:** High. --- -### 43. PredicateBuilder<T> Fluent API +### 11. Hedging: O(N²) `Task.WhenAny` Continuation Registration -**Type:** Feature — API Ergonomics +**Status:** Open +**Type:** Improvement +**Target:** unscheduled +**Depends on:** #54 (the clean fix is net9.0+ only) +**Verified:** 2026-09-30 — `grep -n 'Task.WhenAny\|Task.WhenEach' src/Resilion/Hedging/HedgingStrategy.cs` +→ `WhenAny` at `:69`, `:70`, `:143`, `:333`; no `WhenEach`. **Why** -Polly provides `new PredicateBuilder().Handle().HandleResult(r => r.StatusCode == 500)` with implicit conversion to `Func, bool>`. Resilion requires writing the full predicate lambda manually. For simple cases this is fine, but complex predicates get verbose. +This entry was previously titled "memory leak". **That overstates it.** `WaitForBestOutcome` +(`:322-347`) calls `Task.WhenAny(remaining)` once per iteration, so it registers O(N²) continuations +where N = `MaxHedgedAttempts`, and continuations from earlier iterations attached to still-pending +tasks are never deregistered. But N is a small user-chosen constant (typically 2–3), the cleanup in +`finally` awaits all tasks, and it only "leaks" if an attempt never completes — in which case the +attempt's own `Task` is already leaked and the continuation is the least of the problem. -**Fix:** Add a `PredicateBuilder` class with `.Handle()`, `.HandleResult(Func)`, and implicit conversion to `Func, bool>`. Low priority — the current lambda approach works and is more explicit. +**Fix** ---- +`Task.WhenEach` is the clean answer, and it is **net9.0+ only** — hence the dependency on #54's +multi-targeting decision. -### 44. IConfiguration Binding for Strategy Options +**Do not hand-roll a net8-compatible equivalent.** A `ContinueWith` + `SemaphoreSlim` + +`ConcurrentQueue` implementation trades a bounded O(N²) continuation registration for *more* +allocations at the N this code actually runs at. Net negative. That analysis is recorded here so the +next reader doesn't redo it. -**Type:** Feature +**Do not "simplify" the other `WhenAny` sites.** `:69-70` is the hedging-delay race and is correct as +written — the comment at `:74-77` explains why it deliberately awaits the stored task rather than +calling `WhenAny` twice. `:143` is the cleanup timeout. -**Why** - -Enable binding strategy options from `appsettings.json` so timeout durations, retry counts, and other parameters can be changed without recompiling. Requires #41 (dynamic reload) to be useful — without reload, config changes require app restart. +**Effort:** Medium, and gated. --- -### 45. Telemetry Enrichment +### 5. CancellationTokenSource Pooling for Timeout -**Type:** Feature +**Status:** Open +**Type:** Improvement +**Target:** unscheduled +**Verified:** 2026-09-30 — `grep -n 'CreateLinkedTokenSource\|CreateTimer' src/Resilion/Timeout/TimeoutStrategy.cs` +→ CTS at `:41`, timer at `:48-63`, one of each per execution. **Why** -Polly supports `MeteringEnricher` (custom tags on all telemetry events), `TelemetryListeners` (raw event listeners for custom consumers), and `SeverityProvider` (adjust/suppress event severity). Resilion's current telemetry is static counters only. +The Timeout strategy allocates one `CancellationTokenSource` per execution. +`CancellationTokenSource.TryReset()` (since .NET 6) enables pooling — reset and reuse instead of +allocating. -**Fix:** Add extensibility points in `Resilion.Extensions` for custom meter tags and raw event subscription. Low priority unless enterprise users need it. - ---- +**The missing measurement** -### 47. CI Code Coverage Collection - -**Type:** Infrastructure +There is no isolated timeout benchmark at all. Add a `TimeoutAllocationBenchmarks` with (a) `AddTimeout` +alone on the happy path where the timeout never fires, and (b) the same where it **does** fire — the +case where `TryReset()` returns `false` and pooling yields nothing. -**Why** +**Decision rule:** implement pooling only if the CTS + `ITimer` pair is **> 25%** of total bytes/op for +a timeout-only pipeline **and** pooling removes **>= 20%** of bytes/op with no wall-clock regression. -The test projects target `Microsoft.Testing.Platform` (MTP) via `xunit.v3`, not classic VSTest. `coverlet.collector` — currently referenced in `tests/Directory.Build.props` — is a VSTest data collector, so `dotnet test --collect:"XPlat Code Coverage"` is silently ignored under MTP: verified locally, it produces zero output and no error. The MTP-native replacement, `Microsoft.Testing.Extensions.CodeCoverage`, does work in isolation (`dotnet test -- --coverage --coverage-output-format cobertura` produces a real `.cobertura.xml`) — but adding it as a shared package reference (`tests/Directory.Build.props`) broke `dotnet build`/`dotnet restore` entirely on this machine's installed SDK (10.0.100, resolved via `global.json`'s `rollForward: latestMajor`) with `NETSDK1013: The TargetFramework value '' was not recognized` — reproduced twice, and confirmed the package is the cause by reverting it and rebuilding successfully. This looks like a version incompatibility between `Microsoft.Testing.Extensions.CodeCoverage` 17.14.2 and the newest installed SDK, not a problem with the approach itself. +**The ceiling is lower than this issue implies.** `_timeProvider.CreateTimer(…)` also allocates an +`ITimer` per execution, so pooling only the CTS caps the upside at roughly half. The larger lever — +`CancellationTokenSource.CancelAfter`, which rides the shared timer queue instead of allocating — +**breaks `FakeTimeProvider` testability**, and `builder.TimeProvider` is the repo's single +time-injection seam. That is a hard no. So if this is done at all it must pool the **pair** behind one +policy, which is materially more complex than a "Medium" estimate and carries the `TryReset()` +timer-state edge cases noted below. -**Fix** +Note that #46 *adds* one small allocation to this path, so re-baseline before measuring. -Retry with a newer `Microsoft.Testing.Extensions.CodeCoverage` version once one compatible with the SDK(s) this repo actually builds against is confirmed (test in isolation on a throwaway branch before merging into the shared `Directory.Build.props`, since a bad version breaks every project that imports it, not just the one being tested). `coverlet.collector` can then be removed as dead weight. Once collection works, wire `--coverage` into `.github/workflows/test.yml`, upload the `.cobertura.xml` as a build artifact, and only then consider a coverage badge — a badge without a live report behind it is an unverified claim. +**Complexity:** Medium-to-high. `TryReset()` has edge cases around timer state and concurrent +cancellation. -**Decision:** Left `coverlet.collector` in place for now (harmless no-op under MTP, at least doesn't break the build) rather than leave the tree in the broken state discovered above. +**Effort:** Medium. --- +## P3 — Blocked / needs external change + ### 48. Benchmark CI on PRs +**Status:** Open **Type:** Infrastructure +**Target:** unscheduled +**Verified:** 2026-09-30 — `ls .github/workflows/` → `ci.yml`, `release-nuget.yml`, `test.yml`; no +benchmark workflow. **Why** -`benchmarks/results/README.md` currently only gets updated by someone manually running the suite and committing the numbers. Nothing catches a PR that accidentally regresses performance between those manual runs. - -**Design** - -A GitHub Actions workflow that runs `dotnet run -c Release --project benchmarks/Resilion.Benchmarks -- --job short` on PRs touching `src/` or `benchmarks/`, and compares the result against a committed baseline (fail or comment on a regression past some threshold). Not critical for v1.0, but valuable for ongoing development once the library has enough usage that regressions would actually be noticed by users first. - ---- - -### 53. No SECURITY.md +[benchmarks/results/README.md](../benchmarks/results/README.md) is only updated when someone manually +runs the suite and commits the numbers. Nothing catches a PR that regresses performance between those +runs. -**Type:** Documentation +**Design — the constraint determines everything.** GitHub-hosted runners are far too noisy, and +differ from whoever committed the current numbers, for a committed-baseline comparison to mean +anything. **So compare PR-vs-merge-base within the same job on the same runner:** check out the +merge-base, run the suite, check out the PR head, run the same suite, diff. Two benchmark runs per PR +(~+10–20 min with `--job short`). A cross-machine comparison against committed numbers will produce +false alarms until people stop reading the output. -**Why** +Compare with a `jq` step over the two `*-report-full.json` files — no extra tooling. Flag a benchmark +when `Statistics.Mean` regressed **> 10%** **and** the PR mean falls outside the base run's +`[Mean − 2·StandardError, Mean + 2·StandardError]`; or when `BytesAllocatedPerOperation` increased **at +all** for a benchmark currently at 0 B, or **> 15%** otherwise. -No vulnerability disclosure policy exists (confirmed: `SECURITY.md` not found in repo root). This is a standard, low-effort expectation for OSS infrastructure libraries — it tells users how to privately report security issues instead of disclosing them publicly on GitHub. +**Rollout:** `continue-on-error: true` plus a sticky PR comment. **Do not fail the build initially.** A +benchmark job that fails spuriously gets ignored, then disabled, then deleted — worse than not having +it. Start with `workflow_dispatch` plus nightly-against-master (cheap, catches drift, zero PR latency) +and add the `pull_request` trigger only once the suite has proven stable. -**Fix** +**Honest priority:** the lowest-value item in this file for a library at 1.0 with no users yet, and two +benchmark runs per PR is a real recurring cost. -Add a `SECURITY.md` in the repository root documenting: -- **Supported versions:** Which versions of Resilion receive security updates (typically N and N-1). -- **Reporting:** Link to GitHub's private security advisory feature (Settings → Security & Analysis → Private vulnerability reporting). Include a brief note that security reports should be filed there instead of public issues. -- **Response SLA:** Expected timeline for acknowledgement and patch release (e.g., "critical issues patched within 7 days"). - -This is boilerplate text; most large OSS projects have near-identical policies. Low effort, high trust signal. - -**Effort:** Low. One short document. +**Effort:** Low. --- -### 54. Evaluate Multi-Targeting net9.0 for System.Threading.Lock +### 47. CI Code Coverage Collection -**Type:** Improvement — Performance +**Status:** Blocked +**Type:** Infrastructure +**Target:** unscheduled +**Verified:** 2026-09-30 — `grep -n 'coverlet\|CodeCoverage' tests/Directory.Build.props` +→ `coverlet.collector 10.0.1` only. **Why** -`src/Directory.Build.props` targets `net8.0` only (confirmed). .NET 9 introduced `System.Threading.Lock`, a purpose-built synchronization primitive faster than `lock(object)` under contention, especially in hot-path code with many concurrent waiters. - -`CircuitBreakerStateMachine` and `SlidingWindow` both hold a lock on every call while in the Closed state — the most common state in healthy systems. If benchmarks show `System.Threading.Lock` provides a measurable win in that scenario, multi-targeting is justified. - -**Fix** - -1. **Benchmark first:** Run the existing circuit-breaker-under-load benchmarks with both `lock(object)` and `System.Threading.Lock` implementations in isolation. Only proceed if the new lock type shows >5% wall-clock improvement under the standard load profile. +The test projects target `Microsoft.Testing.Platform` (MTP) via `xunit.v3`, not classic VSTest. +`coverlet.collector` — currently referenced in `tests/Directory.Build.props` — is a VSTest data +collector, so `dotnet test --collect:"XPlat Code Coverage"` is silently ignored under MTP: verified +locally, it produces zero output and no error. The MTP-native replacement, +`Microsoft.Testing.Extensions.CodeCoverage`, does work in isolation +(`dotnet test -- --coverage --coverage-output-format cobertura` produces a real +`.cobertura.xml`) — but adding it as a shared package reference in `tests/Directory.Build.props` broke +`dotnet build`/`dotnet restore` entirely on this machine's installed SDK (10.0.100, resolved via +`global.json`'s `rollForward: latestMajor`) with +`NETSDK1013: The TargetFramework value '' was not recognized` — **reproduced twice**, and confirmed the +package is the cause by reverting it and rebuilding successfully. This looks like a version +incompatibility between `Microsoft.Testing.Extensions.CodeCoverage` 17.14.2 and the newest installed +SDK, not a problem with the approach. + +**Fix — try the tool-based route first; it has no project-file blast radius at all:** + +```bash +dotnet tool install -g dotnet-coverage +dotnet-coverage collect --output-format cobertura --output coverage.cobertura.xml "dotnet test -c Release" +``` -2. **If justified:** Multi-target `net8.0;net9.0` in `src/Directory.Build.props`. Add conditional compilation in `CircuitBreakerStateMachine.cs` and `SlidingWindow.cs`: - ```csharp - #if NET9_0_OR_GREATER - private System.Threading.Lock _lock = new(); - #else - private readonly object _lock = new(); - #endif - ``` - Both APIs are compatible with the same lock syntax; only the initialization changes. +`dotnet-coverage` attaches to the test host process and is agnostic to VSTest vs MTP. If this produces +a non-empty report, this item is solved without touching a single project file. + +**If the package route is still needed, test it in isolation:** + +1. Throwaway branch, never merged as-is. +2. **Do not edit `tests/Directory.Build.props`.** Add the reference to exactly one project file, so a + bad version cannot break the other project's restore. **This is the specific mistake that produced + the previous failure.** +3. `dotnet package search Microsoft.Testing.Extensions.CodeCoverage --take 20`. **Pin an exact + version**; never a floating range. +4. **Remove the SDK variable from the experiment.** Test each supported SDK explicitly, without + editing the shared `global.json` — run with `DOTNET_ROLL_FORWARD=disable` against an explicitly + installed 8.0.404, then 9.0.x, then the newest installed, confirming `dotnet --version` each time. +5. **Success criteria, all four, on all three SDKs:** restore succeeds; `dotnet build -c Release` + succeeds; the coverage run emits a `.cobertura.xml` with **`line-rate > 0`** — not merely a + non-empty file, since the coverlet no-op produced no error either; and a whole-solution build still + succeeds. +6. Only then move the reference into `tests/Directory.Build.props`, remove `coverlet.collector` as dead + weight, and add a `coverage` job to `test.yml` uploading the report as an artifact. **No badge until + a published report exists** — a badge without a live report behind it is an unverified claim. +7. **If it fails again**, append the exact version, SDK and error here rather than leaving a generic + "blocked", so the next attempt doesn't repeat a known-bad combination. + +**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. -3. **Don't multi-target speculatively.** The cost is a more complex build matrix and CI test surface. Only add net9.0 if the benchmark proves it matters. +--- -**Effort:** Medium. Primarily benchmarking time. The conditional code change is trivial. +## Retired items + +Numbers that have left the active list. **Never reuse a number.** + +| # | Title | Disposition | +|---|-------|-------------| +| 49 | Telemetry counters carry no dimensions/tags | Implemented — see CHANGELOG `[1.0.0-pre]`. Residual gap filed as #55. | +| 50 | ActivitySource declared but never used | Partially implemented — untyped async+sync paths only, see CHANGELOG `[1.0.0-pre]`. Residual gap filed as #56. | +| 51 | No public API surface tracking | Package wired up only; the files were not in analyzer format and nothing enforced the rules. See CHANGELOG `[1.0.0-pre]`. Actual enforcement filed as #57. | +| 46 | Timeout cancellation TOCTOU race | Fixed — atomic first-cause recording. See CHANGELOG `[Unreleased]`. | +| 52 | No SourceLink, symbol packages, or deterministic builds | Done — SourceLink, .snupkg and deterministic CI builds. See CHANGELOG `[Unreleased]`. | +| 53 | No SECURITY.md | Done — `SECURITY.md` added. Requires Private Vulnerability Reporting to stay enabled. | +| 55 | Hedging sync path never increments hedging.attempts | Fixed. See CHANGELOG `[Unreleased]`. | +| 56 | Typed strategies emit no ActivitySource spans | Fixed — spans extracted to `StrategyActivity` and applied to all entry points. | +| 57 | PublicAPI files not in analyzer format; no CI enforcement | Fixed — files regenerated, RS0016/RS0017 are errors in CI. | +| 58 | telemetry.md documents span tags that don't exist | Fixed — documented tag set now matches the code. | +| 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). | +| 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]`. | +| 12 | Hedging: HedgingDelayGenerator | Implemented. See CHANGELOG `[Unreleased]`. | +| 41 | Pipeline dynamic reload via IOptionsMonitor | Implemented. See CHANGELOG `[Unreleased]`. | +| 42 | Keyed services support | Implemented — always on, with idempotent `Pipeline.Dispose` as its prerequisite. | +| 43 | PredicateBuilder fluent API | Implemented — generic and non-generic. See CHANGELOG `[Unreleased]`. | --- -See also: [tradeoffs.md](tradeoffs.md) — accepted design imperfections with reasoning for why they won't be fixed. +See also: [tradeoffs.md](tradeoffs.md) — accepted design imperfections with reasoning for why they +won't be fixed, and [verification.md](verification.md) — the commands that back every factual claim in +this file. diff --git a/docs/http.md b/docs/http.md new file mode 100644 index 0000000..388aa02 --- /dev/null +++ b/docs/http.md @@ -0,0 +1,206 @@ +# HttpClient Integration + +`Resilion.Http` wires a Resilion pipeline into `HttpClient` through `IHttpClientFactory`. + +```bash +dotnet add package Resilion.Http +``` + +## Quick start + +```csharp +services.AddHttpClient("api").AddStandardResilienceHandler(); +``` + +That gives the client, outermost first: + +| | Strategy | Default | +|---|---|---| +| 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 | +| 4 | Circuit breaker | 10% failure ratio over 30s, minimum throughput 100, break 5s | +| 5 | Per-attempt timeout | 10 seconds | + +Retry and the circuit breaker treat a call as failed when it throws `HttpRequestException` or +`TimeoutRejectedException`, or returns 5xx, 408, or 429. + +## Adjusting the defaults + +Use a `with` expression to change one thing, rather than replacing the record: + +```csharp +services.AddHttpClient("api").AddStandardResilienceHandler(o => +{ + o.Retry = o.Retry with { MaxRetryAttempts = 5 }; + o.AttemptTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(3) }; +}); +``` + +Replacing a record wholesale is also safe. If you leave `ShouldHandle` null, the HTTP transient +predicate is substituted when the pipeline is built, so this still retries 5xx: + +```csharp +o.Retry = new RetryStrategyOptions { MaxRetryAttempts = 5 }; +``` + +Set `ShouldHandle` explicitly to override that — including to handle exceptions only. + +There is an overload taking the service provider, for pulling configuration or loggers in: + +```csharp +services.AddHttpClient("api").AddStandardResilienceHandler((o, sp) => + o.Retry = o.Retry with { MaxRetryAttempts = sp.GetRequiredService>().Value.Retries }); +``` + +That provider is the **root** one. Resolving a scoped service from it is a captive dependency; +resolve singletons only. + +### Validation + +`AttemptTimeout` longer than `TotalRequestTimeout` throws — it could never fire. Options are +validated when the pipeline is first built, which happens when `IHttpClientFactory` constructs the +handler chain, so a misconfiguration surfaces at `CreateClient` rather than on the first send. + +## Rate limiting + +Off by default, which differs from `Microsoft.Extensions.Http.Resilience` 8.x (it enabled a +1000-permit concurrency limiter; 9.x removed it again). + +A `RateLimiter`'s lifetime belongs to whoever created it — the pipeline never disposes one — so +enabling it by default would mean this package silently owning a limiter and throttling any +application above its permit count. Opt in and keep ownership: + +```csharp +services.AddSingleton(new ConcurrencyLimiter(new ConcurrencyLimiterOptions +{ + PermitLimit = 1000, + QueueLimit = 0, +})); + +services.AddHttpClient("api").AddStandardResilienceHandler((o, sp) => + o.RateLimiter = new RateLimiterStrategyOptions + { + RateLimiter = sp.GetRequiredService(), + }); +``` + +## Hedging + +For latency-sensitive calls to **idempotent** endpoints. Replaces retry with hedging: if the first +attempt has not answered within the delay, a second is issued and the first answer wins. + +```csharp +services.AddHttpClient("api").AddStandardHedgingHandler(o => + o.Hedging = o.Hedging with { MaxHedgedAttempts = 3, HedgingDelay = TimeSpan.FromSeconds(1) }); +``` + +`HedgingDelay` selects the mode: `TimeSpan.Zero` fires every attempt at once, a positive value +waits that long unless an earlier attempt finishes first, and `Timeout.InfiniteTimeSpan` runs them +strictly in sequence. + +Two limits: every attempt targets the same URI — per-endpoint routing is not supported — and this +handler **always clones the request**, so it always buffers the body in memory. Don't use it for +large or streaming uploads. + +## Custom pipelines + +```csharp +services.AddHttpClient("api").AddResilienceHandler("my-pipeline", pb => pb + .AddTimeout(TimeSpan.FromSeconds(20)) + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + ShouldHandle = new PredicateBuilder() + .Handle() + .HandleResult(r => r.StatusCode == HttpStatusCode.ServiceUnavailable), + })); +``` + +Several handlers can be added to one client under different keys; they nest in registration order, +outermost first. Registering the same key twice on the same client throws at registration time. + +## Request replay + +Controls whether each attempt sends your request or a copy. + +| Mode | Behaviour | Use when | +|---|---|---| +| `ReuseRequest` *(default)* | Every attempt sends your original instance. No buffering, no overhead. | Content is replayable, or there is none | +| `CloneRequest` | Body is buffered once; every attempt gets an independent clone. | Content is not replayable, or an inner handler mutates the request | + +Replayable content means `StringContent`, `ByteArrayContent`, `JsonContent`, +`FormUrlEncodedContent`, or no content. `StreamContent` over a non-seekable stream **cannot** be +sent twice, and an inner handler that *adds* rather than sets a header — some auth handlers do — +throws on the second attempt. Either case needs `CloneRequest`: + +```csharp +services.AddHttpClient("api") + .AddStandardResilienceHandler(o => o.RequestReplay = HttpRequestReplay.CloneRequest); +``` + +`CloneRequest` buffers the **entire** body in memory. The hedging handler always clones, because +concurrent attempts cannot share one `HttpRequestMessage`. + +## Response disposal + +Responses from attempts that lose — a retried 500, a hedged straggler — are disposed for you, as +soon as the next attempt starts where attempts cannot overlap. An undisposed response holds its +connection, so without this a retry-heavy client exhausts the pool. + +The response you receive is yours; dispose it as usual. + +## What to catch + +Resilion's exceptions propagate unchanged, matching Polly's handler. They are **not** translated +into `HttpRequestException` or `TaskCanceledException`: + +| Exception | Means | +|---|---| +| `TimeoutRejectedException` | An attempt, or the whole operation, exceeded its timeout | +| `CircuitBrokenException` | The circuit is open; the call was rejected without being sent | +| `RateLimitRejectedException` | The rate limiter rejected the call | +| `HttpRequestException` | A transport failure that survived all retries | + +A 5xx that survives every retry is **returned**, not thrown — it is a response. + +Generated clients that only catch `HttpRequestException` will see types they do not expect. + +## Telemetry + +Metrics and spans carry `pipeline.name = "/"` (for example `api/standard`) and +`operation.key` set to the HTTP method. The method, not the URI — a URI would make the tag's +cardinality unbounded. See [telemetry.md](telemetry.md). + +From a strategy callback, `context.GetRequestMessage()` returns the current attempt's request. + +## Testing + +Register a `TimeProvider` and the whole pipeline uses it: + +```csharp +var time = new FakeTimeProvider(); +services.AddSingleton(time); +services.AddHttpClient("api") + .ConfigurePrimaryHttpMessageHandler(() => stubHandler) + .AddStandardResilienceHandler(); +``` + +Setting `o.TimeProvider` explicitly wins over the container. See [testing.md](testing.md). + +## Handler ordering + +`AddHttpMessageHandler` composes outermost-first. A handler that must run **per attempt** — token +refresh, per-try logging — goes *after* the resilience handler; one that must run **once** goes +before it. + +## Known limitations + +- **No `Retry-After` support.** 429 and 503 are retried on blind exponential backoff, ignoring any + `Retry-After` header. `RetryDelay.Custom` only receives the attempt number, so this needs a core + change — see [future-plans.md](future-plans.md). +- **No synchronous `HttpClient.Send`.** It throws `NotSupportedException`. Silently bypassing the + pipeline would be worse, and a correct sync path needs a synchronous outcome-returning execute + in core. +- **No per-endpoint hedging.** All hedged attempts target the same URI. +- **No per-request configuration.** The pipeline is built once per client and cached. diff --git a/docs/installation.md b/docs/installation.md index fb8f56f..1089721 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -20,6 +20,7 @@ This gives you retry, circuit breaker, timeout, fallback, and hedging — zero e |---------|----------------| | `Resilion` | Always — core library with all built-in strategies, zero dependencies | | `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 | > **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. @@ -29,14 +30,27 @@ This gives you retry, circuit breaker, timeout, fallback, and hedging — zero e ## Dependency graph ```text -Resilion (zero dependencies) +Resilion (zero dependencies) ↑ - ├── Resilion.Extensions (+ Microsoft.Extensions.*) + ├── Resilion.RateLimiting + System.Threading.RateLimiting │ - └── Resilion.RateLimiting (+ System.Threading.RateLimiting) + ├── Resilion.Extensions + Microsoft.Extensions.DependencyInjection.Abstractions + │ + Microsoft.Extensions.Options + │ + └── Resilion.Http + Microsoft.Extensions.Http + + Resilion.Extensions, Resilion.RateLimiting ``` -`Resilion.Extensions` and `Resilion.RateLimiting` both reference `Resilion` — NuGet pulls it in transitively. +Every satellite package references `Resilion` — NuGet pulls it in transitively, so installing one +is enough. + +`Resilion.Http` depends on `Resilion.RateLimiting` even though **its rate limiter is off by +default**, because `HttpStandardResilienceOptions.RateLimiter` is typed +`RateLimiterStrategyOptions?` and that type has to be resolvable to use the options at all. You +pay one small package reference; you do not get a rate limiter unless you configure one. + +There is no `ILogger` dependency anywhere. Resilion emits metrics and spans from the core package, +and anything you want logged goes through a callback you write, so your logger stays yours. --- diff --git a/docs/migration-from-polly.md b/docs/migration-from-polly.md index fc98edf..5a88859 100644 --- a/docs/migration-from-polly.md +++ b/docs/migration-from-polly.md @@ -12,10 +12,11 @@ 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` | Accepts a plain sync `Action` *or* an async `Func` via implicit conversion — no forced `ValueTask` wrapping for the common synchronous case (logging, counters). | +| `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. | | `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. | -| `AddResilienceEnricher()` / `Microsoft.Extensions.Http.Resilience` | *(not yet available)* | See "Not yet available" below. | +| `Microsoft.Extensions.Http.Resilience` | `Resilion.Http` | `AddStandardResilienceHandler()`, `AddStandardHedgingHandler()` and `AddResilienceHandler(key, ...)` on `IHttpClientBuilder`, under the same names. See [HTTP](#http-migrating-from-microsoftextensionshttpresilience) below. | +| `AddResilienceEnricher()` | *(not yet available)* | See "Not yet available" below. | | Sync via `.Execute(...)` (sync-over-async under the hood for some strategies) | `Execute(...)` (true sync — `Thread.Sleep`/`WaitHandle`, no `Task` wrapping) | See `docs/architecture.md`'s "Sync vs async" section. | ## Before/after: the five most common patterns @@ -135,11 +136,113 @@ 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 sync `Action` directly.** `ResilienceEventHandler`'s implicit conversions mean a synchronous callback (the 90% case) doesn't pay for `ValueTask` wrapping. Async callbacks still work when genuinely needed. +- **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(...)`: + + ```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(); + ``` + + ```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, + })); + ``` + + 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. - **`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. +## HTTP: migrating from `Microsoft.Extensions.Http.Resilience` + +`Resilion.Http` covers the same ground, under the same method names, on the same +`IHttpClientBuilder`. Most registrations port across as-is; the way you *configure* them does not. +See [http.md](http.md) for the package's own documentation. + +```bash +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. | +| `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. | +| `HttpStandardHedgingOptions.Hedging` | `HttpStandardHedgingOptions.Hedging` | A `HedgingStrategyOptions` record. | +| `HttpClientResiliencePredicates.IsTransientHttpFailure` | `HttpResiliencePredicates.IsTransient` | Same definition — 5xx, 408, 429, `HttpRequestException`, `TimeoutRejectedException`. `IsTransientStatusCode` and `IsTransientException` are exposed separately. | +| `ResilienceHandlerContext.ServiceProvider` | the `(options, IServiceProvider)` overload | `AddStandardResilienceHandler((o, sp) => ...)`, `AddStandardHedgingHandler((o, sp) => ...)` and `AddResilienceHandler(key, (pb, sp) => ...)` all exist. That provider is the **root** one, so resolve singletons only — a scoped service taken from it is a captive dependency. | + +### The one that will not compile: `with`, not mutation + +This is the change most likely to stop a migration dead. Polly's leg options are mutable classes, +so you set properties on them in place. Resilion's are `record` types with `init` properties, so +you replace the record: + +```csharp +// Polly +services.AddHttpClient("api").AddStandardResilienceHandler(o => +{ + o.Retry.MaxRetryAttempts = 5; + o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3); +}); +``` + +```csharp +// Resilion +services.AddHttpClient("api").AddStandardResilienceHandler(o => +{ + o.Retry = o.Retry with { MaxRetryAttempts = 5 }; + o.AttemptTimeout = o.AttemptTimeout with { Timeout = TimeSpan.FromSeconds(3) }; +}); +``` + +Replacing a leg wholesale is safe too. A null `ShouldHandle` has +`HttpResiliencePredicates.IsTransient` substituted when the pipeline is built, so this still +retries 5xx rather than silently dropping to the exception-only default: + +```csharp +o.Retry = new RetryStrategyOptions { MaxRetryAttempts = 5 }; +``` + +Set `ShouldHandle` explicitly to override that — including to handle exceptions only. + +### Differences that change behaviour + +- **The rate limiter is off by default.** `Microsoft.Extensions.Http.Resilience` 8.x enabled a + 1000-permit concurrency limiter; 9.x removed it again. Resilion never enables one, because a + `RateLimiter`'s lifetime belongs to whoever created it and the pipeline never disposes one. If + you were relying on the 8.x default, register a limiter and opt in — see + [http.md](http.md#rate-limiting). +- **`Retry-After` is not honoured.** Both libraries retry 429 and 503 by default, but Resilion + retries them on blind exponential backoff and ignores the header. `RetryDelay.Custom` receives + only the attempt number, so closing this needs a core change — [future-plans.md](future-plans.md) + #63. Until then, handle 429 yourself if the server publishes a retry window you care about. This + is the gap most likely to matter. +- **Synchronous `HttpClient.Send` throws `NotSupportedException`.** Polly's handler supports it. + Silently bypassing the pipeline would be worse than failing, so Resilion fails. +- **No per-endpoint hedging routing.** Polly's `AddStandardHedgingHandler()` returns a builder that + can route attempts to different authorities. Every Resilion hedged attempt targets the same URI, + and the hedging handler always clones the request, so it always buffers the body. +- **No `IConfiguration` binding.** Polly binds `HttpStandardResilienceOptions` from configuration; + Resilion configures in code only. The `(o, sp)` overload is the workaround — read your own + options from the provider and assign. + +Exceptions behave the same in both: `TimeoutRejectedException`, `CircuitBrokenException` and +`RateLimitRejectedException` propagate unchanged rather than being wrapped in +`HttpRequestException`, and a 5xx that survives every retry is returned, not thrown. + ## Not yet available Resilion is younger than Polly and doesn't yet have feature parity in every area. If you depend @@ -147,12 +250,12 @@ on one of these today, Polly remains the better fit until Resilion catches up: | Feature | Status | |---------|--------| -| `IHttpClientFactory` integration (`AddStandardResilienceHandler()`) | Planned — `Resilion.Http`, highest priority post-v1.0 | | Chaos engineering (Simmy equivalent) | Planned — `Resilion.Chaos` | -| Dynamic reload via `IOptionsMonitor` | Planned | -| Dedicated testing package (`Resilion.Testing`) | Planned | -| `PredicateBuilder` fluent API | Planned, lower priority | +| Dedicated testing package (`Resilion.Testing`) | Deferred — `CircuitBreakerStateProvider` and a public `Pipeline.Name` shipped instead | | `IConfiguration` binding for strategy options | Planned, lower priority | +| `Retry-After` support on HTTP retries | Planned — future-plans.md #63 | +| Synchronous `HttpClient.Send` through the pipeline | Unscheduled — needs future-plans.md #66 | +| Per-endpoint hedging routing | Unscheduled | | Telemetry enrichment (`MeteringEnricher`, raw listeners) | Planned, lower priority | See [future-plans.md](future-plans.md) for the full roadmap and [comparison-with-polly.md](comparison-with-polly.md) for an honest side-by-side on where each library is stronger today. diff --git a/docs/telemetry.md b/docs/telemetry.md index 36703ff..99ce0d3 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -19,11 +19,13 @@ All metrics are on the `"Resilion"` meter. All counters are tagged with context about the operation: +Counters carry exactly two tags. Which strategy emitted the measurement is identified by the +instrument name itself (`resilion.retry.attempts`, `resilion.timeout.expirations`, …), not by a tag. + | Tag | Example | Notes | |-----|---------|-------| -| `pipeline.name` | `"http-api"` | Name passed to `AddResiliencePipeline()` or pipeline name in registry | -| `operation.key` | `"GET /users"` | Custom operation key from `ResilienceContext` | -| `strategy` | `"retry"` | Strategy type (implicit in metric name) | +| `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 | +| `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: ``` @@ -32,9 +34,14 @@ resilion.retry.attempts{pipeline.name="http-api", operation.key="GET /users"} = This allows dashboards and alerting rules to group by pipeline and operation. +Counters are emitted identically on the asynchronous and synchronous paths, and strategies nested +inside a hedging strategy carry the enclosing pipeline's name. Both are pinned by tests +(`HedgingAttempts_SyncAndAsyncAgreeOnCount`, `NestedStrategyInsideHedging_EmitsPipelineNameTag`), +because both were previously broken in ways that were invisible until someone read a dashboard. + ## ActivitySource spans -Resilion also emits OpenTelemetry `Activity` spans for distributed tracing. Each strategy execution creates a span named after the strategy. +Resilion also emits OpenTelemetry `Activity` spans for distributed tracing, named after the strategy. ### Subscribing to ActivitySource @@ -56,18 +63,31 @@ ActivitySource.AddActivityListener(listener); ### Span names and tags -Each strategy creates a span with contextual information: +A span carries exactly four tags. These are the only keys the code sets — there is no `attempt` +tag and no `duration_ms` tag (use the span's own start/stop timestamps for duration). ``` -Activity.OperationName = "Retry" // or Timeout, CircuitBreaker, etc. +Activity.OperationName = "Retry" // or Timeout, Fallback, CircuitBreaker, RateLimiter, Hedging Activity.Tags: - - "strategy" → "retry" - - "pipeline.name" → "http-api" - - "operation.key" → "GET /users" - - "attempt" → "1" (for retryable strategies) - - "duration_ms" → "150" (execution time) + - "strategy.name" → "Retry" + - "pipeline.name" → "http-api" // null for unnamed pipelines + - "operation.key" → "GET /users" // null unless set on ResilienceContext + - "outcome" → "retry_exhausted" ``` +`outcome` is set when the strategy finishes, and its vocabulary is strategy-specific: + +| Value | Emitted by | +|-------|------------| +| `success` | every strategy, when the wrapped call succeeded | +| `failure` | every strategy, when the wrapped call returned a handled failure | +| `exception` | circuit breaker, when the call threw | +| `timeout` / `no_timeout` | timeout | +| `rejected` | circuit breaker (open circuit), rate limiter | +| `retry_exhausted` | retry, when all attempts were used | +| `fallback_applied` | fallback, when the fallback value was returned | +| `hedging_exhausted` | hedging, when no attempt succeeded | + ## Subscribing ### dotnet-counters (CLI) diff --git a/docs/testing.md b/docs/testing.md index 8547f30..309eab4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -76,7 +76,36 @@ public async Task Retries_Three_Times_Then_Succeeds() ## Testing circuit breaker state -Trip the circuit and verify rejection: +Assert on the state directly with `CircuitBreakerStateProvider`: + +```csharp +[Fact] +public async Task Circuit_Opens_After_Failures() +{ + var state = new CircuitBreakerStateProvider(); + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + StateProvider = state, + })); + + Assert.Equal(CircuitState.Closed, state.State); + + for (var i = 0; i < 2; i++) + { + await Assert.ThrowsAsync(async () => + await pipeline.ExecuteAsync(ct => throw new InvalidOperationException())); + } + + Assert.Equal(CircuitState.Open, state.State); +} +``` + +This is a test-only convenience for *observing* 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: ```csharp [Fact] diff --git a/docs/tradeoffs.md b/docs/tradeoffs.md index 2d6b698..bc2b07d 100644 --- a/docs/tradeoffs.md +++ b/docs/tradeoffs.md @@ -36,7 +36,7 @@ The cap check (`Interlocked.Increment(ref _count) <= _maxPoolSize`) followed by `_maxPoolSize` defaults to 256 but is configurable via `new ResilienceContextPool(maxPoolSize)` — the shared `ResilienceContextPool.Shared` instance always uses the default. -**Why it's acceptable:** The cap is a heuristic, not a hard limit. Enforcing it exactly would require a lock on every return for zero practical benefit. Under burst traffic the pool might grow beyond the target, but this is transient and trades memory for lock-free concurrent access. The contexts are small (~200 bytes) and in steady state the pool converges to size. The alternative — `ConcurrentBag.Count` with exact enforcement — is worse because `Count` itself is expensive (see future-plans #25). +**Why it's acceptable:** The cap is a heuristic, not a hard limit. Enforcing it exactly would require a lock on every return for zero practical benefit. Under burst traffic the pool might grow beyond the target, but this is transient and trades memory for lock-free concurrent access. The contexts are small (~200 bytes) and in steady state the pool converges to size. The alternative — `ConcurrentBag.Count` with exact enforcement — is worse because `Count` itself is expensive (it enumerates every thread-local queue). **Watch for:** Don't rely on the pool staying at or under its configured cap. It's a soft cap. Under sustained traffic, the pool size may exceed the target. @@ -44,18 +44,62 @@ The cap check (`Interlocked.Increment(ref _count) <= _maxPoolSize`) followed by --- -## ~~Timeout cancellation classification has a narrow race window~~ — MOVED +## ~~Timeout cancellation classification has a narrow race window~~ — FIXED -This was a solvable issue, not a true tradeoff. Moved to [future-plans.md](future-plans.md) as **#46** (P2 fix). +This was a solvable issue, not a true tradeoff. It was tracked as future-plans #46, fixed by +recording the first cancellation cause atomically, and that entry has since been retired. The cost +of the fix is the next entry below; see `CHANGELOG.md`. --- -## Per-call delegate allocation in pipeline chain +## Timeout pays an allocation for race-free cancellation classification -Each `StrategyComponent` in the chain creates a closure `ctx => _next.ExecuteAsync(callback, ctx)` per execution. N strategies = N closure allocations per call. +Each Timeout execution allocates a small object recording *which* cause cancelled its linked token +first, and — when the incoming token can be cancelled — one `CancellationToken` registration. -**Why it's acceptable:** This is inherent to the middleware/chain-of-responsibility pattern. Polly v8 has the same cost. The closures are small (one display class + one delegate), short-lived (Gen0 collected), and negligible relative to the I/O cost of the operations being protected. Pre-composing at build time would require complex generic threading that doesn't compose with the non-generic `Strategy` base. +Measured on an Apple M4 Pro, `ShortRun`, `net8.0`: -**Watch for:** If benchmarks show this matters for your workload (unlikely unless you're running 100K+ pipeline executions/sec with no I/O), consider reducing pipeline depth or using `ExecuteOutcomeAsync` with a manually composed chain. +| Shape | Before | After | +|-------|-------:|------:| +| Timeout alone (non-cancellable caller token) | 360 B | 392 B | +| Timeout → Retry → CB → Timeout | 976 B | 1120 B | + +The composite grows by 144 B rather than 64 B because the outer Timeout replaces the context token +with its own linked token, so the **inner** Timeout sees a cancellable token and takes the +registration as well. The canonical recommended pipeline has exactly that shape. + +**Why it's acceptable:** the alternative is what shipped before — reading +`linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested` as two separate reads, so +a user cancellation landing between them is reported as `TimeoutRejectedException` instead of +`OperationCanceledException`. Callers branch on that distinction to decide whether to retry, so +getting it wrong turns a deliberate shutdown into a retry storm. 144 B on a path that is protecting +real I/O is a good trade for an exception type that is always correct. + +**Watch for:** if you run a timeout-heavy pipeline at very high throughput with no I/O, this is the +one allocation added in 1.0. + +One alternative is *worth measuring* but is not known to be cheaper: cancel a dedicated timer-only +`CancellationTokenSource` and link *that* together with the caller's token, so "was it the timeout?" +becomes a single read of a source only the timer can touch. That removes this object and the +registration, but `CreateLinkedTokenSource` over two tokens registers on both, so it trades one +registration for two plus an extra source. Benchmark it before assuming it wins. + +**Location:** [TimeoutStrategy.cs](../src/Resilion/Timeout/TimeoutStrategy.cs) — `CancellationCause` + +--- + +## ~~Per-call delegate allocation in pipeline chain~~ — MOVED + +This was tracked simultaneously here as an accepted imperfection *and* in +[future-plans.md](future-plans.md) as an open improvement, so the repo claimed both that the fix was +worse than the problem and that the fix was planned. It now lives in one place only: +[future-plans.md](future-plans.md) as **#4** (P2, unscheduled), which records the benchmark that +would settle it and the decision rule for closing it as "measured, won't fix". + +The short version, for anyone who arrived here from a link: each `StrategyComponent` creates a +closure `ctx => _next.ExecuteAsync(callback, ctx)` per execution, so N strategies means N small +closure allocations per call. This is inherent to the middleware pattern and Polly v8 has the same +cost. If you think it matters for your workload — unlikely below 100K+ executions/sec with no I/O — +reduce pipeline depth or use `ExecuteOutcomeAsync` with a manually composed chain. **Location:** [PipelineComponent.cs](../src/Resilion/Internal/PipelineComponent.cs) — `StrategyComponent.ExecuteAsync` diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..7af4c44 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,233 @@ +# Claim Verification + +Every factual claim in [future-plans.md](future-plans.md), [tradeoffs.md](tradeoffs.md), +[telemetry.md](telemetry.md) and [architecture.md](architecture.md) about *what the code currently +does* must be re-verified with a command from this file before it is written or re-asserted. + +**Never assert "grep confirms X" without pasting the command and its output into the PR +description.** This file exists because `future-plans.md` once carried three items documented twice +with opposite verdicts — one set of entries asserting "grep confirms zero tag usage" and "zero +matches for PublicAPI" while a block above them declared the same items complete. Both were written +from memory. One of them was wrong in a way that left the 1.0 API freeze completely unenforced. + +Run everything from the repository root. + +**Last full sweep:** 2026-09-30, after the 1.0 hardening pass. Expectations below describe the +code as it stands now; lines marked *(was …)* record what the same command returned before that +pass, because several of these claims had drifted. + +--- + +## Telemetry + +```bash +# Does the sync hedging path increment the counter? (future-plans #55) +grep -n 'HedgingAttempts' src/Resilion/Hedging/HedgingStrategy.cs +# Expected: two hits — the async launch loop and the sync loop. (was: one, line 92 only, so the +# sync path reported zero for every sequential sync hedge.) + +# Which execution entry points start a span? (future-plans #56) +grep -rn 'StartActivity' src/ --include='*.cs' | grep -v '/obj/' +# Expected: 16 hits across 7 files, all via StrategyActivity.Start. (was: 10 across 5 — typed +# retry, typed circuit breaker and sync hedging emitted no spans at all.) +grep -c 'StrategyActivity.Start' src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs +# Expected: 2 (async + sync). (was: 0.) + +# Which span tag keys does the code actually set? (future-plans #58) +grep -rho 'SetTag("[^"]*"' src --include='*.cs' | sort -u +# Expected: only operation.key, outcome, pipeline.name, strategy.name — now set in one place, +# StrategyActivity. telemetry.md must document exactly this set. + +# Do all counter increments carry both tags? (retired #49) +grep -rn '\.Add(1' src/ --include='*.cs' | grep -v '/obj/' | grep -vc 'PipelineNameTag' +# Expected: 0 — every .Add(1, ...) site passes PipelineNameTag. +grep -rn '\.Add(1' src/ --include='*.cs' | grep -v '/obj/' | wc -l +# Expected: 14 sites (the sync hedging increment was added). + +# 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. +``` + +## Public API surface + +```bash +# Are the tracking files in the format the analyzer consumes? (future-plans #57) +grep -c '^Resilion\.' src/Resilion/PublicAPI.Unshipped.txt +# Shipped.txt is the frozen 1.0 surface; Unshipped.txt holds only `#nullable enable`. +grep -c '^Resilion\.' src/Resilion/PublicAPI.Shipped.txt # expected: 293 +# (was: Unshipped held 242 hand-written lines in a format the analyzer cannot parse, and +# Shipped.txt held only comments — so RS0016 fired 742 times and RS0017 508 times.) +grep -vE '^(#nullable enable|Resilion)' src/*/PublicAPI.Unshipped.txt +# Expected AFTER the fix: no output. + +# Is anything enforced? +grep -rn 'TreatWarningsAsErrors\|WarningsAsErrors\|RS00' \ + --include='*.props' --include='*.csproj' --include='.editorconfig' . +# Expected: a WarningsAsErrors line in src/Directory.Build.props scoped to RS0016;RS0017. +dotnet build Resilion.sln -c Release --no-incremental 2>&1 | grep -oE 'warning RS[0-9]+' | sort | uniq -c +# Expected AFTER the fix: no output. + +# Does the gate actually bite? This is the ONLY proof that matters. +dotnet build Resilion.sln -c Release -p:ContinuousIntegrationBuild=true --no-incremental +# Expected: exit 0. Then add a stray `public int Sentinel;` to any public class and re-run: +# must exit non-zero with `error RS0016`. Then delete a line from a PublicAPI file and re-run: +# must exit non-zero with `error RS0017`. Revert both. +``` + +## Packaging + +```bash +# 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. + +# 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" +ls "$TMPDIR/rpack" # expect a .snupkg beside the .nupkg +unzip -p "$TMPDIR/rpack"/Resilion.*.nupkg Resilion.nuspec | grep -i repository +# expect: +strings src/Resilion/bin/Release/net8.0/Resilion.pdb | grep -m1 raw.githubusercontent +# expect a raw.githubusercontent.com/FreakyAli/Resilion//... source map +unzip -l "$TMPDIR/rpack"/Resilion.*.snupkg | grep '\.pdb' # expect lib/net8.0/Resilion.pdb + +# 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. + +# Version stamping +grep -n 'Version\|VersionPrefix' src/Directory.Build.props +grep -nE '^## \[' CHANGELOG.md +# The changelog's newest released section and the props version must not contradict each other. +``` + +## Correctness + +```bash +# Is the timeout cancellation classification still token-read-based? (future-plans #46) +grep -n 'WasCancelledByTimeout' src/Resilion/Timeout/TimeoutStrategy.cs +sed -n '216,226p' src/Resilion/Timeout/TimeoutStrategy.cs +# Expected: a single-read `=> cause.WasTimeout`, plus the CancellationCause type. (was: a +# two-read `linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested`.) + +# Does a typed/result mismatch throw, or silently skip? (future-plans #60) +grep -n 'WarnTypeMismatch\|InvalidOperationException' src/Resilion/Internal/PipelineComponent.cs +# Expected: no WarnTypeMismatch, two throw sites. +# +# Reachability: a typed component must not be constructible into an untyped pipeline. +grep -n 'public PipelineBuilder AddPipeline\|public PipelineBuilder AddPipeline' src/Resilion/PipelineBuilder.cs +# Expected: the untyped builder accepts only `Pipeline`. If a `Pipeline` overload ever appears +# here, the mismatch path becomes reachable and the guard stops being defensive. +# +# Note: the Debug.WriteLine in WarnTypeMismatch is [Conditional("DEBUG")], so it is compiled OUT +# of the Release assembly that ships to NuGet. Do not describe it as a diagnostic consumers see. + +# Is WhenAny still called in a loop? (future-plans #11) +grep -n 'Task.WhenAny\|Task.WhenEach' src/Resilion/Hedging/HedgingStrategy.cs +# Expected today: WhenAny at :69, :70, :143, :333. No WhenEach (net8.0 target). +# :69-70 is the hedging-delay race and is correct as written — see the comment at :74-77. + +# How many independent retry loops exist? (future-plans #27) +grep -c 'for (var attempt = 0' src/Resilion/Retry/RetryStrategy.cs # expected today: 4 +wc -l src/Resilion/Retry/RetryStrategy.cs # expected today: 302 +``` + +## 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). +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. + +# Do the published benchmark numbers still match the code? +grep -n 'ns /' README.md +dotnet run -c Release --project benchmarks/Resilion.Benchmarks +# Any change that adds or removes an allocation on a benchmarked path invalidates these tables. +``` + +## Build configuration + +```bash +# Target frameworks and lock primitive (future-plans #54) +grep -n 'TargetFramework\|TargetFrameworks\|LangVersion' src/Directory.Build.props +grep -rn 'System.Threading.Lock\|NET9_0_OR_GREATER' src/ --include='*.cs' | grep -v '/obj/' +# Expected today: net8.0 (singular), LangVersion 12.0, 0 hits for Lock/NET9. + +# Coverage tooling (future-plans #47) +grep -n 'coverlet\|CodeCoverage\|TestingPlatformDotnetTestSupport' tests/Directory.Build.props +# coverlet.collector is a VSTest collector and a verified silent no-op under MTP. + +# Test-dependency drift +grep -rn 'TimeProvider.Testing' tests/ +# Expected today: 8.0.0 in Resilion.Tests, 9.0.0 in Resilion.Extensions.Tests — inconsistent. + +# Vulnerability disclosure policy (future-plans #53) +ls SECURITY.md .github/SECURITY.md 2>&1 # expected today: neither exists +``` + +## Self-consistency of `future-plans.md` + +These are the checks behind CONTRIBUTING.md § Future-plans hygiene. All five must print nothing +except the counts. + +```bash +# No duplicate section headings. +grep -n '^## ' docs/future-plans.md | sed 's/.*## //' | sort | uniq -d + +# Matrix rows and entries are the same set. Strip the "## Retired items" appendix first — +# its rows deliberately have no entries, and including them is a false positive. +diff <(sed '/^## Retired items/,$d' docs/future-plans.md | grep -oE '^\| [0-9]+ \|' | tr -cd '0-9\n' | sort -n) \ + <(grep -oE '^### [0-9]+\.' docs/future-plans.md | tr -cd '0-9\n' | sort -n) + +# No completion markers. Anchor to the front-matter field, not the bare word "Complete" — +# the file's own "How to read this file" section states the rule and would match itself. +grep -nE '^\*\*Status:\*\*.*Complete|✅' docs/future-plans.md + +# No unverified assertions. +grep -niE 'grep (confirms|across)|confirmed via grep' docs/future-plans.md + +# Every entry carries front-matter. All three counts must be equal. +echo "entries: $(grep -c '^### [0-9]' docs/future-plans.md) \ +status: $(grep -c '^\*\*Status:\*\*' docs/future-plans.md) \ +verified: $(grep -c '^\*\*Verified:\*\*' docs/future-plans.md)" +# Expected 2026-09-30: 18 / 18 / 18 (ten P0 items shipped and were retired). + +# No dangling item references anywhere in the docs. A reference resolves if it matches either an +# active entry or a row in the Retired items table — pointing at a retired number is legitimate. +RETIRED=$(sed -n '/^## Retired items/,$p' docs/future-plans.md | grep -oE '^\| [0-9]+ \|' | tr -cd '0-9\n') +for n in $(grep -rhoE '#[0-9]{1,2}' docs/*.md README.md benchmarks/results/README.md \ + | tr -d '#' | sort -un); do + grep -q "^### $n\." docs/future-plans.md && continue + echo "$RETIRED" | grep -qx "$n" && continue + echo "dangling reference: #$n" +done +# Known acceptable output: "#1", from the prose "the #1 onboarding path" in future-plans #39. +# Any other number is a real dangling reference and must be fixed or removed. + +# Every anchor into future-plans.md resolves. Hand-check each fragment against the heading list. +grep -rn 'future-plans.md#' docs/ README.md +grep -n '^### ' docs/future-plans.md +``` + +--- + +## Pre-PR checklist + +Before opening a PR that touches `docs/future-plans.md`: + +1. Run the five self-consistency checks above. All must pass. +2. For every claim you wrote or changed, run its command from this file and paste the output into + the PR description. +3. Update the `**Verified:**` line of every entry you touched with today's date and the command you + ran. +4. If you implemented an item, **delete it** — entry and matrix row — add a `## Retired items` row, + and add the `CHANGELOG.md` entry. Do not mark it complete in place. +5. If you only partially closed an item, file the remainder under a **new** number and retire the + old one. Do not leave a half-true entry. +6. Update the "Last full sweep" line at the top of this file if you re-ran everything. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 415cb94..f3003f5 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,10 @@ - 0.1.0 + + 1.0.0 FreakyAli Copyright 2026 en @@ -22,6 +25,18 @@ enable true true + + + true + true + true + snupkg + portable + true @@ -29,6 +44,19 @@ + + + $(WarningsAsErrors);RS0016;RS0017 + + diff --git a/src/Resilion.Extensions/Configuration/CircuitBreakerStrategyConfiguration.cs b/src/Resilion.Extensions/Configuration/CircuitBreakerStrategyConfiguration.cs new file mode 100644 index 0000000..a3db92a --- /dev/null +++ b/src/Resilion.Extensions/Configuration/CircuitBreakerStrategyConfiguration.cs @@ -0,0 +1,41 @@ +namespace Resilion.Extensions.Configuration; + +/// A bindable description of . +/// +/// Defaults mirror the options record exactly. ManualControl, StateProvider, +/// BreakDurationGenerator and the event handlers are absent: they are object references or +/// delegates, not configuration values. +/// +public sealed class CircuitBreakerStrategyConfiguration +{ + /// Gets or sets the failure ratio that trips the circuit. Defaults to 0.5. + public double FailureRatioThreshold { get; set; } = 0.5; + + /// Gets or sets the rolling window the ratio is measured over. Defaults to 30 seconds. + public TimeSpan SamplingDuration { get; set; } = TimeSpan.FromSeconds(30); + + /// Gets or sets the calls required before the ratio is evaluated. Defaults to 10. + public int MinimumThroughput { get; set; } = 10; + + /// Gets or sets how long the circuit stays open. Defaults to 30 seconds. + public TimeSpan BreakDuration { get; set; } = TimeSpan.FromSeconds(30); + + /// Projects this description into options, leaving all delegates unset. + public CircuitBreakerStrategyOptions ToOptions() => new() + { + FailureRatioThreshold = FailureRatioThreshold, + SamplingDuration = SamplingDuration, + MinimumThroughput = MinimumThroughput, + BreakDuration = BreakDuration, + }; + + /// + /// The pipeline's result type. + public CircuitBreakerStrategyOptions ToOptions() => new() + { + FailureRatioThreshold = FailureRatioThreshold, + SamplingDuration = SamplingDuration, + MinimumThroughput = MinimumThroughput, + BreakDuration = BreakDuration, + }; +} diff --git a/src/Resilion.Extensions/Configuration/HedgingStrategyConfiguration.cs b/src/Resilion.Extensions/Configuration/HedgingStrategyConfiguration.cs new file mode 100644 index 0000000..5279b6e --- /dev/null +++ b/src/Resilion.Extensions/Configuration/HedgingStrategyConfiguration.cs @@ -0,0 +1,31 @@ +namespace Resilion.Extensions.Configuration; + +/// A bindable description of . +/// +/// Hedging options are always typed, so there is no non-generic projection. +/// HedgingDelayGenerator, ActionGenerator, ShouldHandle and OnHedging +/// are absent: all are delegates. +/// +public sealed class HedgingStrategyConfiguration +{ + /// Gets or sets the attempt count, including the primary. Defaults to 2. + public int MaxHedgedAttempts { get; set; } = 2; + + /// + /// Gets or sets the delay before each hedged attempt. Defaults to 2 seconds. + /// + /// + /// This value selects the mode: 00:00:00 runs every attempt in parallel, a positive + /// value waits that long unless an earlier attempt finishes first, and -00:00:00.0010000 + /// () runs them sequentially. + /// + public TimeSpan HedgingDelay { get; set; } = TimeSpan.FromSeconds(2); + + /// Projects this description into options, leaving all delegates unset. + /// The pipeline's result type. + public HedgingStrategyOptions ToOptions() => new() + { + MaxHedgedAttempts = MaxHedgedAttempts, + HedgingDelay = HedgingDelay, + }; +} diff --git a/src/Resilion.Extensions/Configuration/RetryDelayConfiguration.cs b/src/Resilion.Extensions/Configuration/RetryDelayConfiguration.cs new file mode 100644 index 0000000..ed7c0d9 --- /dev/null +++ b/src/Resilion.Extensions/Configuration/RetryDelayConfiguration.cs @@ -0,0 +1,59 @@ +namespace Resilion.Extensions.Configuration; + +/// Selects which variant to build. +/// +/// There is deliberately no Custom member. takes a delegate, +/// which cannot come from a configuration file; offering it here would mean a value that silently +/// does nothing when bound. +/// +public enum RetryDelayKind +{ + /// The same delay between every attempt. + Constant = 0, + + /// Delay grows linearly with the attempt number. + Linear = 1, + + /// Delay doubles with each attempt. + Exponential = 2, +} + +/// +/// A bindable description of a . +/// +/// +/// is a closed union with a private constructor — exactly one backoff +/// strategy can be configured, and the compiler enforces it. That is deliberate, and making it +/// directly bindable would reintroduce the ambiguity it exists to prevent. This type is the +/// one-way funnel instead: a mutable POCO the configuration binder can populate, projected into +/// the real union by . +/// +/// A cap on the computed delay is not here. RetryStrategyConfiguration.MaxDelay +/// carries it, because the strategy-level cap applies to every kind — including +/// , whose factory takes no maximum at all. Exposing a cap in +/// both places would be two knobs for one thing. +/// +/// +public sealed class RetryDelayConfiguration +{ + /// Gets or sets the backoff shape. Defaults to . + public RetryDelayKind Kind { get; set; } = RetryDelayKind.Exponential; + + /// + /// Gets or sets the delay for , or the first delay that + /// later attempts grow from. Defaults to 1 second, matching RetryStrategyOptions.Delay. + /// + /// Binds from a TimeSpan string such as "00:00:01". + public TimeSpan BaseDelay { get; set; } = TimeSpan.FromSeconds(1); + + /// Projects this description into a . + public RetryDelay ToRetryDelay() => Kind switch + { + RetryDelayKind.Constant => RetryDelay.Constant(BaseDelay), + RetryDelayKind.Linear => RetryDelay.Linear(BaseDelay), + RetryDelayKind.Exponential => RetryDelay.Exponential(BaseDelay), + _ => throw new InvalidOperationException( + $"Unknown RetryDelayKind '{Kind}'. Valid values are Constant, Linear and Exponential; " + + "RetryDelay.Custom takes a delegate and cannot be configured from a file."), + }; +} diff --git a/src/Resilion.Extensions/Configuration/RetryStrategyConfiguration.cs b/src/Resilion.Extensions/Configuration/RetryStrategyConfiguration.cs new file mode 100644 index 0000000..a212f40 --- /dev/null +++ b/src/Resilion.Extensions/Configuration/RetryStrategyConfiguration.cs @@ -0,0 +1,59 @@ +namespace Resilion.Extensions.Configuration; + +/// +/// A bindable description of . +/// +/// +/// Every default below mirrors the options record exactly, so binding nothing is identical to +/// passing no options at all. Drift between the two is a silent behaviour change; the +/// ...Defaults_Match... tests exist to pin it. +/// +/// Delegates — ShouldHandle, OnRetry — are absent on purpose: they cannot come from +/// configuration. Layer them on afterwards with a with expression, which needs no extra API: +/// +/// +/// services.Configure<RetryStrategyConfiguration>(config.GetSection("Resilience:Api:Retry")); +/// services.AddResiliencePipeline<RetryStrategyConfiguration>("api", (b, c) => +/// b.AddRetry(c.ToOptions() with { ShouldHandle = ex => ex is HttpRequestException })); +/// +/// +public sealed class RetryStrategyConfiguration +{ + /// Gets or sets the retry count. Defaults to 3. + public int MaxRetryAttempts { get; set; } = 3; + + /// Gets or sets whether to apply decorrelated jitter. Defaults to . + public bool UseJitter { get; set; } = true; + + /// + /// Gets or sets a cap applied after the backoff computes its value, for every kind. Defaults to + /// no cap. + /// + public TimeSpan? MaxDelay { get; set; } + + /// Gets or sets the backoff shape. + public RetryDelayConfiguration Delay { get; set; } = new(); + + /// Projects this description into options, leaving all delegates unset. + /// + /// Delegate members stay , so each strategy's own default predicate + /// applies — the same behaviour as constructing the options by hand and setting nothing. + /// + public RetryStrategyOptions ToOptions() => new() + { + MaxRetryAttempts = MaxRetryAttempts, + UseJitter = UseJitter, + MaxDelay = MaxDelay, + Delay = (Delay ?? new RetryDelayConfiguration()).ToRetryDelay(), + }; + + /// + /// The pipeline's result type. + public RetryStrategyOptions ToOptions() => new() + { + MaxRetryAttempts = MaxRetryAttempts, + UseJitter = UseJitter, + MaxDelay = MaxDelay, + Delay = (Delay ?? new RetryDelayConfiguration()).ToRetryDelay(), + }; +} diff --git a/src/Resilion.Extensions/Configuration/TimeoutStrategyConfiguration.cs b/src/Resilion.Extensions/Configuration/TimeoutStrategyConfiguration.cs new file mode 100644 index 0000000..1e46e8a --- /dev/null +++ b/src/Resilion.Extensions/Configuration/TimeoutStrategyConfiguration.cs @@ -0,0 +1,14 @@ +namespace Resilion.Extensions.Configuration; + +/// A bindable description of . +/// +/// TimeoutGenerator is absent: it is a delegate and cannot be configured from a file. +/// +public sealed class TimeoutStrategyConfiguration +{ + /// Gets or sets the timeout. Defaults to 30 seconds. + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Projects this description into options, leaving all delegates unset. + public TimeoutStrategyOptions ToOptions() => new() { Timeout = Timeout }; +} diff --git a/src/Resilion.Extensions/PipelineReplacedArgs.cs b/src/Resilion.Extensions/PipelineReplacedArgs.cs new file mode 100644 index 0000000..bf2d776 --- /dev/null +++ b/src/Resilion.Extensions/PipelineReplacedArgs.cs @@ -0,0 +1,31 @@ +namespace Resilion.Extensions; + +/// +/// Describes a pipeline that has just been evicted from a +/// by an invalidation. +/// +/// The registry's key type. +/// The key whose pipeline was evicted. +/// +/// The pipeline's result type, or for a non-generic pipeline. +/// +/// +/// The evicted instance. is the common supertype of +/// and . +/// +/// +/// The registry does not dispose an evicted pipeline, because it cannot know when +/// the last in-flight execution on it has finished — that would require ref-counting every execute +/// path. Disposal is therefore the caller's decision, and this is how the caller learns there is +/// something to decide about. +/// +/// Disposing it immediately is usually wrong: executions already running on the old pipeline +/// complete on the old pipeline. Wait, or simply let it be collected, which is safe while no +/// strategy owns an unmanaged resource. +/// +/// +public readonly record struct PipelineReplacedArgs( + TKey Key, + Type? ResultType, + IAsyncDisposable ReplacedPipeline) + where TKey : notnull; diff --git a/src/Resilion.Extensions/PublicAPI.Shipped.txt b/src/Resilion.Extensions/PublicAPI.Shipped.txt index 528fbaa..1023678 100644 --- a/src/Resilion.Extensions/PublicAPI.Shipped.txt +++ b/src/Resilion.Extensions/PublicAPI.Shipped.txt @@ -1,4 +1,18 @@ -# This file tracks the public API surface of Resilion.Extensions v1.0. -# It will be generated after v1.0 is shipped using PublicApiAnalyzers. -# Until then, it serves as a placeholder to enable the analyzer. -# See: https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md +#nullable enable +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.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.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.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.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! diff --git a/src/Resilion.Extensions/PublicAPI.Unshipped.txt b/src/Resilion.Extensions/PublicAPI.Unshipped.txt index 8127168..2a2a59f 100644 --- a/src/Resilion.Extensions/PublicAPI.Unshipped.txt +++ b/src/Resilion.Extensions/PublicAPI.Unshipped.txt @@ -1,25 +1,67 @@ #nullable enable - -# New public APIs for Resilion.Extensions (yet to ship). -# Entries here should be moved to PublicAPI.Shipped.txt upon release. - -namespace Resilion.Extensions; - -IPipelineProvider -IPipelineProvider.GetPipeline(TKey) -> Resilion.Pipeline! -IPipelineProvider.GetPipeline(TKey) -> Resilion.Pipeline! -IPipelineProvider.TryGetPipeline(TKey, out Resilion.Pipeline?) -> bool - -ResilionServiceCollectionExtensions -ResilionServiceCollectionExtensions.AddResilienceServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -ResilionServiceCollectionExtensions.AddResilion(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! - -ResiliencePipelineRegistry -ResiliencePipelineRegistry.RegisterPipeline(TKey!, System.Action!) -> void -ResiliencePipelineRegistry.RegisterPipeline(TKey!, System.Action>!) -> void -ResiliencePipelineRegistry.GetPipeline(TKey!) -> Resilion.Pipeline! -ResiliencePipelineRegistry.GetPipeline(TKey!) -> Resilion.Pipeline! -ResiliencePipelineRegistry.TryGetPipeline(TKey!, out Resilion.Pipeline?) -> bool -ResiliencePipelineRegistry.Dispose() -> void +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, 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, 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.Extensions/ResiliencePipelineRegistry.cs b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs index 3b367e9..6109502 100644 --- a/src/Resilion.Extensions/ResiliencePipelineRegistry.cs +++ b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs @@ -14,6 +14,7 @@ public sealed class ResiliencePipelineRegistry : IPipelineProvider, private readonly ConcurrentDictionary<(TKey, Type), Lazy> _typedPipelines = new(); private readonly ConcurrentDictionary> _factories = new(); private readonly ConcurrentDictionary<(TKey, Type), object> _typedFactories = new(); + private readonly ConcurrentBag _reloadSubscriptions = []; /// /// Registers a factory for a named pipeline. The factory is invoked lazily on first access. @@ -56,7 +57,9 @@ public void RegisterPipeline(TKey key, Action> /// No pipeline is registered with the specified key. public Pipeline GetPipeline(TKey key) { - // Validate factory exists before creating Lazy to prevent caching faulted entries + // Look the factory up before creating the Lazy, so an unknown key throws without leaving a + // cache entry behind. Note this does NOT cover a factory that throws: that exception is + // cached by the Lazy and rethrown until the key is invalidated. See future-plans #67. if (!_factories.TryGetValue(key, out var factory)) { throw new KeyNotFoundException($"No pipeline registered with key '{key}'."); @@ -82,7 +85,9 @@ public Pipeline GetPipeline(TKey key) { var compositeKey = (key, typeof(TResult)); - // Validate factory exists before creating Lazy to prevent caching faulted entries + // Look the factory up before creating the Lazy, so an unknown key throws without leaving a + // cache entry behind. Note this does NOT cover a factory that throws: that exception is + // cached by the Lazy and rethrown until the key is invalidated. See future-plans #67. if (!_typedFactories.TryGetValue(compositeKey, out var factory)) { throw new KeyNotFoundException( @@ -118,9 +123,127 @@ public bool TryGetPipeline(TKey key, out Pipeline? pipeline) return false; } + /// + /// Attempts to get the typed pipeline registered under the specified key and result type. + /// + /// The pipeline's result type. + /// The pipeline name. + /// The pipeline, if found. + /// true if the pipeline exists; false otherwise. + /// + /// The typed counterpart of . Not on + /// : adding a member to a public interface would break + /// external implementers, so the interface gains it in a future major version. + /// + public bool TryGetPipeline(TKey key, out Pipeline? pipeline) + { + if (_typedFactories.ContainsKey((key, typeof(TResult)))) + { + pipeline = GetPipeline(key); + return true; + } + + pipeline = null; + return false; + } + + /// + /// Gets or sets a callback invoked when an invalidation evicts a pipeline that had already + /// been built. + /// + /// + /// The registry never disposes an evicted pipeline — see + /// for why, and what your options are. + /// + public Action>? OnPipelineReplaced { get; set; } + + /// + /// Drops the cached pipeline for , so the next + /// rebuilds it from its registered factory. + /// + /// The pipeline name. + /// + /// true if a cached entry was removed; false if nothing was cached — either the + /// key is unregistered, or its pipeline had not been built yet, in which case the next access + /// already builds a current one. + /// + /// + /// The registered factory is untouched, which is what makes reload work: builders are + /// single-use, and the factory constructs a fresh one on every build. + /// + /// In-flight executions complete on the old pipeline. It is immutable and the executing frame + /// holds it on its own stack, so nothing tears. + /// + /// + /// Eventually consistent: a concurrent invalidate and get can return a pipeline built from + /// options that were current microseconds ago. Locking to prevent that would serialize every + /// resolve for no practical gain. + /// + /// + public bool InvalidatePipeline(TKey key) + { + if (!_pipelines.TryRemove(key, out var lazy)) + { + return false; + } + + if (lazy.IsValueCreated) + { + OnPipelineReplaced?.Invoke(new PipelineReplacedArgs(key, null, lazy.Value)); + } + + return true; + } + + /// + /// Drops the cached typed pipeline for and + /// , so the next + /// rebuilds it. + /// + /// The pipeline's result type. + /// The pipeline name. + /// true if a cached entry was removed; false otherwise. + /// + /// Typed pipelines are keyed by name and result type, so this affects only the entry + /// for . Same semantics as + /// otherwise. + /// + public bool InvalidatePipeline(TKey key) + { + var compositeKey = (key, typeof(TResult)); + if (!_typedPipelines.TryRemove(compositeKey, out var lazy)) + { + return false; + } + + if (lazy.IsValueCreated && lazy.Value is IAsyncDisposable disposable) + { + OnPipelineReplaced?.Invoke( + new PipelineReplacedArgs(key, typeof(TResult), disposable)); + } + + return true; + } + + /// + /// Takes ownership of a change-notification subscription, disposing it with the registry. + /// + internal void AddReloadSubscription(IDisposable subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + _reloadSubscriptions.Add(subscription); + } + /// public void Dispose() { + // Stop listening before disposing pipelines, so a change arriving mid-teardown cannot + // rebuild a pipeline into a registry that is going away. + while (_reloadSubscriptions.TryTake(out var subscription)) + { + subscription.Dispose(); + } + foreach (var lazy in _pipelines.Values) { if (lazy.IsValueCreated) diff --git a/src/Resilion.Extensions/Resilion.Extensions.csproj b/src/Resilion.Extensions/Resilion.Extensions.csproj index c539d9f..60a6ea1 100644 --- a/src/Resilion.Extensions/Resilion.Extensions.csproj +++ b/src/Resilion.Extensions/Resilion.Extensions.csproj @@ -19,7 +19,6 @@ - diff --git a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs index e9eb568..96b3347 100644 --- a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs +++ b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace Resilion.Extensions; @@ -70,6 +71,7 @@ public static IServiceCollection AddResiliencePipeline( var capturedConfigure = configure; services.AddSingleton( new PipelineConfigurator(capturedName, capturedConfigure)); + AddKeyedPipeline(services, name); return services; } @@ -95,6 +97,177 @@ public static IServiceCollection AddResiliencePipeline( services.AddSingleton( new TypedPipelineConfigurator(name, configure)); + AddKeyedPipelineOfT(services, name); + + return services; + } + + /// + /// Registers a named resilience pipeline built from , rebuilt + /// automatically whenever those options change. + /// + /// The options type to bind the pipeline to. + /// The service collection. + /// The pipeline name. + /// Configures the builder from the current options value. + /// The service collection for chaining. + /// + /// runs on every build and receives the options value current at + /// that moment, so it must read from its argument rather than capturing anything. + /// + /// Resolve the pipeline per call. A reload replaces the cached instance, so a + /// consumer that caches it in a field keeps executing the old one forever. Depend on + /// and call GetPipeline each time; it is a + /// dictionary lookup. + /// + /// + public static IServiceCollection AddResiliencePipeline( + this IServiceCollection services, + string name, + Action configure) + where TOptions : class + => services.AddResiliencePipeline(name, optionsName: null, configure); + + /// + /// Registers a named resilience pipeline built from a named + /// instance, rebuilt automatically whenever those options change. + /// + /// The options type to bind the pipeline to. + /// The service collection. + /// The pipeline name. + /// + /// The named options instance to read, or for the default instance. + /// + /// Configures the builder from the current options value. + /// The service collection for chaining. + public static IServiceCollection AddResiliencePipeline( + this IServiceCollection services, + string name, + string? optionsName, + Action configure) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(configure); + + services.AddResilienceServices(); + services.AddOptions(); + services.AddSingleton( + new MonitoredPipelineConfigurator(name, optionsName, configure)); + AddKeyedPipeline(services, name); + + return services; + } + + /// + /// Registers a named typed resilience pipeline built from , + /// rebuilt automatically whenever those options change. + /// + /// The pipeline's result type. + /// The options type to bind the pipeline to. + /// The service collection. + /// The pipeline name. + /// Configures the builder from the current options value. + /// The service collection for chaining. + public static IServiceCollection AddResiliencePipeline( + this IServiceCollection services, + string name, + Action, TOptions> configure) + where TOptions : class + => services.AddResiliencePipeline(name, optionsName: null, configure); + + /// + /// Registers a named typed resilience pipeline built from a named + /// instance, rebuilt automatically whenever those options + /// change. + /// + /// The pipeline's result type. + /// The options type to bind the pipeline to. + /// The service collection. + /// The pipeline name. + /// + /// The named options instance to read, or for the default instance. + /// + /// Configures the builder from the current options value. + /// The service collection for chaining. + public static IServiceCollection AddResiliencePipeline( + this IServiceCollection services, + string name, + string? optionsName, + Action, TOptions> configure) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(configure); + + services.AddResilienceServices(); + services.AddOptions(); + services.AddSingleton( + new MonitoredTypedPipelineConfigurator(name, optionsName, configure)); + AddKeyedPipelineOfT(services, name); + + return services; + } + + /// + /// Registers the pipeline as a keyed singleton, so it can be injected with + /// [FromKeyedServices(name)] instead of going through the registry. + /// + /// + /// Always on: it costs one per pipeline, adds no Resilion API + /// (the consumer-facing surface is the BCL's FromKeyedServicesAttribute), and an opt-in + /// flag would mean a boolean on every overload. + /// + /// Registered with TryAddKeyedSingleton so a duplicate AddResiliencePipeline call + /// does not add a second descriptor — the registry already rejects duplicate keys, which is + /// where that error belongs. + /// + /// + /// Singleton is the only correct lifetime. Keyed transient would have DI track and dispose the + /// instance per scope, destroying the registry's cached pipeline while other callers still + /// hold it. + /// + /// + /// Incompatible with reload. A keyed singleton resolves once and an injected + /// field caches it again, so neither observes + /// . Keyed injection is + /// for static pipelines; a reloadable pipeline must be resolved per call through + /// . + /// + /// + private static void AddKeyedPipeline(IServiceCollection services, string name) + => services.TryAddKeyedSingleton(name, static (sp, key) => + sp.GetRequiredService>().GetPipeline((string)key!)); + + /// + private static void AddKeyedPipelineOfT(IServiceCollection services, string name) + => services.TryAddKeyedSingleton>(name, static (sp, key) => + sp.GetRequiredService>() + .GetPipeline((string)key!)); + + /// + /// Configures the itself, before any pipeline + /// factory is registered on it. + /// + /// The service collection. + /// Configures the registry. + /// The service collection for chaining. + /// + /// Use this for registry-level concerns such as + /// . It runs before pipeline + /// registration, so settings are in place by the time the first pipeline can be built. + /// + public static IServiceCollection ConfigureResiliencePipelineRegistry( + this IServiceCollection services, + Action> configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + services.AddResilienceServices(); + services.AddSingleton(new RegistryConfigurator(configure)); return services; } @@ -108,11 +281,19 @@ internal static ResiliencePipelineRegistry BuildRegistry(IServiceProvide // Create a new registry instance (don't call GetRequiredService to avoid infinite recursion) var registry = new ResiliencePipelineRegistry(); + // Registry-level configuration runs FIRST and deliberately so: it sets things a pipeline + // build can observe (OnPipelineReplaced, and any future registry default), so applying it + // after factory registration would leave a window where a pipeline is built without it. + foreach (var configurator in sp.GetServices()) + { + configurator.Configure(registry); + } + // Apply all registered pipeline configurations var configurators = sp.GetServices(); foreach (var configurator in configurators) { - configurator.Configure(registry); + configurator.Configure(registry, sp); } return registry; @@ -120,10 +301,108 @@ internal static ResiliencePipelineRegistry BuildRegistry(IServiceProvide } internal interface IPipelineConfigurator +{ + /// The registry being built. + /// + /// The provider the registry is being resolved from. Needed by configurators that read + /// ; ignored by the static ones. + /// + void Configure(ResiliencePipelineRegistry registry, IServiceProvider services); +} + +/// Applies registry-level configuration before any pipeline factory is registered. +internal interface IRegistryConfigurator { void Configure(ResiliencePipelineRegistry registry); } +internal sealed class RegistryConfigurator : IRegistryConfigurator +{ + private readonly Action> _configure; + + public RegistryConfigurator(Action> configure) + => _configure = configure; + + public void Configure(ResiliencePipelineRegistry registry) => _configure(registry); +} + +/// +/// Registers a pipeline factory that reads at build time, and +/// invalidates the cached pipeline whenever those options change. +/// +internal sealed class MonitoredPipelineConfigurator : IPipelineConfigurator + where TOptions : class +{ + private readonly string _name; + private readonly string? _optionsName; + private readonly Action _configure; + + public MonitoredPipelineConfigurator( + string name, + string? optionsName, + Action configure) + { + _name = name; + _optionsName = optionsName; + _configure = configure; + } + + public void Configure(ResiliencePipelineRegistry registry, IServiceProvider services) + { + var monitor = services.GetRequiredService>(); + + // Read through the monitor on every build rather than capturing a value, so a rebuild + // after a change picks up the new options. + registry.RegisterPipeline(_name, builder => _configure(builder, CurrentValue(monitor))); + + // 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)); + if (subscription is not null) + { + registry.AddReloadSubscription(subscription); + } + } + + private TOptions CurrentValue(IOptionsMonitor monitor) + => _optionsName is null ? monitor.CurrentValue : monitor.Get(_optionsName); +} + +/// The typed counterpart of . +internal sealed class MonitoredTypedPipelineConfigurator : IPipelineConfigurator + where TOptions : class +{ + private readonly string _name; + private readonly string? _optionsName; + private readonly Action, TOptions> _configure; + + public MonitoredTypedPipelineConfigurator( + string name, + string? optionsName, + Action, TOptions> configure) + { + _name = name; + _optionsName = optionsName; + _configure = configure; + } + + public void Configure(ResiliencePipelineRegistry registry, IServiceProvider services) + { + var monitor = services.GetRequiredService>(); + + registry.RegisterPipeline(_name, builder => _configure(builder, CurrentValue(monitor))); + + var subscription = monitor.OnChange((_, _) => registry.InvalidatePipeline(_name)); + if (subscription is not null) + { + registry.AddReloadSubscription(subscription); + } + } + + private TOptions CurrentValue(IOptionsMonitor monitor) + => _optionsName is null ? monitor.CurrentValue : monitor.Get(_optionsName); +} + internal sealed class PipelineConfigurator : IPipelineConfigurator { private readonly string _name; @@ -135,7 +414,7 @@ public PipelineConfigurator(string name, Action configure) _configure = configure; } - public void Configure(ResiliencePipelineRegistry registry) + public void Configure(ResiliencePipelineRegistry registry, IServiceProvider services) => registry.RegisterPipeline(_name, _configure); } @@ -150,6 +429,6 @@ public TypedPipelineConfigurator(string name, Action> c _configure = configure; } - public void Configure(ResiliencePipelineRegistry registry) + public void Configure(ResiliencePipelineRegistry registry, IServiceProvider services) => registry.RegisterPipeline(_name, _configure); } diff --git a/src/Resilion.Http/HttpRequestReplay.cs b/src/Resilion.Http/HttpRequestReplay.cs new file mode 100644 index 0000000..d2ff1d3 --- /dev/null +++ b/src/Resilion.Http/HttpRequestReplay.cs @@ -0,0 +1,31 @@ +namespace Resilion.Http; + +/// +/// Controls whether each attempt sends the caller's HttpRequestMessage or an independent +/// copy of it. +/// +public enum HttpRequestReplay +{ + /// + /// Every attempt sends the caller's original request instance. + /// + /// + /// Zero overhead, and what Microsoft.Extensions.Http.Resilience does. Safe only for + /// replayable content — StringContent, ByteArrayContent, + /// JsonContent, FormUrlEncodedContent, or no content at all. A + /// StreamContent over a non-seekable stream cannot be sent twice, and an inner handler + /// that adds rather than sets a header (some auth handlers do) will throw on the + /// second attempt. Use in either case. + /// + ReuseRequest = 0, + + /// + /// The request body is buffered once up front and every attempt sends an independent clone. + /// + /// + /// Required whenever attempts can overlap, and the fix for non-replayable content or + /// header-mutating inner handlers. It buffers the entire body in memory, so do not use + /// it for large or streaming uploads. + /// + CloneRequest = 1, +} diff --git a/src/Resilion.Http/HttpResilienceContextExtensions.cs b/src/Resilion.Http/HttpResilienceContextExtensions.cs new file mode 100644 index 0000000..55150b8 --- /dev/null +++ b/src/Resilion.Http/HttpResilienceContextExtensions.cs @@ -0,0 +1,29 @@ +using System.Net.Http; +using Resilion.Http.Internal; + +namespace Resilion.Http; + +/// +/// helpers for code running inside an HTTP resilience pipeline. +/// +public static class HttpResilienceContextExtensions +{ + /// + /// Gets the for the attempt currently executing, for use from + /// a strategy callback such as OnRetry. + /// + /// The resilience context. + /// + /// The current attempt's request — the clone, when + /// is in effect — or if the + /// pipeline was not executed by a Resilion HTTP handler. + /// + public static HttpRequestMessage? GetRequestMessage(this ResilienceContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return context.Properties.TryGetValue(HttpResilienceKeys.RequestMessage, out var request) + ? request + : null; + } +} diff --git a/src/Resilion.Http/HttpResiliencePredicates.cs b/src/Resilion.Http/HttpResiliencePredicates.cs new file mode 100644 index 0000000..1428bd0 --- /dev/null +++ b/src/Resilion.Http/HttpResiliencePredicates.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http; + +namespace Resilion.Http; + +/// +/// The default "is this worth retrying?" predicates for HTTP, matching what +/// Microsoft.Extensions.Http.Resilience treats as transient. +/// +/// +/// Substituted automatically wherever a standard handler's ShouldHandle is left +/// , and exposed publicly so a custom pipeline can compose against the same +/// definition instead of re-deriving it. +/// +public static class HttpResiliencePredicates +{ + /// + /// Determines whether an outcome represents a transient HTTP failure — either a transient + /// exception, or a successful call that returned a transient status code. + /// + public static bool IsTransient(Outcome outcome) + { + if (outcome.Exception is not null) + { + return IsTransientException(outcome.Exception); + } + + return outcome.TryGetResult(out var response) + && response is not null + && IsTransientStatusCode(response.StatusCode); + } + + /// + /// Determines whether a status code is transient: any 5xx, plus 408 Request Timeout and + /// 429 Too Many Requests. + /// + /// + /// 429 is included to match Polly's default, but note that Resilion cannot yet honour a + /// Retry-After header, so a 429 is retried on blind exponential backoff. If the server + /// publishes a retry window, prefer handling 429 yourself. + /// + public static bool IsTransientStatusCode(HttpStatusCode statusCode) + => (int)statusCode >= 500 + || statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests; + + /// + /// Determines whether an exception represents a transient HTTP failure. + /// + /// + /// is never transient, and is checked first: + /// cancellation is a deliberate instruction, and retrying it would defeat the caller's token. + /// A per-attempt timeout surfaces as — which is not an + /// — so timeouts remain retryable while cancellation + /// does not. + /// + public static bool IsTransientException(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + if (exception is OperationCanceledException) + { + return false; + } + + return exception is HttpRequestException or TimeoutRejectedException; + } +} diff --git a/src/Resilion.Http/HttpStandardHedgingOptions.cs b/src/Resilion.Http/HttpStandardHedgingOptions.cs new file mode 100644 index 0000000..51fb1bd --- /dev/null +++ b/src/Resilion.Http/HttpStandardHedgingOptions.cs @@ -0,0 +1,82 @@ +using System.Net.Http; +using Resilion.RateLimiting; + +namespace Resilion.Http; + +/// +/// Options for AddStandardHedgingHandler. +/// +/// +/// The resulting pipeline, outermost first, is +/// RateLimiter → → → +/// → — the standard shape with hedging +/// in place of retry, for latency-sensitive calls to idempotent endpoints. +/// +/// There is no request-replay setting: hedged attempts can run concurrently and cannot share one +/// HttpRequestMessage, so this handler always clones per attempt, which means it always +/// buffers the request body in memory. Do not use it for large or streaming uploads. +/// +/// +/// All attempts target the same URI. Per-endpoint hedging is not supported. +/// +/// +/// The null-ShouldHandle substitution described on +/// applies here too. +/// +/// +public sealed class HttpStandardHedgingOptions +{ + /// + public TimeProvider TimeProvider { get; set; } = TimeProvider.System; + + /// + public RateLimiterStrategyOptions? RateLimiter { get; set; } + + /// + /// Gets or sets the total timeout across all hedged attempts. Defaults to 30 seconds. + /// + public TimeoutStrategyOptions TotalRequestTimeout { get; set; } = + new() { Timeout = TimeSpan.FromSeconds(30) }; + + /// + /// Gets or sets the hedging options. Defaults to 2 attempts with a 2 second delay, so a second + /// request is issued only if the first has not answered in that time. + /// + public HedgingStrategyOptions Hedging { get; set; } = new() + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromSeconds(2), + }; + + /// + public CircuitBreakerStrategyOptions CircuitBreaker { get; set; } = new() + { + FailureRatioThreshold = 0.1, + MinimumThroughput = 100, + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(5), + }; + + /// + public TimeoutStrategyOptions AttemptTimeout { get; set; } = + new() { Timeout = TimeSpan.FromSeconds(10) }; + + /// Validates the options, throwing if they cannot produce a sensible pipeline. + internal void Validate() + { + ArgumentNullException.ThrowIfNull(TimeProvider); + ArgumentNullException.ThrowIfNull(TotalRequestTimeout); + ArgumentNullException.ThrowIfNull(Hedging); + ArgumentNullException.ThrowIfNull(CircuitBreaker); + ArgumentNullException.ThrowIfNull(AttemptTimeout); + + if (AttemptTimeout.Timeout != System.Threading.Timeout.InfiniteTimeSpan + && TotalRequestTimeout.Timeout != System.Threading.Timeout.InfiniteTimeSpan + && AttemptTimeout.Timeout > TotalRequestTimeout.Timeout) + { + throw new InvalidOperationException( + $"AttemptTimeout ({AttemptTimeout.Timeout}) is longer than TotalRequestTimeout " + + $"({TotalRequestTimeout.Timeout}), so it can never fire."); + } + } +} diff --git a/src/Resilion.Http/HttpStandardResilienceOptions.cs b/src/Resilion.Http/HttpStandardResilienceOptions.cs new file mode 100644 index 0000000..43f24cb --- /dev/null +++ b/src/Resilion.Http/HttpStandardResilienceOptions.cs @@ -0,0 +1,124 @@ +using System.Net.Http; +using Resilion.RateLimiting; + +namespace Resilion.Http; + +/// +/// Options for AddStandardResilienceHandler. +/// +/// +/// The resulting pipeline, outermost first, is +/// RateLimiter → → → +/// → . +/// +/// Each leg is the corresponding Resilion options record, so anything you can express on a +/// hand-built pipeline you can express here. Adjust one property with a with expression +/// rather than replacing the record, to keep the rest of the defaults: +/// o.Retry = o.Retry with { MaxRetryAttempts = 5 }. +/// +/// +/// Null ShouldHandle means "use the HTTP default". If +/// or has a null predicate when the pipeline is +/// built, is substituted. That way replacing an +/// options record wholesale does not silently lose 5xx handling. Set an explicit predicate to +/// override it — including one that handles exceptions only. +/// +/// +public sealed class HttpStandardResilienceOptions +{ + /// + /// Gets or sets the time provider used by every strategy in the pipeline. + /// + /// + /// Seeded from the container's if one is registered, so + /// injecting a FakeTimeProvider is enough to control timing in tests. Setting this + /// explicitly wins over the container. + /// + public TimeProvider TimeProvider { get; set; } = TimeProvider.System; + + /// + /// Gets or sets the rate limiter applied before anything else, or to + /// omit rate limiting. + /// + /// + /// Null by default, which is a deliberate deviation from + /// Microsoft.Extensions.Http.Resilience 8.x — it enabled a 1000-permit concurrency + /// limiter by default, and 9.x removed it again. A limiter's lifetime belongs to whoever + /// created it (RateLimiterStrategyOptions.RateLimiter takes an instance and the pipeline + /// never disposes it), so defaulting it on would mean this package silently owning a limiter + /// and throttling any application above its permit count. Opt in explicitly: + /// + /// var limiter = new ConcurrencyLimiter(new() { PermitLimit = 1000, QueueLimit = 0 }); + /// services.AddSingleton(limiter); // the container disposes it + /// services.AddHttpClient("api").AddStandardResilienceHandler((o, sp) => + /// o.RateLimiter = new RateLimiterStrategyOptions + /// { + /// RateLimiter = sp.GetRequiredService<ConcurrencyLimiter>(), + /// }); + /// + /// + public RateLimiterStrategyOptions? RateLimiter { get; set; } + + /// + /// Gets or sets the total timeout across all retries. Defaults to 30 seconds. + /// + public TimeoutStrategyOptions TotalRequestTimeout { get; set; } = + new() { Timeout = TimeSpan.FromSeconds(30) }; + + /// + /// Gets or sets the retry options. Defaults to 3 attempts with jittered exponential backoff + /// from 2 seconds. + /// + public RetryStrategyOptions Retry { get; set; } = new() + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(2)), + UseJitter = true, + }; + + /// + /// Gets or sets the circuit breaker options. Defaults to a 10% failure ratio over a 30 second + /// window with a minimum throughput of 100, breaking for 5 seconds. + /// + public CircuitBreakerStrategyOptions CircuitBreaker { get; set; } = new() + { + FailureRatioThreshold = 0.1, + MinimumThroughput = 100, + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(5), + }; + + /// + /// Gets or sets the per-attempt timeout, innermost in the pipeline. Defaults to 10 seconds. + /// + public TimeoutStrategyOptions AttemptTimeout { get; set; } = + new() { Timeout = TimeSpan.FromSeconds(10) }; + + /// + /// Gets or sets whether each attempt sends the caller's request or an independent clone. + /// Defaults to . + /// + public HttpRequestReplay RequestReplay { get; set; } = HttpRequestReplay.ReuseRequest; + + /// Validates the options, throwing if they cannot produce a sensible pipeline. + internal void Validate() + { + ArgumentNullException.ThrowIfNull(TimeProvider); + ArgumentNullException.ThrowIfNull(TotalRequestTimeout); + ArgumentNullException.ThrowIfNull(Retry); + ArgumentNullException.ThrowIfNull(CircuitBreaker); + ArgumentNullException.ThrowIfNull(AttemptTimeout); + + // A per-attempt timeout longer than the total can never fire, so the caller has almost + // certainly swapped the two. Failing at build time beats silently ignoring one of them. + if (AttemptTimeout.Timeout != System.Threading.Timeout.InfiniteTimeSpan + && TotalRequestTimeout.Timeout != System.Threading.Timeout.InfiniteTimeSpan + && AttemptTimeout.Timeout > TotalRequestTimeout.Timeout) + { + throw new InvalidOperationException( + $"AttemptTimeout ({AttemptTimeout.Timeout}) is longer than TotalRequestTimeout " + + $"({TotalRequestTimeout.Timeout}), so it can never fire. The attempt timeout " + + "bounds one try; the total timeout bounds the whole operation including retries."); + } + } +} diff --git a/src/Resilion.Http/Internal/HttpAttemptTracker.cs b/src/Resilion.Http/Internal/HttpAttemptTracker.cs new file mode 100644 index 0000000..43247f4 --- /dev/null +++ b/src/Resilion.Http/Internal/HttpAttemptTracker.cs @@ -0,0 +1,119 @@ +using System.Net.Http; + +namespace Resilion.Http.Internal; + +/// +/// Tracks the produced by each attempt so the ones that lose are +/// disposed rather than leaked. +/// +/// +/// The pipeline discards non-final outcomes without disposing them: a retry overwrites its outcome +/// each iteration, and hedging returns the first success and abandons the rest. An undisposed +/// response holds its connection, so under retry-heavy traffic this exhausts the pool. +/// +/// This cannot be done with OnRetry, because ResilienceEventHandler's invocation is +/// internal to Resilion — a user-supplied handler can only be replaced, not wrapped — so the +/// handler has to own disposal itself. +/// +/// +internal sealed class HttpAttemptTracker +{ + private readonly object _gate = new(); + private readonly bool _disposePreviousEagerly; + + private int _attempts = -1; + private HttpResponseMessage? _last; + private List? _others; + private bool _closed; + + /// + /// When attempts cannot overlap, dispose the previous response as soon as the next attempt + /// starts rather than waiting for the pipeline to finish. With three retries and exponential + /// backoff, "at the end" can be tens of seconds of a connection held for nothing. + /// + internal HttpAttemptTracker(bool disposePreviousEagerly) + => _disposePreviousEagerly = disposePreviousEagerly; + + /// Marks the start of an attempt and returns its 0-based index. + internal int BeginAttempt() + { + var index = Interlocked.Increment(ref _attempts); + + if (_disposePreviousEagerly && index > 0) + { + HttpResponseMessage? previous; + lock (_gate) + { + previous = _last; + _last = null; + } + + previous?.Dispose(); + } + + return index; + } + + /// Records a response produced by an attempt. + internal void Track(HttpResponseMessage response) + { + lock (_gate) + { + if (!_closed) + { + if (_disposePreviousEagerly) + { + _last = response; + } + else + { + (_others ??= []).Add(response); + } + + return; + } + } + + // Closed means the handler has already returned, so nobody will ever read this. Hedging + // cancels losers and waits only for a bounded period before returning, so a straggler can + // land here — dispose on the spot rather than leaking it. + response.Dispose(); + } + + /// + /// Disposes every tracked response except and refuses further + /// tracking. + /// + internal void CloseAndDisposeAllExcept(HttpResponseMessage? winner) + { + HttpResponseMessage? last; + List? others; + + lock (_gate) + { + _closed = true; + last = _last; + others = _others; + _last = null; + _others = null; + } + + if (last is not null && !ReferenceEquals(last, winner)) + { + last.Dispose(); + } + + if (others is null) + { + return; + } + + foreach (var response in others) + { + if (!ReferenceEquals(response, winner)) + { + response.Dispose(); + } + } + } +} diff --git a/src/Resilion.Http/Internal/HttpPipelineFactory.cs b/src/Resilion.Http/Internal/HttpPipelineFactory.cs new file mode 100644 index 0000000..e1923fd --- /dev/null +++ b/src/Resilion.Http/Internal/HttpPipelineFactory.cs @@ -0,0 +1,126 @@ +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Resilion.RateLimiting; + +namespace Resilion.Http.Internal; + +/// +/// Builds the HTTP resilience pipelines from their options. +/// +internal static class HttpPipelineFactory +{ + /// + /// Configures the standard pipeline: RateLimiter → TotalRequestTimeout → Retry → + /// CircuitBreaker → AttemptTimeout. + /// + /// + /// Runs inside the registry's lazy factory, so the caller's + /// delegate is invoked once, on first use of the client, not at registration time. + /// + /// This order passes Resilion's ordering validation with no errors and no warnings, so + /// ThrowOnOrderingErrors is left at its default and warnings are not suppressed — if a + /// future change makes the shape questionable, the build should say so. + /// + /// + internal static void ConfigureStandard( + PipelineBuilder builder, + Action? configure, + IServiceProvider services) + { + var options = new HttpStandardResilienceOptions + { + TimeProvider = services.GetService() ?? TimeProvider.System, + }; + + configure?.Invoke(options, services); + options.Validate(); + + // Must precede every Add*: each extension captures builder.TimeProvider at add time into + // the strategy it creates, so setting it afterwards would have no effect. + builder.TimeProvider = options.TimeProvider; + + if (options.RateLimiter is not null) + { + builder.AddRateLimiter(options.RateLimiter); + } + + builder.AddTimeout(options.TotalRequestTimeout); + builder.AddRetry(WithHttpDefaults(options.Retry)); + builder.AddCircuitBreaker(WithHttpDefaults(options.CircuitBreaker)); + builder.AddTimeout(options.AttemptTimeout); + + // Do not touch builder.Name — the registry already set it to the pipeline key, which is + // what makes the pipeline.name telemetry tag read "/standard". + } + + /// + /// Configures the standard hedging pipeline: RateLimiter → TotalRequestTimeout → Hedging → + /// CircuitBreaker → AttemptTimeout. + /// + internal static void ConfigureHedging( + PipelineBuilder builder, + Action? configure, + IServiceProvider services) + { + var options = new HttpStandardHedgingOptions + { + TimeProvider = services.GetService() ?? TimeProvider.System, + }; + + configure?.Invoke(options, services); + options.Validate(); + + builder.TimeProvider = options.TimeProvider; + + if (options.RateLimiter is not null) + { + builder.AddRateLimiter(options.RateLimiter); + } + + builder.AddTimeout(options.TotalRequestTimeout); + builder.AddHedging(WithHttpDefaults(options.Hedging)); + builder.AddCircuitBreaker(WithHttpDefaults(options.CircuitBreaker)); + builder.AddTimeout(options.AttemptTimeout); + } + + /// + /// Configures a caller-defined pipeline, pre-seeding the time provider from the container so + /// a registered TimeProvider works here too. + /// + internal static void ConfigureCustom( + PipelineBuilder builder, + Action, IServiceProvider> configure, + IServiceProvider services) + { + builder.TimeProvider = services.GetService() ?? TimeProvider.System; + configure(builder, services); + } + + /// + /// Substitutes the HTTP transient predicate when the caller left ShouldHandle null. + /// + /// + /// This is the single place the HTTP default is applied. Without it, replacing an options + /// record wholesale — o.Retry = new() { MaxRetryAttempts = 5 } — would silently drop + /// 5xx/408/429 handling and leave a retry that only fires on exceptions. + /// + private static RetryStrategyOptions WithHttpDefaults( + RetryStrategyOptions options) + => options.ShouldHandle is null + ? options with { ShouldHandle = HttpResiliencePredicates.IsTransient } + : options; + + /// + private static CircuitBreakerStrategyOptions WithHttpDefaults( + CircuitBreakerStrategyOptions options) + => options.ShouldHandle is null + ? options with { ShouldHandle = HttpResiliencePredicates.IsTransient } + : options; + + /// + private static HedgingStrategyOptions WithHttpDefaults( + HedgingStrategyOptions options) + => options.ShouldHandle is null + ? options with { ShouldHandle = HttpResiliencePredicates.IsTransient } + : options; +} diff --git a/src/Resilion.Http/Internal/HttpRequestMessageCloner.cs b/src/Resilion.Http/Internal/HttpRequestMessageCloner.cs new file mode 100644 index 0000000..051c5d8 --- /dev/null +++ b/src/Resilion.Http/Internal/HttpRequestMessageCloner.cs @@ -0,0 +1,69 @@ +using System.Net.Http; + +namespace Resilion.Http.Internal; + +/// +/// Produces an independent copy of an so each attempt can send its +/// own. +/// +internal static class HttpRequestMessageCloner +{ + /// + /// Clones , giving the copy its own content built from + /// . + /// + /// The request to copy. + /// + /// The body bytes captured once up front, or when the source has no + /// content. Sharing one array across concurrent instances is + /// safe: it carries no mutable position. + /// + internal static HttpRequestMessage Clone(HttpRequestMessage source, byte[]? body) + { + // A real check, not Debug.Assert. Violating this silently drops the caller's request body, + // and Debug.Assert is compiled out of the Release build that ships — which is exactly how + // an earlier version of the handler shipped a body-dropping retry past a Release test run. + if (body is null && source.Content is not null) + { + throw new InvalidOperationException( + "Cannot clone a request with content without its buffered body. This is a bug in " + + "Resilion.Http: the handler must buffer the body before executing the pipeline " + + "whenever HttpRequestReplay.CloneRequest is in effect."); + } + + var clone = new HttpRequestMessage(source.Method, source.RequestUri) + { + Version = source.Version, + VersionPolicy = source.VersionPolicy, + }; + + // TryAddWithoutValidation: these values already passed validation on the source, and + // re-validating can reject headers a server legitimately sent us. + foreach (var header in source.Headers) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + foreach (var option in (IDictionary)source.Options) + { + ((IDictionary)clone.Options)[option.Key] = option.Value; + } + + if (body is not null) + { + var content = new ByteArrayContent(body); + + if (source.Content is not null) + { + foreach (var header in source.Content.Headers) + { + content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + clone.Content = content; + } + + return clone; + } +} diff --git a/src/Resilion.Http/Internal/HttpResilienceKeys.cs b/src/Resilion.Http/Internal/HttpResilienceKeys.cs new file mode 100644 index 0000000..526d727 --- /dev/null +++ b/src/Resilion.Http/Internal/HttpResilienceKeys.cs @@ -0,0 +1,17 @@ +using System.Net.Http; + +namespace Resilion.Http.Internal; + +/// +/// Well-known property keys used by the HTTP handlers. +/// +/// +/// Internal on purpose: exposing the key would make its string a permanent part of the public API. +/// Callers reach the request through +/// instead. +/// +internal static class HttpResilienceKeys +{ + internal static readonly ResiliencePropertyKey RequestMessage = + new("Resilion.Http.RequestMessage"); +} diff --git a/src/Resilion.Http/Internal/HttpResilienceRootProvider.cs b/src/Resilion.Http/Internal/HttpResilienceRootProvider.cs new file mode 100644 index 0000000..8f85187 --- /dev/null +++ b/src/Resilion.Http/Internal/HttpResilienceRootProvider.cs @@ -0,0 +1,47 @@ +namespace Resilion.Http.Internal; + +/// +/// Captures the root so a pipeline built lazily can resolve +/// services without capturing a scoped provider. +/// +/// +/// builds handler chains inside a dedicated scope +/// whose lifetime is the handler's — roughly two minutes by default. Capturing that provider in a +/// singleton pipeline would be a captive dependency that outlives its scope. Registering this as a +/// singleton means it is activated in the root scope, so the provider it receives is the root one. +/// +internal sealed class HttpResilienceRootProvider +{ + // Public, though the type is internal: the DI container activates by reflection and requires + // a public constructor. Internal visibility of the type is what keeps this off the public API. + public HttpResilienceRootProvider(IServiceProvider services) => Services = services; + + internal IServiceProvider Services { get; } +} + +/// +/// A one-field holder bridging registration time to resolution time. +/// +/// +/// ResiliencePipelineRegistry's factory signature has no , so +/// a pipeline that needs one has to reach it some other way. The handler factory assigns +/// immediately before the GetPipeline call that can trigger the lazy +/// build, which is the only thing that reads it — so the ordering is deterministic rather than +/// hopeful. Concurrent first-resolution from two clients writes the same root provider, making the +/// race benign. +/// +internal sealed class RootProviderAccessor +{ + internal IServiceProvider? Services { get; set; } +} + +/// +/// Marks a pipeline key as already claimed, so a duplicate registration is caught at registration +/// time rather than surfacing later as a confusing failure from the registry. +/// +internal sealed class ResilionHttpPipelineMarker +{ + internal ResilionHttpPipelineMarker(string pipelineKey) => PipelineKey = pipelineKey; + + internal string PipelineKey { get; } +} diff --git a/src/Resilion.Http/Internal/ResilienceHandler.cs b/src/Resilion.Http/Internal/ResilienceHandler.cs new file mode 100644 index 0000000..25431fb --- /dev/null +++ b/src/Resilion.Http/Internal/ResilienceHandler.cs @@ -0,0 +1,184 @@ +using System.Net.Http; + +namespace Resilion.Http.Internal; + +/// +/// Executes each outbound request through a of +/// . +/// +internal sealed class ResilienceHandler : DelegatingHandler +{ + private readonly Pipeline _pipeline; + private readonly HttpRequestReplay _replay; + private readonly bool _sequentialAttempts; + + /// The pipeline to execute each request through. + /// Whether each attempt sends the original request or a clone. + /// + /// when attempts cannot overlap, which lets a discarded response be + /// disposed as soon as the next attempt begins rather than at the end of the pipeline. + /// + internal ResilienceHandler( + Pipeline pipeline, + HttpRequestReplay replay, + bool sequentialAttempts) + { + _pipeline = pipeline; + _replay = replay; + _sequentialAttempts = sequentialAttempts; + } + + /// + /// The state handed to the pipeline callback. + /// + /// + /// A struct passed as TState with a static lambda, so executing a request adds no + /// closure allocation of its own on top of the pipeline's. + /// + private readonly struct SendState + { + internal SendState( + ResilienceHandler handler, + HttpRequestMessage request, + byte[]? bufferedContent, + HttpAttemptTracker tracker) + { + Handler = handler; + Request = request; + BufferedContent = bufferedContent; + Tracker = tracker; + } + + internal ResilienceHandler Handler { get; } + + internal HttpRequestMessage Request { get; } + + internal byte[]? BufferedContent { get; } + + internal HttpAttemptTracker Tracker { get; } + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + return SendCoreAsync(request, cancellationToken); + } + + /// + /// Synchronous sending is not supported. + /// + /// + /// This override is mandatory, not defensive. forwards + /// straight to the inner handler, so without it a caller using HttpClient.Send would + /// silently bypass the entire resilience pipeline and believe it was protected. + /// + /// Throwing rather than implementing it: has no synchronous + /// ExecuteOutcome, and the outcome is what identifies the winning response so the losers + /// can be disposed. A sync path would therefore either leak responses or behave differently + /// from the async one. + /// + /// + protected override HttpResponseMessage Send( + HttpRequestMessage request, + CancellationToken cancellationToken) + => throw new NotSupportedException( + "Resilion's HTTP resilience handler does not support synchronous HttpClient.Send. " + + "Use SendAsync / the async HttpClient methods."); + + private async Task SendCoreAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + byte[]? buffered = null; + if (_replay == HttpRequestReplay.CloneRequest && request.Content is not null) + { + // Buffered outside the pipeline deliberately: reading the caller's body is not an + // attempt, so it should not consume the total timeout or be retried. + buffered = await request.Content + .ReadAsByteArrayAsync(cancellationToken) + .ConfigureAwait(false); + } + + var tracker = new HttpAttemptTracker(disposePreviousEagerly: _sequentialAttempts); + + // Rent rather than let the pipeline own the context: ExecuteOutcomeAsync is caller-owned, + // and Rent(token) is the only way to set the execution's cancellation token. + var context = ResilienceContextPool.Shared.Rent(cancellationToken); + + // The HTTP method, not the URI: bounded cardinality for the operation.key telemetry tag, + // and interned so it costs nothing. + context.OperationKey = request.Method.Method; + context.Properties.Set(HttpResilienceKeys.RequestMessage, request); + + HttpResponseMessage? winner = null; + try + { + var outcome = await _pipeline.ExecuteOutcomeAsync( + static (state, ctx) => state.Handler.SendAttemptAsync(state, ctx), + new SendState(this, request, buffered, tracker), + context).ConfigureAwait(false); + + winner = outcome.IsSuccess ? outcome.GetResultOrDefault() : null; + return outcome.ThrowIfFailed(); + } + finally + { + // Covers strategies that throw rather than returning an outcome, too — + // ExecuteOutcomeAsync does not wrap those. + tracker.CloseAndDisposeAllExcept(winner); + + // Reset() clears Properties, so the request does not outlive the call via the pool. + ResilienceContextPool.Shared.Return(context); + } + } + + private async ValueTask> SendAttemptAsync( + SendState state, + ResilienceContext context) + { + // Return value unused: the index only mattered to the old per-attempt branch. The call + // itself is load-bearing — it is what triggers eager disposal of the previous response. + state.Tracker.BeginAttempt(); + + // ReuseRequest reuses the original on EVERY attempt, not just the first. Cloning a + // later attempt here would clone against a null buffered body — this mode never buffers — + // and silently send an empty request, which is why the condition is on the mode alone. + var message = _replay == HttpRequestReplay.ReuseRequest + ? state.Request + : HttpRequestMessageCloner.Clone(state.Request, state.BufferedContent); + + + // Overwritten per attempt so GetRequestMessage() from a callback sees this attempt's + // message rather than the first one's. + context.Properties.Set(HttpResilienceKeys.RequestMessage, message); + + try + { + // context.CancellationToken, not the caller's: an enclosing Timeout strategy replaces + // it with its own linked token, which is how per-attempt timeouts actually bite. + var response = await base + .SendAsync(message, context.CancellationToken) + .ConfigureAwait(context.ContinueOnCapturedContext); + + state.Tracker.Track(response); + return Outcome.FromResult(response); + } + catch (Exception ex) + { + // Returned as an outcome rather than rethrown, including OperationCanceledException: + // the Timeout strategy inspects the outcome to decide whether a cancellation was its + // own timeout or the caller's, and it cannot do that if the exception unwinds past it. + return Outcome.FromException(ex); + } + finally + { + if (!ReferenceEquals(message, state.Request)) + { + // Disposes the clone and its ByteArrayContent; the shared body array is untouched. + message.Dispose(); + } + } + } +} diff --git a/src/Resilion.Http/PublicAPI.Shipped.txt b/src/Resilion.Http/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/Resilion.Http/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Resilion.Http/PublicAPI.Unshipped.txt b/src/Resilion.Http/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..a351dbc --- /dev/null +++ b/src/Resilion.Http/PublicAPI.Unshipped.txt @@ -0,0 +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/Resilion.Http.csproj b/src/Resilion.Http/Resilion.Http.csproj new file mode 100644 index 0000000..b54c8c5 --- /dev/null +++ b/src/Resilion.Http/Resilion.Http.csproj @@ -0,0 +1,26 @@ + + + + Resilion.Http + Resilion.Http + Resilion.Http + HttpClient and IHttpClientFactory resilience for Resilion. AddStandardResilienceHandler, AddStandardHedgingHandler, and custom per-client resilience pipelines. + + false + + + + + + + + + + + + + + + + + diff --git a/src/Resilion.Http/ResilionHttpClientBuilderExtensions.cs b/src/Resilion.Http/ResilionHttpClientBuilderExtensions.cs new file mode 100644 index 0000000..f415189 --- /dev/null +++ b/src/Resilion.Http/ResilionHttpClientBuilderExtensions.cs @@ -0,0 +1,334 @@ +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Resilion; +using Resilion.Extensions; +using Resilion.Http; +using Resilion.Http.Internal; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Adds Resilion resilience pipelines to an . +/// +/// +/// No method here takes an optional parameter, and that is deliberate: adding a parameter to a +/// method that already has one is source-compatible but binary-breaking, so a shared +/// internal package that wraps these and ships as a binary would need recompiling. Distinct +/// overloads cost nothing at the call site — AddStandardResilienceHandler() still compiles — +/// and keep RS0026/RS0027 clean on a surface that is about to be frozen. +/// +/// Deliberately in the Microsoft.Extensions.DependencyInjection namespace, which is in +/// ASP.NET Core's implicit usings, so these methods are discoverable with no extra using — +/// the convention for extensions. This differs from +/// Resilion.RateLimiting, which keeps its extensions in its own namespace. +/// +/// +public static class ResilionHttpClientBuilderExtensions +{ + /// + /// Adds the standard resilience pipeline to the client: rate limiter (off by default), total + /// timeout, retry, circuit breaker, and per-attempt timeout. + /// + /// The HTTP client builder. + /// The builder for chaining. + public static IHttpClientBuilder AddStandardResilienceHandler(this IHttpClientBuilder builder) + => AddStandardResilienceHandlerCore(builder, configure: null); + + /// + /// Adds the standard resilience pipeline to the client, adjusting the defaults. + /// + /// The HTTP client builder. + /// Adjusts the defaults. + /// The builder for chaining. + public static IHttpClientBuilder AddStandardResilienceHandler( + this IHttpClientBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + return AddStandardResilienceHandlerCore(builder, (options, _) => configure(options)); + } + + /// + /// Adds the standard resilience pipeline to the client, with access to the service provider + /// when configuring it. + /// + /// The HTTP client builder. + /// + /// Adjusts the defaults. The is the root + /// provider, so resolving a scoped service from it is a captive dependency — resolve + /// singletons only. + /// + /// The builder for chaining. + public static IHttpClientBuilder AddStandardResilienceHandler( + this IHttpClientBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + return AddStandardResilienceHandlerCore(builder, configure); + } + + private static IHttpClientBuilder AddStandardResilienceHandlerCore( + IHttpClientBuilder builder, + Action? configure) + { + ArgumentNullException.ThrowIfNull(builder); + + var pipelineKey = PipelineKey(builder, "standard"); + var accessor = Register( + builder, + pipelineKey, + acc => b => HttpPipelineFactory.ConfigureStandard( + b, configure, AccessorServices(acc, pipelineKey))); + + // RequestReplay lives on the options, which are not materialised until the pipeline is + // built — but the handler needs it at construction. Resolve it by building the options + // once more here rather than reaching into the pipeline. + return AddHandler(builder, pipelineKey, accessor, sequentialAttempts: true, sp => + { + var probe = new HttpStandardResilienceOptions + { + TimeProvider = sp.GetService() ?? TimeProvider.System, + }; + configure?.Invoke(probe, sp); + return probe.RequestReplay; + }); + } + + /// + /// Adds the standard hedging pipeline to the client: rate limiter (off by default), total + /// timeout, hedging, circuit breaker, and per-attempt timeout. + /// + /// The HTTP client builder. + /// The builder for chaining. + /// + /// Always clones the request per attempt, and therefore always buffers the request body. + /// + public static IHttpClientBuilder AddStandardHedgingHandler(this IHttpClientBuilder builder) + => AddStandardHedgingHandlerCore(builder, configure: null); + + /// + /// Adds the standard hedging pipeline to the client, adjusting the defaults. + /// + /// The HTTP client builder. + /// Adjusts the defaults. + /// The builder for chaining. + /// + /// Always clones the request per attempt, and therefore always buffers the request body. + /// + public static IHttpClientBuilder AddStandardHedgingHandler( + this IHttpClientBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + return AddStandardHedgingHandlerCore(builder, (options, _) => configure(options)); + } + + /// + /// Adds the standard hedging pipeline to the client, with access to the service provider when + /// configuring it. + /// + /// The HTTP client builder. + /// + /// Adjusts the defaults. The is the root + /// provider. + /// + /// The builder for chaining. + public static IHttpClientBuilder AddStandardHedgingHandler( + this IHttpClientBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + return AddStandardHedgingHandlerCore(builder, configure); + } + + private static IHttpClientBuilder AddStandardHedgingHandlerCore( + IHttpClientBuilder builder, + Action? configure) + { + ArgumentNullException.ThrowIfNull(builder); + + var pipelineKey = PipelineKey(builder, "hedging"); + var accessor = Register( + builder, + pipelineKey, + acc => b => HttpPipelineFactory.ConfigureHedging( + b, configure, AccessorServices(acc, pipelineKey))); + + // Hedged attempts can overlap, so cloning is mandatory and eager disposal is unsafe. + return AddHandler( + builder, pipelineKey, accessor, + sequentialAttempts: false, + _ => HttpRequestReplay.CloneRequest); + } + + /// + /// Adds a custom resilience pipeline to the client under the given key. + /// + /// The HTTP client builder. + /// A name for this pipeline, unique within the client. + /// Builds the pipeline. + /// The builder for chaining. + public static IHttpClientBuilder AddResilienceHandler( + this IHttpClientBuilder builder, + string key, + Action> configure) + => builder.AddResilienceHandler(key, configure, HttpRequestReplay.ReuseRequest); + + /// + /// Adds a custom resilience pipeline to the client under the given key, choosing how each + /// attempt obtains its request. + /// + /// The HTTP client builder. + /// A name for this pipeline, unique within the client. + /// Builds the pipeline. + /// Whether each attempt sends the original request or a clone. + /// The builder for chaining. + public static IHttpClientBuilder AddResilienceHandler( + this IHttpClientBuilder builder, + string key, + Action> configure, + HttpRequestReplay requestReplay) + { + ArgumentNullException.ThrowIfNull(configure); + return builder.AddResilienceHandler(key, (b, _) => configure(b), requestReplay); + } + + /// + /// Adds a custom resilience pipeline to the client under the given key, with access to the + /// service provider when building it. + /// + /// The HTTP client builder. + /// A name for this pipeline, unique within the client. + /// + /// Builds the pipeline. The is the root + /// provider. + /// + /// The builder for chaining. + public static IHttpClientBuilder AddResilienceHandler( + this IHttpClientBuilder builder, + string key, + Action, IServiceProvider> configure) + => builder.AddResilienceHandler(key, configure, HttpRequestReplay.ReuseRequest); + + /// + /// Adds a custom resilience pipeline to the client under the given key, with access to the + /// service provider and an explicit request-replay mode. + /// + /// The HTTP client builder. + /// A name for this pipeline, unique within the client. + /// + /// Builds the pipeline. The is the root + /// provider. + /// + /// Whether each attempt sends the original request or a clone. + /// The builder for chaining. + public static IHttpClientBuilder AddResilienceHandler( + this IHttpClientBuilder builder, + string key, + Action, IServiceProvider> configure, + HttpRequestReplay requestReplay) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(configure); + + var pipelineKey = PipelineKey(builder, key); + var accessor = Register( + builder, + pipelineKey, + acc => b => HttpPipelineFactory.ConfigureCustom( + b, configure, AccessorServices(acc, pipelineKey))); + + // A caller-built pipeline may contain hedging, so attempts cannot be assumed sequential. + return AddHandler(builder, pipelineKey, accessor, sequentialAttempts: false, _ => requestReplay); + } + + /// + /// Keys the pipeline by client name and kind, e.g. "api/standard". + /// + /// + /// The slash cannot appear in a C# identifier-shaped client name, so this is unlikely to + /// collide with a hand-registered pipeline key. Typed pipelines are additionally keyed by + /// result type in the registry, so even an identical key registered for a different + /// TResult is a separate entry. + /// + private static string PipelineKey(IHttpClientBuilder builder, string kind) + => $"{builder.Name}/{kind}"; + + /// + /// Registers the pipeline, handing the caller's factory an accessor it closes over. + /// + private static RootProviderAccessor Register( + IHttpClientBuilder builder, + string pipelineKey, + Func>> configureFactory) + { + ThrowIfKeyAlreadyRegistered(builder, pipelineKey); + + var accessor = new RootProviderAccessor(); + + builder.Services.TryAddSingleton(); + builder.Services.AddSingleton(new ResilionHttpPipelineMarker(pipelineKey)); + builder.Services.AddResiliencePipeline(pipelineKey, configureFactory(accessor)); + + return accessor; + } + + private static IHttpClientBuilder AddHandler( + IHttpClientBuilder builder, + string pipelineKey, + RootProviderAccessor accessor, + bool sequentialAttempts, + Func resolveReplay) + { + 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; + + var pipeline = sp.GetRequiredService>() + .GetPipeline(pipelineKey); + + return new ResilienceHandler(pipeline, resolveReplay(accessor.Services), sequentialAttempts); + }); + + return builder; + } + + /// + /// Reads the root provider an accessor is holding, failing loudly if it is not there yet. + /// + /// + /// The accessor is captured by the registration closure rather than looked up in a static + /// map. An earlier version keyed a static dictionary by pipeline key, which collides across + /// service collections: two containers in one process registering the same client name would + /// overwrite each other's accessor and cross-wire their root providers. + /// + private static IServiceProvider AccessorServices(RootProviderAccessor accessor, string pipelineKey) + => accessor.Services + ?? throw new InvalidOperationException( + $"The root service provider for pipeline '{pipelineKey}' was not available when " + + "the pipeline was built. This should be unreachable: the handler factory assigns " + + "it immediately before resolving the pipeline."); + + /// + /// Fails at registration time on a duplicate key, rather than letting the registry throw on + /// first resolve where the error is far from its cause. + /// + private static void ThrowIfKeyAlreadyRegistered(IHttpClientBuilder builder, string pipelineKey) + { + foreach (var descriptor in builder.Services) + { + if (descriptor.ServiceType == typeof(ResilionHttpPipelineMarker) + && descriptor.ImplementationInstance is ResilionHttpPipelineMarker marker + && marker.PipelineKey == pipelineKey) + { + throw new InvalidOperationException( + $"A Resilion resilience handler is already registered for HTTP client " + + $"'{builder.Name}' under the key '{pipelineKey}'. Use AddResilienceHandler " + + "with a distinct key to add a second pipeline to the same client."); + } + } + } +} diff --git a/src/Resilion.RateLimiting/PublicAPI.Shipped.txt b/src/Resilion.RateLimiting/PublicAPI.Shipped.txt index 71c9683..5333313 100644 --- a/src/Resilion.RateLimiting/PublicAPI.Shipped.txt +++ b/src/Resilion.RateLimiting/PublicAPI.Shipped.txt @@ -1,4 +1,18 @@ -# This file tracks the public API surface of Resilion.RateLimiting v1.0. -# It will be generated after v1.0 is shipped using PublicApiAnalyzers. -# Until then, it serves as a placeholder to enable the analyzer. -# See: https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md +#nullable enable +Resilion.RateLimiting.OnRateLimitRejectedEvent +Resilion.RateLimiting.OnRateLimitRejectedEvent.Context.get -> Resilion.ResilienceContext! +Resilion.RateLimiting.OnRateLimitRejectedEvent.Context.init -> void +Resilion.RateLimiting.OnRateLimitRejectedEvent.OnRateLimitRejectedEvent() -> void +Resilion.RateLimiting.OnRateLimitRejectedEvent.OnRateLimitRejectedEvent(System.TimeSpan? RetryAfter, Resilion.ResilienceContext! Context) -> void +Resilion.RateLimiting.OnRateLimitRejectedEvent.RetryAfter.get -> System.TimeSpan? +Resilion.RateLimiting.OnRateLimitRejectedEvent.RetryAfter.init -> void +Resilion.RateLimiting.RateLimitRejectedException +Resilion.RateLimiting.RateLimitRejectedException.RetryAfter.get -> System.TimeSpan? +Resilion.RateLimiting.RateLimiterExtensions +Resilion.RateLimiting.RateLimiterStrategyOptions +Resilion.RateLimiting.RateLimiterStrategyOptions.OnRejected.get -> Resilion.ResilienceEventHandler? +Resilion.RateLimiting.RateLimiterStrategyOptions.OnRejected.init -> void +Resilion.RateLimiting.RateLimiterStrategyOptions.RateLimiter.get -> System.Threading.RateLimiting.RateLimiter? +Resilion.RateLimiting.RateLimiterStrategyOptions.RateLimiter.init -> void +static Resilion.RateLimiting.RateLimiterExtensions.AddRateLimiter(this Resilion.PipelineBuilder! builder, Resilion.RateLimiting.RateLimiterStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.RateLimiting.RateLimiterExtensions.AddRateLimiter(this Resilion.PipelineBuilder! builder, Resilion.RateLimiting.RateLimiterStrategyOptions! options) -> Resilion.PipelineBuilder! diff --git a/src/Resilion.RateLimiting/PublicAPI.Unshipped.txt b/src/Resilion.RateLimiting/PublicAPI.Unshipped.txt index 6edd03d..7dc5c58 100644 --- a/src/Resilion.RateLimiting/PublicAPI.Unshipped.txt +++ b/src/Resilion.RateLimiting/PublicAPI.Unshipped.txt @@ -1,23 +1 @@ #nullable enable - -# New public APIs for Resilion.RateLimiting (yet to ship). -# Entries here should be moved to PublicAPI.Shipped.txt upon release. - -namespace Resilion.RateLimiting; - -OnRateLimitRejectedEvent -OnRateLimitRejectedEvent.RetryAfter.get -> System.TimeSpan? -OnRateLimitRejectedEvent.Context.get -> Resilion.ResilienceContext! - -RateLimitRejectedException -RateLimitRejectedException.RetryAfter.get -> System.TimeSpan? - -RateLimiterExtensions -RateLimiterExtensions.AddRateLimiter(this Resilion.PipelineBuilder! builder, RateLimiterStrategyOptions! options) -> Resilion.PipelineBuilder! -RateLimiterExtensions.AddRateLimiter(this Resilion.PipelineBuilder! builder, RateLimiterStrategyOptions! options) -> Resilion.PipelineBuilder! - -RateLimiterStrategyOptions -RateLimiterStrategyOptions.RateLimiter.get -> System.Threading.RateLimiting.RateLimiter? -RateLimiterStrategyOptions.RateLimiter.init -> void -RateLimiterStrategyOptions.OnRejected.get -> Resilion.ResilienceEventHandler? -RateLimiterStrategyOptions.OnRejected.init -> void diff --git a/src/Resilion.RateLimiting/RateLimiterStrategy.cs b/src/Resilion.RateLimiting/RateLimiterStrategy.cs index e26648e..d0d50d7 100644 --- a/src/Resilion.RateLimiting/RateLimiterStrategy.cs +++ b/src/Resilion.RateLimiting/RateLimiterStrategy.cs @@ -19,13 +19,7 @@ protected internal override async ValueTask> ExecuteAsync>> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("RateLimiter"); - if (activity is not null) - { - activity.SetTag("strategy.name", "RateLimiter"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("RateLimiter", context); var lease = await _options.RateLimiter!.AcquireAsync( permitCount: 1, @@ -36,18 +30,12 @@ protected internal override async ValueTask> ExecuteAsync(lease, context).ConfigureAwait(false); - if (activity is not null) - { - activity.SetTag("outcome", "rejected"); - } + StrategyActivity.SetOutcome(activity, "rejected"); return result; } var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } finally @@ -60,13 +48,7 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("RateLimiter"); - if (activity is not null) - { - activity.SetTag("strategy.name", "RateLimiter"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("RateLimiter", context); // Sync acquire — AttemptAcquire does not wait in a queue. using var lease = _options.RateLimiter!.AttemptAcquire(permitCount: 1); @@ -74,18 +56,12 @@ protected internal override Outcome Execute( if (!lease.IsAcquired) { var result = HandleRejectionSync(lease, context); - if (activity is not null) - { - activity.SetTag("outcome", "rejected"); - } + StrategyActivity.SetOutcome(activity, "rejected"); return result; } var outcome = callback(context); - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs index d9d84ab..9f7d038 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs @@ -38,7 +38,8 @@ internal CircuitBreakerStateMachine( ResilienceEventHandler? onOpened, ResilienceEventHandler? onClosed, ResilienceEventHandler? onHalfOpened, - CircuitBreakerManualControl? manualControl) + CircuitBreakerManualControl? manualControl, + CircuitBreakerStateProvider? stateProvider) { _failureRatioThreshold = failureRatioThreshold; _minimumThroughput = minimumThroughput; @@ -54,6 +55,8 @@ internal CircuitBreakerStateMachine( manualControl?.Initialize( onIsolate: () => { Isolate(); return Task.CompletedTask; }, onReset: () => { Reset(); return Task.CompletedTask; }); + + stateProvider?.Initialize(() => _state); } internal CircuitState State => _state; diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStateProvider.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStateProvider.cs new file mode 100644 index 0000000..4c37a3e --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStateProvider.cs @@ -0,0 +1,92 @@ +namespace Resilion; + +/// +/// Exposes the current of the circuit breaker it is bound to. +/// +/// +/// Pass an instance via CircuitBreakerStrategyOptions.StateProvider and read +/// afterwards. Without this there is no way to observe a circuit's state: +/// can isolate and reset a circuit but cannot report it, +/// which forced callers into indirect assertions such as "the next call threw +/// CircuitBrokenException, therefore it must be open". +/// +/// One provider binds to one circuit breaker, matching +/// 's behaviour: reusing an instance across two breakers +/// would silently report one of them, so the second binding throws. +/// +/// +/// Intended for observation — health endpoints, dashboards, logging, and tests. See +/// for why it must not gate execution. +/// +/// +public sealed class CircuitBreakerStateProvider +{ + private readonly object _initLock = new(); + private Func? _getState; + + /// + /// Gets the circuit breaker's state at the moment of the read. + /// + /// + /// Do not use this to decide whether to execute. It is a point-in-time read of + /// a value other threads change concurrently, so anything you conclude from it may already be + /// stale by the next line: + /// + /// // WRONG — the circuit can open between the check and the call, and this also + /// // bypasses the half-open probe that lets the circuit recover. + /// if (provider.State == CircuitState.Closed) + /// { + /// await pipeline.ExecuteAsync(CallDependencyAsync); + /// } + /// + /// // RIGHT — execute and let the strategy reject. It holds the lock that makes the + /// // decision correct, and an open circuit fails fast on its own. + /// try + /// { + /// await pipeline.ExecuteAsync(CallDependencyAsync); + /// } + /// catch (CircuitBrokenException) + /// { + /// // shed load, serve cached data, surface a 503 — whatever degradation suits + /// } + /// + /// The guard is not merely racy, it is counterproductive: skipping the call while the circuit + /// is open also skips the trial call that transitions it to half-open, so a circuit gated this + /// way can stay open after the dependency has recovered. + /// + /// Reading it for a health check, a metric, a log line, or a test assertion is exactly what it + /// is for. + /// + /// + /// + /// This provider is not associated with any circuit breaker strategy. + /// + public CircuitState State + { + get + { + var getState = _getState + ?? throw new InvalidOperationException( + "This CircuitBreakerStateProvider is not associated with a circuit breaker " + + "strategy. Pass it via CircuitBreakerStrategyOptions.StateProvider when " + + "building the pipeline."); + + return getState(); + } + } + + internal void Initialize(Func getState) + { + lock (_initLock) + { + if (_getState is not null) + { + throw new InvalidOperationException( + "This CircuitBreakerStateProvider is already bound to a circuit breaker " + + "strategy. Create a separate instance for each circuit breaker."); + } + + _getState = getState; + } + } +} diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs index 8131877..fcebba1 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs @@ -23,7 +23,8 @@ internal CircuitBreakerStrategy(CircuitBreakerStrategyOptions options, TimeProvi options.OnOpened, options.OnClosed, options.OnHalfOpened, - options.ManualControl); + options.ManualControl, + options.StateProvider); } internal CircuitState State => _machine.State; @@ -32,21 +33,12 @@ protected internal override async ValueTask> ExecuteAsync>> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("CircuitBreaker"); - if (activity is not null) - { - activity.SetTag("strategy.name", "CircuitBreaker"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("CircuitBreaker", context); var rejection = _machine.TryReject(context); if (rejection is not null) { - if (activity is not null) - { - activity.SetTag("outcome", "rejected"); - } + StrategyActivity.SetOutcome(activity, "rejected"); return Outcome.FromException(rejection); } @@ -55,19 +47,13 @@ protected internal override async ValueTask> ExecuteAsync Execute( Func> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("CircuitBreaker"); - if (activity is not null) - { - activity.SetTag("strategy.name", "CircuitBreaker"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("CircuitBreaker", context); var rejection = _machine.TryReject(context); if (rejection is not null) { - if (activity is not null) - { - activity.SetTag("outcome", "rejected"); - } + StrategyActivity.SetOutcome(activity, "rejected"); return Outcome.FromException(rejection); } @@ -98,19 +75,13 @@ protected internal override Outcome Execute( { var outcome = callback(context); _machine.RecordOutcome(IsFailure(outcome.Exception), context); - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } catch (Exception ex) { _machine.RecordOutcome(IsFailure(ex), context); - if (activity is not null) - { - activity.SetTag("outcome", "exception"); - } + StrategyActivity.SetOutcome(activity, "exception"); throw; } } diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs index 62790da..6bd4cd5 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs @@ -65,6 +65,12 @@ public sealed record CircuitBreakerStrategyOptions /// public CircuitBreakerManualControl? ManualControl { get; init; } + /// + /// Gets an optional provider exposing this circuit breaker's current state. Bind one instance + /// per circuit breaker; a second binding throws. + /// + public CircuitBreakerStateProvider? StateProvider { get; init; } + internal void Validate() { if (FailureRatioThreshold is < 0.0 or > 1.0) @@ -141,6 +147,12 @@ public sealed record CircuitBreakerStrategyOptions /// public CircuitBreakerManualControl? ManualControl { get; init; } + /// + /// Gets an optional provider exposing this circuit breaker's current state. Bind one instance + /// per circuit breaker; a second binding throws. + /// + public CircuitBreakerStateProvider? StateProvider { get; init; } + internal void Validate() { if (FailureRatioThreshold is < 0.0 or > 1.0) diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs b/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs index 706b33c..c95a5da 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs @@ -21,7 +21,8 @@ internal CircuitBreakerTypedStrategy(CircuitBreakerStrategyOptions opti options.OnOpened, options.OnClosed, options.OnHalfOpened, - options.ManualControl); + options.ManualControl, + options.StateProvider); } internal CircuitState State => _machine.State; @@ -30,9 +31,12 @@ protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { + using var activity = StrategyActivity.Start("CircuitBreaker", context); + var rejection = _machine.TryReject(context); if (rejection is not null) { + StrategyActivity.SetOutcome(activity, "rejected"); return Outcome.FromException(rejection); } @@ -41,12 +45,14 @@ protected internal override async ValueTask> ExecuteAsync( var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); var isFailure = _options.ShouldHandleOutcome(outcome); await _machine.RecordOutcomeAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); + StrategyActivity.SetOutcome(activity, isFailure ? "failure" : "success"); return outcome; } catch (Exception ex) { var isFailure = _options.ShouldHandleOutcome(Outcome.FromException(ex)); await _machine.RecordOutcomeAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); + StrategyActivity.SetOutcome(activity, "exception"); throw; } } @@ -55,9 +61,12 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { + using var activity = StrategyActivity.Start("CircuitBreaker", context); + var rejection = _machine.TryReject(context); if (rejection is not null) { + StrategyActivity.SetOutcome(activity, "rejected"); return Outcome.FromException(rejection); } @@ -66,12 +75,14 @@ protected internal override Outcome Execute( var outcome = callback(context); var isFailure = _options.ShouldHandleOutcome(outcome); _machine.RecordOutcome(isFailure, context); + StrategyActivity.SetOutcome(activity, isFailure ? "failure" : "success"); return outcome; } catch (Exception ex) { var isFailure = _options.ShouldHandleOutcome(Outcome.FromException(ex)); _machine.RecordOutcome(isFailure, context); + StrategyActivity.SetOutcome(activity, "exception"); throw; } } diff --git a/src/Resilion/Fallback/FallbackStrategy.cs b/src/Resilion/Fallback/FallbackStrategy.cs index e27a7aa..dba908c 100644 --- a/src/Resilion/Fallback/FallbackStrategy.cs +++ b/src/Resilion/Fallback/FallbackStrategy.cs @@ -18,22 +18,13 @@ protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("Fallback"); - if (activity is not null) - { - activity.SetTag("strategy.name", "Fallback"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("Fallback", context); var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); if (!_options.ShouldHandleOutcome(outcome)) { - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } @@ -48,10 +39,7 @@ await handler.InvokeAsync(new OnFallbackEvent(outcome, fallbackResult, .ConfigureAwait(false); } - if (activity is not null) - { - activity.SetTag("outcome", "fallback_applied"); - } + StrategyActivity.SetOutcome(activity, "fallback_applied"); return Outcome.FromResult(fallbackResult); } @@ -60,22 +48,13 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("Fallback"); - if (activity is not null) - { - activity.SetTag("strategy.name", "Fallback"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("Fallback", context); var outcome = callback(context); if (!_options.ShouldHandleOutcome(outcome)) { - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } @@ -89,10 +68,7 @@ protected internal override Outcome Execute( handler.Invoke(new OnFallbackEvent(outcome, fallbackResult, context)); } - if (activity is not null) - { - activity.SetTag("outcome", "fallback_applied"); - } + StrategyActivity.SetOutcome(activity, "fallback_applied"); return Outcome.FromResult(fallbackResult); } diff --git a/src/Resilion/Hedging/HedgingStrategy.cs b/src/Resilion/Hedging/HedgingStrategy.cs index d6cbc31..1544b2f 100644 --- a/src/Resilion/Hedging/HedgingStrategy.cs +++ b/src/Resilion/Hedging/HedgingStrategy.cs @@ -21,22 +21,13 @@ protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("Hedging"); - if (activity is not null) - { - activity.SetTag("strategy.name", "Hedging"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("Hedging", context); if (_options.MaxHedgedAttempts == 1) { // No hedging — just execute the primary. var result = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); - if (activity is not null) - { - activity.SetTag("outcome", result.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, result.IsSuccess ? "success" : "failure"); return result; } @@ -51,8 +42,12 @@ protected internal override async ValueTask> ExecuteAsync( for (var attemptIndex = 1; attemptIndex < _options.MaxHedgedAttempts; attemptIndex++) { + // Resolved per attempt: the delay selects the mode, so a generator can make one + // attempt sequential and the next parallel. See HedgingDelayGenerator's remarks. + var hedgingDelay = ResolveHedgingDelay(attemptIndex, context); + // Wait for the hedging delay, or for the primary/earlier attempt to complete. - if (_options.HedgingDelay == System.Threading.Timeout.InfiniteTimeSpan) + if (hedgingDelay == System.Threading.Timeout.InfiniteTimeSpan) { // Sequential mode: wait for the current attempt to complete before launching next. var completed = await attempts[^1].ConfigureAwait(false); @@ -62,10 +57,10 @@ protected internal override async ValueTask> ExecuteAsync( return completed; } } - else if (_options.HedgingDelay > TimeSpan.Zero) + else if (hedgingDelay > TimeSpan.Zero) { // Latency mode: wait for the delay, but also check if any attempt completes early. - var delayTask = Task.Delay(_options.HedgingDelay, _timeProvider, userToken); + var delayTask = Task.Delay(hedgingDelay, _timeProvider, userToken); var whenAnyTask = Task.WhenAny(attempts); var winner = await Task.WhenAny(whenAnyTask, delayTask).ConfigureAwait(false); @@ -104,10 +99,7 @@ await handler.InvokeAsync(new OnHedgingEvent(attemptIndex, context)) // All attempts launched. Wait for the first success or all to fail. var outcome = await WaitForBestOutcome(attempts, userToken).ConfigureAwait(false); - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "hedging_exhausted"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "hedging_exhausted"); return outcome; } finally @@ -163,13 +155,18 @@ protected internal override Outcome Execute( // Hedging's entire value proposition is running attempts concurrently. The sync path // can only ever run sequentially — silently degrading parallel/latency hedging to // sequential would give the caller zero indication their hedging isn't actually hedging. - if (_options.HedgingDelay != System.Threading.Timeout.InfiniteTimeSpan) + if (_options.HedgingDelayGenerator is not null + || _options.HedgingDelay != System.Threading.Timeout.InfiniteTimeSpan) { throw new InvalidOperationException( - "Parallel and latency hedging modes require async execution. Use ExecuteAsync(), " + - "or set HedgingDelay to Timeout.InfiniteTimeSpan for sequential mode."); + "Parallel and latency hedging modes require async execution, and a " + + "HedgingDelayGenerator cannot be evaluated on the synchronous path. Use " + + "ExecuteAsync(), or set HedgingDelay to Timeout.InfiniteTimeSpan with no " + + "generator for sequential mode."); } + using var activity = StrategyActivity.Start("Hedging", context); + // Sequential mode: execute sequentially (equivalent to InfiniteTimeSpan mode). Outcome lastOutcome = default; @@ -183,6 +180,7 @@ protected internal override Outcome Execute( // Matches async path behavior (ResilienceContextPool.Shared.Rent + CopyFrom). var attemptContext = ResilienceContextPool.Shared.Rent(context.CancellationToken); attemptContext.OperationKey = context.OperationKey; + attemptContext.PipelineName = context.PipelineName; attemptContext.ContinueOnCapturedContext = context.ContinueOnCapturedContext; attemptContext.Properties.CopyFrom(context.Properties); try @@ -196,11 +194,16 @@ protected internal override Outcome Execute( if (!_options.ShouldHandleOutcome(lastOutcome)) { + StrategyActivity.SetOutcome(activity, "success"); return lastOutcome; } if (attemptIndex < _options.MaxHedgedAttempts - 1) { + // Mirror the async path: count the hedge launch regardless of whether an + // 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) { handler.Invoke(new OnHedgingEvent(attemptIndex + 1, context)); @@ -208,9 +211,33 @@ protected internal override Outcome Execute( } } + StrategyActivity.SetOutcome(activity, "hedging_exhausted"); return lastOutcome; } + /// + /// Resolves the delay before launching , preferring the + /// generator over the static value. A negative result other than InfiniteTimeSpan is + /// clamped to zero rather than throwing mid-execution. + /// + private TimeSpan ResolveHedgingDelay(int attemptIndex, ResilienceContext context) + { + if (_options.HedgingDelayGenerator is null) + { + return _options.HedgingDelay; + } + + var delay = _options.HedgingDelayGenerator( + new HedgingDelayGeneratorArgs(attemptIndex, context)); + + if (delay == System.Threading.Timeout.InfiniteTimeSpan) + { + return delay; + } + + return delay < TimeSpan.Zero ? TimeSpan.Zero : delay; + } + private void LaunchAttempt( int attemptIndex, Func>> callback, @@ -272,6 +299,7 @@ private Func>> ResolveAsyncAction( // Create a lightweight context copy with the per-attempt token. var attemptContext = ResilienceContextPool.Shared.Rent(ct); attemptContext.OperationKey = context.OperationKey; + attemptContext.PipelineName = context.PipelineName; attemptContext.ContinueOnCapturedContext = context.ContinueOnCapturedContext; attemptContext.Properties.CopyFrom(context.Properties); try diff --git a/src/Resilion/Hedging/HedgingStrategyOptions.cs b/src/Resilion/Hedging/HedgingStrategyOptions.cs index eec22f5..4e57d0b 100644 --- a/src/Resilion/Hedging/HedgingStrategyOptions.cs +++ b/src/Resilion/Hedging/HedgingStrategyOptions.cs @@ -35,6 +35,28 @@ public sealed record HedgingStrategyOptions /// public TimeSpan HedgingDelay { get; init; } = TimeSpan.FromSeconds(2); + /// + /// Gets an optional generator computing the delay before each hedged attempt. When set, + /// is ignored. + /// + /// + /// Because the delay selects the hedging mode, a generator selects it per attempt: + /// returning makes that attempt wait + /// for its predecessor, launches it immediately, and any positive + /// value waits that long unless an earlier attempt finishes first. That is the point of the + /// feature — for example, hedge the first retry eagerly and later ones only on real latency. + /// + /// A negative result other than InfiniteTimeSpan is clamped to + /// rather than throwing, because failing mid-execution would turn + /// a delay-computation slip into a lost request. + /// + /// + /// Not supported on the synchronous path: Execute throws when this is set, for the same + /// reason it rejects parallel and latency modes. + /// + /// + public Func? HedgingDelayGenerator { get; init; } + /// /// Gets the predicate that determines which outcomes should trigger hedging. /// Defaults to all exceptions except . @@ -61,7 +83,11 @@ internal void Validate() "MaxHedgedAttempts must be >= 1."); } - if (HedgingDelay < TimeSpan.Zero && HedgingDelay != System.Threading.Timeout.InfiniteTimeSpan) + // Skip the static range check when a generator is present: HedgingDelay is then ignored, + // so rejecting its value would refuse a configuration that is entirely consistent. + if (HedgingDelayGenerator is null + && HedgingDelay < TimeSpan.Zero + && HedgingDelay != System.Threading.Timeout.InfiniteTimeSpan) { throw new ArgumentOutOfRangeException(nameof(HedgingDelay), HedgingDelay, "HedgingDelay must be non-negative, TimeSpan.Zero, or Timeout.InfiniteTimeSpan."); @@ -85,6 +111,17 @@ internal bool ShouldHandleOutcome(Outcome outcome) /// The 0-based attempt index (0 = primary, 1 = first hedge, etc.). public readonly record struct HedgingActionContext(int AttemptNumber); +/// +/// Arguments passed to . +/// +/// +/// The 0-based index of the attempt about to be launched, so 1 for the first hedge. +/// Matches and +/// OnHedgingEvent<TResult>.AttemptNumber. +/// +/// The resilience context for the execution. +public readonly record struct HedgingDelayGeneratorArgs(int AttemptNumber, ResilienceContext Context); + /// /// Event arguments for the callback. /// diff --git a/src/Resilion/Internal/PipelineComponent.cs b/src/Resilion/Internal/PipelineComponent.cs index 9e89dfc..db690a7 100644 --- a/src/Resilion/Internal/PipelineComponent.cs +++ b/src/Resilion/Internal/PipelineComponent.cs @@ -96,8 +96,19 @@ public override async ValueTask DisposeAsync() /// /// Wraps a generic as a . -/// When the execution result type does not match, the component passes through to the next component. /// +/// +/// If the execution result type does not match , this throws +/// rather than skipping the strategy. A skipped strategy is a silent wrong answer — a retry that +/// never retries, or a circuit breaker that never breaks, on an execution that looks successful. +/// +/// No public API can currently produce a mismatch: this component is only created by +/// PipelineBuilder<TResult>.AddStrategy(Strategy<TResult>), which constrains the +/// strategy to the builder's result type, and that builder only yields a +/// whose execute methods instantiate exactly that type. The guard +/// exists so that a future composition surface cannot reintroduce a silent skip. +/// +/// internal sealed class TypedStrategyComponent : PipelineComponent { private readonly Strategy _strategy; @@ -119,9 +130,7 @@ internal override ValueTask> ExecuteAsync( return ExecuteTypedAsync(callback, context); } - // Type mismatch — skip this strategy entirely. - WarnTypeMismatch(typeof(TResult)); - return _next.ExecuteAsync(callback, context); + throw TypeMismatch(typeof(TResult)); } internal override Outcome Execute( @@ -133,22 +142,22 @@ internal override Outcome Execute( return ExecuteTyped(callback, context); } - WarnTypeMismatch(typeof(TResult)); - return _next.Execute(callback, context); + throw TypeMismatch(typeof(TResult)); } /// - /// A typed strategy added to an untyped silently does nothing when - /// executed with a different result type — typeof(TResult) != typeof(TStrategyResult). - /// This doesn't change that behavior (it's still not an error), but at least makes it - /// visible when debugging why a strategy doesn't seem to run. + /// Builds the exception thrown when a typed strategy is reached by an execution whose result + /// type differs from the strategy's. Previously this was a Debug.WriteLine and a + /// pass-through; that warning was compiled out of Release builds by + /// [Conditional("DEBUG")], so shipped packages skipped the strategy with + /// no diagnostic at all. /// - private void WarnTypeMismatch(Type requestedType) - { - System.Diagnostics.Debug.WriteLine( - $"[Resilion] {_strategy.GetType().Name} is a Strategy<{typeof(TStrategyResult).Name}> but was " + - $"executed with result type {requestedType.Name} — this strategy was skipped, not applied."); - } + private InvalidOperationException TypeMismatch(Type requestedType) + => new( + $"{_strategy.GetType().Name} is a Strategy<{typeof(TStrategyResult).Name}> but the pipeline " + + $"was executed with result type {requestedType.Name}. A typed strategy cannot be applied to " + + "an execution of a different result type. Use a Pipeline<" + typeof(TStrategyResult).Name + + "> for this strategy, or add it as a non-generic Strategy."); private ValueTask> ExecuteTypedAsync( Func>> callback, diff --git a/src/Resilion/Internal/PredicateBuilderInternals.cs b/src/Resilion/Internal/PredicateBuilderInternals.cs new file mode 100644 index 0000000..449a542 --- /dev/null +++ b/src/Resilion/Internal/PredicateBuilderInternals.cs @@ -0,0 +1,52 @@ +namespace Resilion.Internal; + +/// +/// Exception-chain walking shared by both PredicateBuilder variants, so the two cannot +/// disagree about what "inner" means. +/// +internal static class PredicateBuilderInternals +{ + /// + /// Determines whether , or any exception nested within it, is a + /// matching . + /// + /// + /// Walks and flattens + /// . Depth is bounded to avoid spinning on a + /// self-referencing chain, which is malformed but observable in the wild. + /// + internal static bool MatchesInner(Exception exception, Func predicate) + where TException : Exception + => MatchesInner(exception, predicate, depth: 0); + + private const int MaxDepth = 32; + + private static bool MatchesInner(Exception? exception, Func predicate, int depth) + where TException : Exception + { + if (exception is null || depth > MaxDepth) + { + return false; + } + + if (exception is TException match && predicate(match)) + { + return true; + } + + if (exception is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + if (MatchesInner(inner, predicate, depth + 1)) + { + return true; + } + } + + return false; + } + + return MatchesInner(exception.InnerException, predicate, depth + 1); + } +} diff --git a/src/Resilion/Pipeline.cs b/src/Resilion/Pipeline.cs index 09f63c8..6c4c4af 100644 --- a/src/Resilion/Pipeline.cs +++ b/src/Resilion/Pipeline.cs @@ -44,7 +44,7 @@ internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = nul /// /// Gets the optional name of this pipeline, used in telemetry and diagnostics. /// - internal string? Name => _name; + public string? Name => _name; /// /// Creates a pipeline by configuring strategies on a builder. @@ -285,11 +285,37 @@ public void Execute( cancellationToken); } + private int _disposed; + /// - public void Dispose() => _component.Dispose(); + /// + /// Idempotent: the second and later calls are no-ops. A pipeline can legitimately be reached + /// by two owners — the DI container disposes a keyed singleton, and + /// ResiliencePipelineRegistry disposes every pipeline it created — so without this + /// guard the same instance is disposed twice. That is harmless while no strategy holds a + /// resource, and a real bug the day one does. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _component.Dispose(); + } /// - public ValueTask DisposeAsync() => _component.DisposeAsync(); + /// Idempotent, for the same reason as . + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return ValueTask.CompletedTask; + } + + return _component.DisposeAsync(); + } } /// diff --git a/src/Resilion/PipelineOfT.cs b/src/Resilion/PipelineOfT.cs index 8199cd2..91c4ee2 100644 --- a/src/Resilion/PipelineOfT.cs +++ b/src/Resilion/PipelineOfT.cs @@ -37,7 +37,7 @@ internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = nul /// /// Gets the optional name of this pipeline, used in telemetry and diagnostics. /// - internal string? Name => _name; + public string? Name => _name; // ────────────────────────────────────────────────────────────────── // Async execution @@ -183,9 +183,35 @@ public TResult Execute( return Execute(static (act, ct) => act(ct), action, cancellationToken); } + private int _disposed; + /// - public void Dispose() => _component.Dispose(); + /// + /// Idempotent: the second and later calls are no-ops. A pipeline can legitimately be reached + /// by two owners — the DI container disposes a keyed singleton, and + /// ResiliencePipelineRegistry disposes every pipeline it created — so without this + /// guard the same instance is disposed twice. That is harmless while no strategy holds a + /// resource, and a real bug the day one does. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _component.Dispose(); + } /// - public ValueTask DisposeAsync() => _component.DisposeAsync(); + /// Idempotent, for the same reason as . + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return ValueTask.CompletedTask; + } + + return _component.DisposeAsync(); + } } diff --git a/src/Resilion/PredicateBuilder.cs b/src/Resilion/PredicateBuilder.cs new file mode 100644 index 0000000..3286931 --- /dev/null +++ b/src/Resilion/PredicateBuilder.cs @@ -0,0 +1,232 @@ +using Resilion.Internal; + +namespace Resilion; + +/// +/// Composes a result-aware predicate for a strategy's ShouldHandle, as an alternative to +/// writing the by hand. +/// +/// The pipeline's result type. +/// +/// Clauses combine with OR: the built predicate handles an outcome if any clause matches. +/// +/// This is additive sugar, never a second route to the default predicate. A strategy falls back to +/// "handle every exception except " only when its +/// ShouldHandle is , and a builder always produces a non-null +/// delegate. An empty builder would blur that line, so throws rather than +/// quietly meaning "handle everything". +/// +/// +/// This does not inherit the default's cancellation carve-out. +/// new PredicateBuilder<T>().Handle<Exception>() matches +/// , because an explicit clause should mean what it says. +/// Exclude it yourself if you don't want retries on cancellation: +/// .Handle<Exception>(ex => ex is not OperationCanceledException). +/// +/// +/// Unlike this is not single-use: it produces a value rather than +/// configuring one thing, so calling more than once is expected and each call +/// snapshots the clauses added so far. +/// +/// +public sealed class PredicateBuilder +{ + private readonly List, bool>> _clauses = []; + + /// Handles any outcome carrying a . + public PredicateBuilder Handle() + where TException : Exception + => Handle(static _ => true); + + /// + /// Handles any outcome carrying a for which + /// returns . + /// + public PredicateBuilder Handle(Func predicate) + where TException : Exception + { + ArgumentNullException.ThrowIfNull(predicate); + _clauses.Add(outcome => outcome.Exception is TException ex && predicate(ex)); + return this; + } + + /// + /// Handles any outcome whose exception, or any exception nested within it, is a + /// . + /// + public PredicateBuilder HandleInner() + where TException : Exception + => HandleInner(static _ => true); + + /// + /// Handles any outcome whose exception, or any exception nested within it, is a + /// matching . + /// + /// + /// Walks the chain and flattens + /// , so a fault wrapped by a library boundary is still matched. + /// + public PredicateBuilder HandleInner(Func predicate) + where TException : Exception + { + ArgumentNullException.ThrowIfNull(predicate); + _clauses.Add(outcome => outcome.Exception is not null + && PredicateBuilderInternals.MatchesInner(outcome.Exception, predicate)); + return this; + } + + /// + /// Handles any successful outcome whose result matches . + /// + /// + /// Evaluated only for successful outcomes, so the predicate never sees a default value + /// standing in for a failure. + /// + public PredicateBuilder HandleResult(Func predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + _clauses.Add(outcome => outcome.TryGetResult(out var result) && predicate(result)); + return this; + } + + /// + /// Handles any successful outcome whose result equals , compared with + /// . + /// + public PredicateBuilder HandleResult(TResult result) + => HandleResult(candidate => EqualityComparer.Default.Equals(candidate, result)); + + /// Builds the predicate. + /// No clause has been added. + public Func, bool> Build() + { + if (_clauses.Count == 0) + { + throw new InvalidOperationException( + "PredicateBuilder has no clauses, so the predicate it would build is meaningless. " + + "Add at least one Handle/HandleInner/HandleResult clause, or leave ShouldHandle " + + "null to use the default (handle every exception except OperationCanceledException)."); + } + + // Snapshot so the returned delegate is unaffected by later mutation of this builder. + var clauses = _clauses.ToArray(); + + if (clauses.Length == 1) + { + return clauses[0]; + } + + return outcome => + { + foreach (var clause in clauses) + { + if (clause(outcome)) + { + return true; + } + } + + return false; + }; + } + + /// Builds the predicate, so a builder can be assigned directly to ShouldHandle. + /// No clause has been added. + public static implicit operator Func, bool>(PredicateBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Build(); + } +} + +/// +/// Composes an exception predicate for the ShouldHandle of a non-generic strategy's options, +/// which sees exceptions only and never a result. +/// +/// +/// The same rules as apply: clauses combine with OR, +/// throws on an empty builder, the cancellation carve-out is not inherited, and +/// reuse is expected. +/// +public sealed class PredicateBuilder +{ + private readonly List> _clauses = []; + + /// Handles any . + public PredicateBuilder Handle() + where TException : Exception + => Handle(static _ => true); + + /// + /// Handles any for which returns + /// . + /// + public PredicateBuilder Handle(Func predicate) + where TException : Exception + { + ArgumentNullException.ThrowIfNull(predicate); + _clauses.Add(exception => exception is TException ex && predicate(ex)); + return this; + } + + /// + /// Handles any exception which, or any exception nested within which, is a + /// . + /// + public PredicateBuilder HandleInner() + where TException : Exception + => HandleInner(static _ => true); + + /// + /// Handles any exception which, or any exception nested within which, is a + /// matching . + /// + public PredicateBuilder HandleInner(Func predicate) + where TException : Exception + { + ArgumentNullException.ThrowIfNull(predicate); + _clauses.Add(exception => PredicateBuilderInternals.MatchesInner(exception, predicate)); + return this; + } + + /// Builds the predicate. + /// No clause has been added. + public Func Build() + { + if (_clauses.Count == 0) + { + throw new InvalidOperationException( + "PredicateBuilder has no clauses, so the predicate it would build is meaningless. " + + "Add at least one Handle/HandleInner clause, or leave ShouldHandle null to use the " + + "default (handle every exception except OperationCanceledException)."); + } + + var clauses = _clauses.ToArray(); + + if (clauses.Length == 1) + { + return clauses[0]; + } + + return exception => + { + foreach (var clause in clauses) + { + if (clause(exception)) + { + return true; + } + } + + return false; + }; + } + + /// Builds the predicate, so a builder can be assigned directly to ShouldHandle. + /// No clause has been added. + public static implicit operator Func(PredicateBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Build(); + } +} diff --git a/src/Resilion/PublicAPI.Shipped.txt b/src/Resilion/PublicAPI.Shipped.txt index 6e64274..bc74cca 100644 --- a/src/Resilion/PublicAPI.Shipped.txt +++ b/src/Resilion/PublicAPI.Shipped.txt @@ -1,4 +1,346 @@ -# This file tracks the public API surface of Resilion v1.0. -# It will be generated after v1.0 is shipped using PublicApiAnalyzers. -# Until then, it serves as a placeholder to enable the analyzer. -# See: https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md +#nullable enable +Resilion.BreakDurationGeneratorArgs +Resilion.BreakDurationGeneratorArgs.BreakDurationGeneratorArgs() -> void +Resilion.BreakDurationGeneratorArgs.BreakDurationGeneratorArgs(int FailureCount, System.TimeSpan CurrentBreakDuration, Resilion.ResilienceContext! Context) -> void +Resilion.BreakDurationGeneratorArgs.Context.get -> Resilion.ResilienceContext! +Resilion.BreakDurationGeneratorArgs.Context.init -> void +Resilion.BreakDurationGeneratorArgs.CurrentBreakDuration.get -> System.TimeSpan +Resilion.BreakDurationGeneratorArgs.CurrentBreakDuration.init -> void +Resilion.BreakDurationGeneratorArgs.FailureCount.get -> int +Resilion.BreakDurationGeneratorArgs.FailureCount.init -> void +Resilion.CircuitBreakerExtensions +Resilion.CircuitBreakerManualControl +Resilion.CircuitBreakerManualControl.CircuitBreakerManualControl() -> void +Resilion.CircuitBreakerManualControl.IsolateAsync() -> System.Threading.Tasks.Task! +Resilion.CircuitBreakerManualControl.ResetAsync() -> System.Threading.Tasks.Task! +Resilion.CircuitBreakerStrategyOptions +Resilion.CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan +Resilion.CircuitBreakerStrategyOptions.BreakDuration.init -> void +Resilion.CircuitBreakerStrategyOptions.BreakDurationGenerator.get -> System.Func? +Resilion.CircuitBreakerStrategyOptions.BreakDurationGenerator.init -> void +Resilion.CircuitBreakerStrategyOptions.FailureRatioThreshold.get -> double +Resilion.CircuitBreakerStrategyOptions.FailureRatioThreshold.init -> void +Resilion.CircuitBreakerStrategyOptions.ManualControl.get -> Resilion.CircuitBreakerManualControl? +Resilion.CircuitBreakerStrategyOptions.ManualControl.init -> void +Resilion.CircuitBreakerStrategyOptions.MinimumThroughput.get -> int +Resilion.CircuitBreakerStrategyOptions.MinimumThroughput.init -> void +Resilion.CircuitBreakerStrategyOptions.OnClosed.get -> Resilion.ResilienceEventHandler? +Resilion.CircuitBreakerStrategyOptions.OnClosed.init -> void +Resilion.CircuitBreakerStrategyOptions.OnHalfOpened.get -> Resilion.ResilienceEventHandler? +Resilion.CircuitBreakerStrategyOptions.OnHalfOpened.init -> void +Resilion.CircuitBreakerStrategyOptions.OnOpened.get -> Resilion.ResilienceEventHandler? +Resilion.CircuitBreakerStrategyOptions.OnOpened.init -> void +Resilion.CircuitBreakerStrategyOptions.SamplingDuration.get -> System.TimeSpan +Resilion.CircuitBreakerStrategyOptions.SamplingDuration.init -> void +Resilion.CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func? +Resilion.CircuitBreakerStrategyOptions.ShouldHandle.init -> void +Resilion.CircuitBreakerStrategyOptions +Resilion.CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan +Resilion.CircuitBreakerStrategyOptions.BreakDuration.init -> void +Resilion.CircuitBreakerStrategyOptions.BreakDurationGenerator.get -> System.Func? +Resilion.CircuitBreakerStrategyOptions.BreakDurationGenerator.init -> void +Resilion.CircuitBreakerStrategyOptions.FailureRatioThreshold.get -> double +Resilion.CircuitBreakerStrategyOptions.FailureRatioThreshold.init -> void +Resilion.CircuitBreakerStrategyOptions.ManualControl.get -> Resilion.CircuitBreakerManualControl? +Resilion.CircuitBreakerStrategyOptions.ManualControl.init -> void +Resilion.CircuitBreakerStrategyOptions.MinimumThroughput.get -> int +Resilion.CircuitBreakerStrategyOptions.MinimumThroughput.init -> void +Resilion.CircuitBreakerStrategyOptions.OnClosed.get -> Resilion.ResilienceEventHandler? +Resilion.CircuitBreakerStrategyOptions.OnClosed.init -> void +Resilion.CircuitBreakerStrategyOptions.OnHalfOpened.get -> Resilion.ResilienceEventHandler? +Resilion.CircuitBreakerStrategyOptions.OnHalfOpened.init -> void +Resilion.CircuitBreakerStrategyOptions.OnOpened.get -> Resilion.ResilienceEventHandler? +Resilion.CircuitBreakerStrategyOptions.OnOpened.init -> void +Resilion.CircuitBreakerStrategyOptions.SamplingDuration.get -> System.TimeSpan +Resilion.CircuitBreakerStrategyOptions.SamplingDuration.init -> void +Resilion.CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func, bool>? +Resilion.CircuitBreakerStrategyOptions.ShouldHandle.init -> void +Resilion.CircuitBrokenException +Resilion.CircuitBrokenException.CircuitState.get -> Resilion.CircuitState +Resilion.CircuitBrokenException.RetryAfter.get -> System.TimeSpan +Resilion.CircuitState +Resilion.CircuitState.Closed = 0 -> Resilion.CircuitState +Resilion.CircuitState.HalfOpen = 2 -> Resilion.CircuitState +Resilion.CircuitState.Isolated = 3 -> Resilion.CircuitState +Resilion.CircuitState.Open = 1 -> Resilion.CircuitState +Resilion.CircuitStateChangedEvent +Resilion.CircuitStateChangedEvent.CircuitStateChangedEvent() -> void +Resilion.CircuitStateChangedEvent.CircuitStateChangedEvent(Resilion.CircuitState PreviousState, Resilion.CircuitState CurrentState, Resilion.ResilienceContext! Context) -> void +Resilion.CircuitStateChangedEvent.Context.get -> Resilion.ResilienceContext! +Resilion.CircuitStateChangedEvent.Context.init -> void +Resilion.CircuitStateChangedEvent.CurrentState.get -> Resilion.CircuitState +Resilion.CircuitStateChangedEvent.CurrentState.init -> void +Resilion.CircuitStateChangedEvent.PreviousState.get -> Resilion.CircuitState +Resilion.CircuitStateChangedEvent.PreviousState.init -> void +Resilion.FallbackAction +Resilion.FallbackAction.FallbackAction() -> void +Resilion.FallbackAction.HasValue.get -> bool +Resilion.FallbackContext +Resilion.FallbackContext.Context.get -> Resilion.ResilienceContext! +Resilion.FallbackContext.Context.init -> void +Resilion.FallbackContext.Exception.get -> System.Exception? +Resilion.FallbackContext.FallbackContext() -> void +Resilion.FallbackContext.FallbackContext(Resilion.Outcome Outcome, Resilion.ResilienceContext! Context) -> void +Resilion.FallbackContext.Outcome.get -> Resilion.Outcome +Resilion.FallbackContext.Outcome.init -> void +Resilion.FallbackExtensions +Resilion.FallbackStrategyOptions +Resilion.FallbackStrategyOptions.FallbackAction.get -> Resilion.FallbackAction +Resilion.FallbackStrategyOptions.FallbackAction.init -> void +Resilion.FallbackStrategyOptions.OnFallback.get -> Resilion.ResilienceEventHandler>? +Resilion.FallbackStrategyOptions.OnFallback.init -> void +Resilion.FallbackStrategyOptions.ShouldHandle.get -> System.Func, bool>? +Resilion.FallbackStrategyOptions.ShouldHandle.init -> void +Resilion.HedgingActionContext +Resilion.HedgingActionContext.AttemptNumber.get -> int +Resilion.HedgingActionContext.AttemptNumber.init -> void +Resilion.HedgingActionContext.HedgingActionContext() -> void +Resilion.HedgingActionContext.HedgingActionContext(int AttemptNumber) -> 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.MaxHedgedAttempts.get -> int +Resilion.HedgingStrategyOptions.MaxHedgedAttempts.init -> void +Resilion.HedgingStrategyOptions.OnHedging.get -> Resilion.ResilienceEventHandler>? +Resilion.HedgingStrategyOptions.OnHedging.init -> void +Resilion.HedgingStrategyOptions.ShouldHandle.get -> System.Func, bool>? +Resilion.HedgingStrategyOptions.ShouldHandle.init -> void +Resilion.OnFallbackEvent +Resilion.OnFallbackEvent.Context.get -> Resilion.ResilienceContext! +Resilion.OnFallbackEvent.Context.init -> void +Resilion.OnFallbackEvent.FallbackResult.get -> TResult +Resilion.OnFallbackEvent.FallbackResult.init -> void +Resilion.OnFallbackEvent.OnFallbackEvent() -> void +Resilion.OnFallbackEvent.OnFallbackEvent(Resilion.Outcome Outcome, TResult FallbackResult, Resilion.ResilienceContext! Context) -> void +Resilion.OnFallbackEvent.Outcome.get -> Resilion.Outcome +Resilion.OnFallbackEvent.Outcome.init -> void +Resilion.OnHedgingEvent +Resilion.OnHedgingEvent.AttemptNumber.get -> int +Resilion.OnHedgingEvent.AttemptNumber.init -> void +Resilion.OnHedgingEvent.Context.get -> Resilion.ResilienceContext! +Resilion.OnHedgingEvent.Context.init -> void +Resilion.OnHedgingEvent.OnHedgingEvent() -> void +Resilion.OnHedgingEvent.OnHedgingEvent(int AttemptNumber, Resilion.ResilienceContext! Context) -> void +Resilion.OnTimeoutArgs +Resilion.OnTimeoutArgs.Context.get -> Resilion.ResilienceContext! +Resilion.OnTimeoutArgs.Context.init -> void +Resilion.OnTimeoutArgs.ElapsedTime.get -> System.TimeSpan +Resilion.OnTimeoutArgs.ElapsedTime.init -> void +Resilion.OnTimeoutArgs.OnTimeoutArgs() -> void +Resilion.OnTimeoutArgs.OnTimeoutArgs(Resilion.ResilienceContext! Context, System.TimeSpan Timeout, System.TimeSpan ElapsedTime) -> void +Resilion.OnTimeoutArgs.Timeout.get -> System.TimeSpan +Resilion.OnTimeoutArgs.Timeout.init -> void +Resilion.Outcome +Resilion.Outcome.Equals(Resilion.Outcome other) -> bool +Resilion.Outcome.Exception.get -> System.Exception? +Resilion.Outcome.GetResultOrDefault(TResult defaultValue = default(TResult)) -> TResult +Resilion.Outcome.IsFailure.get -> bool +Resilion.Outcome.IsSuccess.get -> bool +Resilion.Outcome.Match(System.Func! onSuccess, System.Func! onFailure) -> T +Resilion.Outcome.Outcome() -> void +Resilion.Outcome.Result.get -> TResult +Resilion.Outcome.ThrowIfFailed() -> TResult +Resilion.Outcome.TryGetResult(out TResult result) -> bool +Resilion.Pipeline +Resilion.Pipeline.Dispose() -> void +Resilion.Pipeline.DisposeAsync() -> System.Threading.Tasks.ValueTask +Resilion.Pipeline.Execute(System.Action! action, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +Resilion.Pipeline.Execute(System.Func! action, TState state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult +Resilion.Pipeline.Execute(System.Func! action, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult +Resilion.Pipeline.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.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 +Resilion.Pipeline.Dispose() -> void +Resilion.Pipeline.DisposeAsync() -> System.Threading.Tasks.ValueTask +Resilion.Pipeline.Execute(System.Func! action, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult +Resilion.Pipeline.Execute(System.Func! action, TState state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> TResult +Resilion.Pipeline.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.PipelineBuilder +Resilion.PipelineBuilder.AddPipeline(Resilion.Pipeline! pipeline) -> Resilion.PipelineBuilder! +Resilion.PipelineBuilder.AddStrategy(Resilion.Strategy! strategy) -> Resilion.PipelineBuilder! +Resilion.PipelineBuilder.Build() -> Resilion.Pipeline! +Resilion.PipelineBuilder.PipelineBuilder() -> void +Resilion.PipelineBuilder +Resilion.PipelineBuilder.AddPipeline(Resilion.Pipeline! pipeline) -> Resilion.PipelineBuilder! +Resilion.PipelineBuilder.AddPipeline(Resilion.Pipeline! pipeline) -> Resilion.PipelineBuilder! +Resilion.PipelineBuilder.AddStrategy(Resilion.Strategy! strategy) -> Resilion.PipelineBuilder! +Resilion.PipelineBuilder.AddStrategy(Resilion.Strategy! strategy) -> Resilion.PipelineBuilder! +Resilion.PipelineBuilder.AddStrategy(string! name, System.Func>>!, System.Threading.Tasks.ValueTask>>! handler) -> Resilion.PipelineBuilder! +Resilion.PipelineBuilder.Build() -> Resilion.Pipeline! +Resilion.PipelineBuilder.PipelineBuilder() -> void +Resilion.PipelineBuilderBase +Resilion.PipelineBuilderBase.Name.get -> string? +Resilion.PipelineBuilderBase.Name.set -> void +Resilion.PipelineBuilderBase.OnValidationWarning.get -> System.Action? +Resilion.PipelineBuilderBase.OnValidationWarning.set -> void +Resilion.PipelineBuilderBase.PipelineBuilderBase() -> void +Resilion.PipelineBuilderBase.SuppressOrderingWarnings.get -> bool +Resilion.PipelineBuilderBase.SuppressOrderingWarnings.set -> void +Resilion.PipelineBuilderBase.ThrowOnOrderingErrors.get -> bool +Resilion.PipelineBuilderBase.ThrowOnOrderingErrors.set -> void +Resilion.PipelineBuilderBase.TimeProvider.get -> System.TimeProvider! +Resilion.PipelineBuilderBase.TimeProvider.set -> void +Resilion.ResilienceContext +Resilion.ResilienceContext.CancellationToken.get -> System.Threading.CancellationToken +Resilion.ResilienceContext.ContinueOnCapturedContext.get -> bool +Resilion.ResilienceContext.ContinueOnCapturedContext.set -> void +Resilion.ResilienceContext.OperationKey.get -> string? +Resilion.ResilienceContext.OperationKey.set -> void +Resilion.ResilienceContext.Properties.get -> Resilion.ResilienceProperties! +Resilion.ResilienceContextPool +Resilion.ResilienceContextPool.Rent(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> Resilion.ResilienceContext! +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 +Resilion.ResilienceProperties.Remove(Resilion.ResiliencePropertyKey key) -> bool +Resilion.ResilienceProperties.ResilienceProperties() -> void +Resilion.ResilienceProperties.Set(Resilion.ResiliencePropertyKey key, TValue value) -> void +Resilion.ResilienceProperties.TryGetValue(Resilion.ResiliencePropertyKey key, out TValue? value) -> bool +Resilion.ResiliencePropertyKey +Resilion.ResiliencePropertyKey.Equals(Resilion.ResiliencePropertyKey other) -> bool +Resilion.ResiliencePropertyKey.Key.get -> string! +Resilion.ResiliencePropertyKey.ResiliencePropertyKey() -> void +Resilion.ResiliencePropertyKey.ResiliencePropertyKey(string! key) -> void +Resilion.ResilionException +Resilion.ResilionException.ResilionException(string! message) -> void +Resilion.ResilionException.ResilionException(string! message, System.Exception! innerException) -> void +Resilion.ResilionTelemetry +Resilion.RetryAttemptEvent +Resilion.RetryAttemptEvent.AttemptNumber.get -> int +Resilion.RetryAttemptEvent.AttemptNumber.init -> void +Resilion.RetryAttemptEvent.Context.get -> Resilion.ResilienceContext! +Resilion.RetryAttemptEvent.Context.init -> void +Resilion.RetryAttemptEvent.Exception.get -> System.Exception! +Resilion.RetryAttemptEvent.Exception.init -> void +Resilion.RetryAttemptEvent.RetryAttemptEvent() -> void +Resilion.RetryAttemptEvent.RetryAttemptEvent(int AttemptNumber, System.TimeSpan RetryDelay, System.Exception! Exception, Resilion.ResilienceContext! Context) -> void +Resilion.RetryAttemptEvent.RetryDelay.get -> System.TimeSpan +Resilion.RetryAttemptEvent.RetryDelay.init -> void +Resilion.RetryAttemptEvent +Resilion.RetryAttemptEvent.AttemptNumber.get -> int +Resilion.RetryAttemptEvent.AttemptNumber.init -> void +Resilion.RetryAttemptEvent.Context.get -> Resilion.ResilienceContext! +Resilion.RetryAttemptEvent.Context.init -> void +Resilion.RetryAttemptEvent.Outcome.get -> Resilion.Outcome +Resilion.RetryAttemptEvent.Outcome.init -> void +Resilion.RetryAttemptEvent.RetryAttemptEvent() -> void +Resilion.RetryAttemptEvent.RetryAttemptEvent(int AttemptNumber, System.TimeSpan RetryDelay, Resilion.Outcome Outcome, Resilion.ResilienceContext! Context) -> void +Resilion.RetryAttemptEvent.RetryDelay.get -> System.TimeSpan +Resilion.RetryAttemptEvent.RetryDelay.init -> void +Resilion.RetryDelay +Resilion.RetryDelayContext +Resilion.RetryDelayContext.AttemptNumber.get -> int +Resilion.RetryDelayContext.AttemptNumber.init -> void +Resilion.RetryDelayContext.RetryDelayContext() -> void +Resilion.RetryDelayContext.RetryDelayContext(int AttemptNumber) -> void +Resilion.RetryExtensions +Resilion.RetryStrategyOptions +Resilion.RetryStrategyOptions.Delay.get -> Resilion.RetryDelay! +Resilion.RetryStrategyOptions.Delay.init -> void +Resilion.RetryStrategyOptions.MaxDelay.get -> System.TimeSpan? +Resilion.RetryStrategyOptions.MaxDelay.init -> void +Resilion.RetryStrategyOptions.MaxRetryAttempts.get -> int +Resilion.RetryStrategyOptions.MaxRetryAttempts.init -> void +Resilion.RetryStrategyOptions.OnRetry.get -> Resilion.ResilienceEventHandler? +Resilion.RetryStrategyOptions.OnRetry.init -> void +Resilion.RetryStrategyOptions.ShouldHandle.get -> System.Func? +Resilion.RetryStrategyOptions.ShouldHandle.init -> void +Resilion.RetryStrategyOptions.UseJitter.get -> bool +Resilion.RetryStrategyOptions.UseJitter.init -> void +Resilion.RetryStrategyOptions +Resilion.RetryStrategyOptions.Delay.get -> Resilion.RetryDelay! +Resilion.RetryStrategyOptions.Delay.init -> void +Resilion.RetryStrategyOptions.MaxDelay.get -> System.TimeSpan? +Resilion.RetryStrategyOptions.MaxDelay.init -> void +Resilion.RetryStrategyOptions.MaxRetryAttempts.get -> int +Resilion.RetryStrategyOptions.MaxRetryAttempts.init -> void +Resilion.RetryStrategyOptions.OnRetry.get -> Resilion.ResilienceEventHandler>? +Resilion.RetryStrategyOptions.OnRetry.init -> void +Resilion.RetryStrategyOptions.ShouldHandle.get -> System.Func, bool>? +Resilion.RetryStrategyOptions.ShouldHandle.init -> void +Resilion.RetryStrategyOptions.UseJitter.get -> bool +Resilion.RetryStrategyOptions.UseJitter.init -> void +Resilion.Strategy +Resilion.Strategy.Strategy() -> void +Resilion.Strategy +Resilion.Strategy.Strategy() -> void +Resilion.TimeoutExtensions +Resilion.TimeoutGeneratorArgs +Resilion.TimeoutGeneratorArgs.Context.get -> Resilion.ResilienceContext! +Resilion.TimeoutGeneratorArgs.Context.init -> void +Resilion.TimeoutGeneratorArgs.TimeoutGeneratorArgs() -> void +Resilion.TimeoutGeneratorArgs.TimeoutGeneratorArgs(Resilion.ResilienceContext! Context) -> void +Resilion.TimeoutRejectedException +Resilion.TimeoutRejectedException.ConfiguredTimeout.get -> System.TimeSpan +Resilion.TimeoutRejectedException.ElapsedTime.get -> System.TimeSpan +Resilion.TimeoutStrategyOptions +Resilion.TimeoutStrategyOptions.OnTimeout.get -> Resilion.ResilienceEventHandler? +Resilion.TimeoutStrategyOptions.OnTimeout.init -> void +Resilion.TimeoutStrategyOptions.Timeout.get -> System.TimeSpan +Resilion.TimeoutStrategyOptions.Timeout.init -> void +Resilion.TimeoutStrategyOptions.TimeoutGenerator.get -> System.Func? +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.MeterName = "Resilion" -> string! +const Resilion.ResilionTelemetry.OperationKeyTag = "operation.key" -> string! +const Resilion.ResilionTelemetry.PipelineNameTag = "pipeline.name" -> string! +override Resilion.Outcome.Equals(object? obj) -> bool +override Resilion.Outcome.GetHashCode() -> int +override Resilion.Outcome.ToString() -> string! +override Resilion.ResiliencePropertyKey.Equals(object? obj) -> bool +override Resilion.ResiliencePropertyKey.GetHashCode() -> int +override Resilion.ResiliencePropertyKey.ToString() -> string! +static Resilion.CircuitBreakerExtensions.AddCircuitBreaker(this Resilion.PipelineBuilder! builder, Resilion.CircuitBreakerStrategyOptions? options = null) -> Resilion.PipelineBuilder! +static Resilion.CircuitBreakerExtensions.AddCircuitBreaker(this Resilion.PipelineBuilder! builder, Resilion.CircuitBreakerStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.CircuitBreakerExtensions.AddCircuitBreaker(this Resilion.PipelineBuilder! builder, Resilion.CircuitBreakerStrategyOptions? options = null) -> Resilion.PipelineBuilder! +static Resilion.FallbackAction.implicit operator Resilion.FallbackAction(System.Func, System.Threading.Tasks.ValueTask>! factory) -> Resilion.FallbackAction +static Resilion.FallbackAction.implicit operator Resilion.FallbackAction(System.Func, TResult>! factory) -> Resilion.FallbackAction +static Resilion.FallbackAction.implicit operator Resilion.FallbackAction(TResult value) -> Resilion.FallbackAction +static Resilion.FallbackExtensions.AddFallback(this Resilion.PipelineBuilder! builder, Resilion.FallbackStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.HedgingExtensions.AddHedging(this Resilion.PipelineBuilder! builder, Resilion.HedgingStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.Outcome.FromException(System.Exception! exception) -> Resilion.Outcome +static Resilion.Outcome.FromResult(TResult result) -> Resilion.Outcome +static Resilion.Outcome.implicit operator Resilion.Outcome(TResult result) -> Resilion.Outcome +static Resilion.Outcome.operator !=(Resilion.Outcome left, Resilion.Outcome right) -> bool +static Resilion.Outcome.operator ==(Resilion.Outcome left, Resilion.Outcome right) -> bool +static Resilion.Pipeline.Create(System.Action! configure) -> Resilion.Pipeline! +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.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! +static Resilion.RetryDelay.Custom(System.Func! compute) -> Resilion.RetryDelay! +static Resilion.RetryDelay.Exponential(System.TimeSpan baseDelay, System.TimeSpan? maxDelay = null) -> Resilion.RetryDelay! +static Resilion.RetryDelay.Linear(System.TimeSpan baseDelay, System.TimeSpan? maxDelay = null) -> Resilion.RetryDelay! +static Resilion.RetryDelay.None.get -> Resilion.RetryDelay! +static Resilion.RetryExtensions.AddRetry(this Resilion.PipelineBuilder! builder, Resilion.RetryStrategyOptions? options = null) -> Resilion.PipelineBuilder! +static Resilion.RetryExtensions.AddRetry(this Resilion.PipelineBuilder! builder, Resilion.RetryStrategyOptions! options) -> Resilion.PipelineBuilder! +static Resilion.RetryExtensions.AddRetry(this Resilion.PipelineBuilder! builder, Resilion.RetryStrategyOptions? options = null) -> Resilion.PipelineBuilder! +static Resilion.TimeoutExtensions.AddTimeout(this Resilion.PipelineBuilder! builder, Resilion.TimeoutStrategyOptions? options = null) -> Resilion.PipelineBuilder! +static Resilion.TimeoutExtensions.AddTimeout(this Resilion.PipelineBuilder! builder, System.TimeSpan timeout) -> Resilion.PipelineBuilder! +static Resilion.TimeoutExtensions.AddTimeout(this Resilion.PipelineBuilder! builder, Resilion.TimeoutStrategyOptions? options = null) -> Resilion.PipelineBuilder! +static Resilion.TimeoutExtensions.AddTimeout(this Resilion.PipelineBuilder! builder, System.TimeSpan timeout) -> Resilion.PipelineBuilder! +virtual Resilion.Strategy.Dispose() -> void +virtual Resilion.Strategy.DisposeAsync() -> System.Threading.Tasks.ValueTask +virtual Resilion.Strategy.Execute(System.Func>! callback, Resilion.ResilienceContext! context) -> Resilion.Outcome +virtual Resilion.Strategy.Dispose() -> void +virtual Resilion.Strategy.DisposeAsync() -> System.Threading.Tasks.ValueTask +virtual Resilion.Strategy.Execute(System.Func>! callback, Resilion.ResilienceContext! context) -> Resilion.Outcome diff --git a/src/Resilion/PublicAPI.Unshipped.txt b/src/Resilion/PublicAPI.Unshipped.txt index 45903e5..2e5aecb 100644 --- a/src/Resilion/PublicAPI.Unshipped.txt +++ b/src/Resilion/PublicAPI.Unshipped.txt @@ -1,242 +1,37 @@ #nullable enable - -# New public APIs for Resilion (yet to ship). -# Entries here should be moved to PublicAPI.Shipped.txt upon release. - -namespace Resilion; - -CircuitBreakerExtensions -CircuitBreakerExtensions.AddCircuitBreaker(this PipelineBuilder! builder, CircuitBreakerStrategyOptions? options) -> PipelineBuilder! -CircuitBreakerExtensions.AddCircuitBreaker(this PipelineBuilder! builder, CircuitBreakerStrategyOptions! options) -> PipelineBuilder! -CircuitBreakerExtensions.AddCircuitBreaker(this PipelineBuilder! builder, CircuitBreakerStrategyOptions? options) -> PipelineBuilder! - -CircuitBreakerManualControl -CircuitBreakerManualControl.IsolateAsync() -> System.Threading.Tasks.Task! -CircuitBreakerManualControl.ResetAsync() -> System.Threading.Tasks.Task! - -CircuitBreakerStrategyOptions -CircuitBreakerStrategyOptions.FailureRatioThreshold.get -> double -CircuitBreakerStrategyOptions.SamplingDuration.get -> System.TimeSpan -CircuitBreakerStrategyOptions.MinimumThroughput.get -> int -CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan -CircuitBreakerStrategyOptions.BreakDurationGenerator.get -> System.Func? -CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func? -CircuitBreakerStrategyOptions.OnOpened.get -> ResilienceEventHandler? -CircuitBreakerStrategyOptions.OnClosed.get -> ResilienceEventHandler? -CircuitBreakerStrategyOptions.OnHalfOpened.get -> ResilienceEventHandler? -CircuitBreakerStrategyOptions.ManualControl.get -> CircuitBreakerManualControl? - -CircuitBreakerStrategyOptions -CircuitBreakerStrategyOptions.FailureRatioThreshold.get -> double -CircuitBreakerStrategyOptions.SamplingDuration.get -> System.TimeSpan -CircuitBreakerStrategyOptions.MinimumThroughput.get -> int -CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan -CircuitBreakerStrategyOptions.BreakDurationGenerator.get -> System.Func? -CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func, bool>? -CircuitBreakerStrategyOptions.OnOpened.get -> ResilienceEventHandler? -CircuitBreakerStrategyOptions.OnClosed.get -> ResilienceEventHandler? -CircuitBreakerStrategyOptions.OnHalfOpened.get -> ResilienceEventHandler? -CircuitBreakerStrategyOptions.ManualControl.get -> CircuitBreakerManualControl? - -CircuitBrokenException -CircuitBrokenException.CircuitState.get -> CircuitState -CircuitBrokenException.RetryAfter.get -> System.TimeSpan - -CircuitState -CircuitState.Closed = 0 -CircuitState.Open = 1 -CircuitState.HalfOpen = 2 -CircuitState.Isolated = 3 - -FallbackAction -FallbackAction.HasValue.get -> bool - -FallbackExtensions -FallbackExtensions.AddFallback(this PipelineBuilder! builder, FallbackStrategyOptions! options) -> PipelineBuilder! - -FallbackStrategyOptions -FallbackStrategyOptions.FallbackAction.get -> FallbackAction -FallbackStrategyOptions.ShouldHandle.get -> System.Func, bool>? -FallbackStrategyOptions.OnFallback.get -> ResilienceEventHandler>? - -HedgingExtensions -HedgingExtensions.AddHedging(this PipelineBuilder! builder, HedgingStrategyOptions! options) -> PipelineBuilder! - -HedgingStrategyOptions -HedgingStrategyOptions.Delay.get -> System.TimeSpan -HedgingStrategyOptions.MaxHedgedAttempts.get -> int -HedgingStrategyOptions.ShouldHandle.get -> System.Func, bool>? -HedgingStrategyOptions.OnHedging.get -> ResilienceEventHandler>? - -Outcome -Outcome.IsSuccess.get -> bool -Outcome.IsFailure.get -> bool -Outcome.Exception.get -> System.Exception? -Outcome.Result.get -> TResult -Outcome.TryGetResult(out TResult) -> bool -Outcome.GetResultOrDefault(TResult) -> TResult -Outcome.ThrowIfFailed() -> TResult -Outcome.Match(System.Func!, System.Func!) -> T -Outcome.FromResult(TResult) -> Outcome -Outcome.FromException(System.Exception!) -> Outcome -Outcome.operator ==(Outcome, Outcome) -> bool -Outcome.operator !=(Outcome, Outcome) -> bool -Outcome.operator implicit(TResult) -> Outcome - -Pipeline -Pipeline.Empty.get -> Pipeline! -Pipeline.Create(System.Action!) -> Pipeline! -Pipeline.Create(System.Action>!) -> Pipeline! -Pipeline.ExecuteAsync(System.Func>!, TState, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask -Pipeline.ExecuteAsync(System.Func>!, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask -Pipeline.ExecuteAsync(System.Func!, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask -Pipeline.ExecuteOutcomeAsync(System.Func>>!, TState, ResilienceContext!) -> System.Threading.Tasks.ValueTask> -Pipeline.Execute(System.Func!, TState, System.Threading.CancellationToken) -> TResult -Pipeline.Execute(System.Func!, System.Threading.CancellationToken) -> TResult -Pipeline.Execute(System.Action!, System.Threading.CancellationToken) -> void -Pipeline.Dispose() -> void -Pipeline.DisposeAsync() -> System.Threading.Tasks.ValueTask - -Pipeline -Pipeline.Empty.get -> Pipeline! -Pipeline.ExecuteAsync(System.Func>!, TState, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask -Pipeline.ExecuteAsync(System.Func>!, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask -Pipeline.ExecuteOutcomeAsync(System.Func>>!, TState, ResilienceContext!) -> System.Threading.Tasks.ValueTask> -Pipeline.Execute(System.Func!, TState, System.Threading.CancellationToken) -> TResult -Pipeline.Execute(System.Func!, System.Threading.CancellationToken) -> TResult -Pipeline.Dispose() -> void -Pipeline.DisposeAsync() -> System.Threading.Tasks.ValueTask - -PipelineBuilder -PipelineBuilder.Name.get -> string? -PipelineBuilder.Name.set -> void -PipelineBuilder.TimeProvider.get -> System.TimeProvider! -PipelineBuilder.TimeProvider.set -> void -PipelineBuilder.SuppressOrderingWarnings.get -> bool -PipelineBuilder.SuppressOrderingWarnings.set -> void -PipelineBuilder.ThrowOnOrderingErrors.get -> bool -PipelineBuilder.ThrowOnOrderingErrors.set -> void -PipelineBuilder.OnValidationWarning.get -> System.Action? -PipelineBuilder.OnValidationWarning.set -> void -PipelineBuilder.AddStrategy(Strategy!) -> PipelineBuilder! -PipelineBuilder.AddPipeline(Pipeline!) -> PipelineBuilder! -PipelineBuilder.Build() -> Pipeline! - -PipelineBuilder -PipelineBuilder.Name.get -> string? -PipelineBuilder.Name.set -> void -PipelineBuilder.TimeProvider.get -> System.TimeProvider! -PipelineBuilder.TimeProvider.set -> void -PipelineBuilder.SuppressOrderingWarnings.get -> bool -PipelineBuilder.SuppressOrderingWarnings.set -> void -PipelineBuilder.ThrowOnOrderingErrors.get -> bool -PipelineBuilder.ThrowOnOrderingErrors.set -> void -PipelineBuilder.OnValidationWarning.get -> System.Action? -PipelineBuilder.OnValidationWarning.set -> void -PipelineBuilder.AddStrategy(Strategy!) -> PipelineBuilder! -PipelineBuilder.AddStrategy(Strategy!) -> PipelineBuilder! -PipelineBuilder.AddStrategy(string!, System.Func>>, System.Threading.Tasks.ValueTask>>!) -> PipelineBuilder! -PipelineBuilder.AddPipeline(Pipeline!) -> PipelineBuilder! -PipelineBuilder.AddPipeline(Pipeline!) -> PipelineBuilder! -PipelineBuilder.Build() -> Pipeline! - -PipelineBuilderBase -PipelineBuilderBase.Name.get -> string? -PipelineBuilderBase.Name.set -> void -PipelineBuilderBase.TimeProvider.get -> System.TimeProvider! -PipelineBuilderBase.TimeProvider.set -> void -PipelineBuilderBase.SuppressOrderingWarnings.get -> bool -PipelineBuilderBase.SuppressOrderingWarnings.set -> void -PipelineBuilderBase.ThrowOnOrderingErrors.get -> bool -PipelineBuilderBase.ThrowOnOrderingErrors.set -> void -PipelineBuilderBase.OnValidationWarning.get -> System.Action? -PipelineBuilderBase.OnValidationWarning.set -> void - -ResilienceContext -ResilienceContext.CancellationToken.get -> System.Threading.CancellationToken -ResilienceContext.CancellationToken.set -> void -ResilienceContext.OperationKey.get -> string? -ResilienceContext.OperationKey.set -> void -ResilienceContext.ContinueOnCapturedContext.get -> bool -ResilienceContext.ContinueOnCapturedContext.set -> void -ResilienceContext.Properties.get -> ResilienceProperties! - -ResilienceContextPool -ResilienceContextPool.Shared.get -> ResilienceContextPool! -ResilienceContextPool.Rent(System.Threading.CancellationToken) -> ResilienceContext! -ResilienceContextPool.Return(ResilienceContext!) -> void - -ResilienceEventHandler -ResilienceEventHandler.Invoke(TEvent, ResilienceContext!) -> System.Threading.Tasks.ValueTask - -ResilienceProperties -ResilienceProperties.Set(ResiliencePropertyKey, TValue) -> void -ResilienceProperties.TryGetValue(ResiliencePropertyKey, out TValue?) -> bool -ResilienceProperties.GetValue(ResiliencePropertyKey, TValue) -> TValue -ResilienceProperties.Remove(ResiliencePropertyKey) -> bool -ResilienceProperties.Count.get -> int - -ResiliencePropertyKey -ResiliencePropertyKey.ResiliencePropertyKey(string!) -> void -ResiliencePropertyKey.Key.get -> string! -ResiliencePropertyKey.Equals(ResiliencePropertyKey) -> bool -ResiliencePropertyKey.operator ==(ResiliencePropertyKey, ResiliencePropertyKey) -> bool -ResiliencePropertyKey.operator !=(ResiliencePropertyKey, ResiliencePropertyKey) -> bool - -ResilionException -ResilionException.ResilionException(string!) -> void -ResilionException.ResilionException(string!, System.Exception!) -> void - -RetryExtensions -RetryExtensions.AddRetry(this PipelineBuilder! builder, RetryStrategyOptions? options) -> PipelineBuilder! -RetryExtensions.AddRetry(this PipelineBuilder! builder, RetryStrategyOptions! options) -> PipelineBuilder! -RetryExtensions.AddRetry(this PipelineBuilder! builder, RetryStrategyOptions? options) -> PipelineBuilder! - -RetryStrategyOptions -RetryStrategyOptions.MaxRetryAttempts.get -> int -RetryStrategyOptions.Delay.get -> RetryDelay! -RetryStrategyOptions.UseJitter.get -> bool -RetryStrategyOptions.MaxDelay.get -> System.TimeSpan? -RetryStrategyOptions.ShouldHandle.get -> System.Func? -RetryStrategyOptions.OnRetry.get -> ResilienceEventHandler? - -RetryStrategyOptions -RetryStrategyOptions.MaxRetryAttempts.get -> int -RetryStrategyOptions.Delay.get -> RetryDelay! -RetryStrategyOptions.UseJitter.get -> bool -RetryStrategyOptions.MaxDelay.get -> System.TimeSpan? -RetryStrategyOptions.ShouldHandle.get -> System.Func, bool>? -RetryStrategyOptions.OnRetry.get -> ResilienceEventHandler? - -RetryDelay -RetryDelay.Constant(System.TimeSpan) -> RetryDelay! -RetryDelay.Linear(System.TimeSpan, System.TimeSpan) -> RetryDelay! -RetryDelay.Exponential(System.TimeSpan) -> RetryDelay! -RetryDelay.Exponential(System.TimeSpan, System.TimeSpan) -> RetryDelay! -RetryDelay.Custom(System.Func!) -> RetryDelay! - -Strategy -Strategy.ExecuteAsync(System.Func>>!, ResilienceContext!) -> System.Threading.Tasks.ValueTask> -Strategy.Execute(System.Func>!, ResilienceContext!) -> Outcome -Strategy.Dispose() -> void -Strategy.DisposeAsync() -> System.Threading.Tasks.ValueTask - -Strategy -Strategy.ExecuteAsync(System.Func>>!, ResilienceContext!) -> System.Threading.Tasks.ValueTask> -Strategy.Execute(System.Func>!, ResilienceContext!) -> Outcome -Strategy.Dispose() -> void -Strategy.DisposeAsync() -> System.Threading.Tasks.ValueTask - -TimeoutExtensions -TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, System.TimeSpan timeout) -> PipelineBuilder! -TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, TimeoutStrategyOptions? options) -> PipelineBuilder! -TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, System.TimeSpan timeout) -> PipelineBuilder! -TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, TimeoutStrategyOptions? options) -> PipelineBuilder! - -TimeoutRejectedException -TimeoutRejectedException.ConfiguredTimeout.get -> System.TimeSpan -TimeoutRejectedException.ElapsedTime.get -> System.TimeSpan - -TimeoutStrategyOptions -TimeoutStrategyOptions.Timeout.get -> System.TimeSpan -TimeoutStrategyOptions.Timeout.set -> void +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 +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/Retry/RetryStrategy.cs b/src/Resilion/Retry/RetryStrategy.cs index b88f8d2..47c0793 100644 --- a/src/Resilion/Retry/RetryStrategy.cs +++ b/src/Resilion/Retry/RetryStrategy.cs @@ -20,13 +20,7 @@ protected internal override async ValueTask> ExecuteAsync>> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("Retry"); - if (activity is not null) - { - activity.SetTag("strategy.name", "Retry"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("Retry", context); if (_options.MaxRetryAttempts == 0) { @@ -45,20 +39,14 @@ protected internal override async ValueTask> ExecuteAsync Execute( Func> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("Retry"); - if (activity is not null) - { - activity.SetTag("strategy.name", "Retry"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("Retry", context); if (_options.MaxRetryAttempts == 0) { @@ -129,19 +108,13 @@ protected internal override Outcome Execute( if (outcome.IsSuccess) { - if (activity is not null) - { - activity.SetTag("outcome", "success"); - } + StrategyActivity.SetOutcome(activity, "success"); return outcome; } if (!_options.ShouldHandleException(outcome.Exception!)) { - if (activity is not null) - { - activity.SetTag("outcome", "failure"); - } + StrategyActivity.SetOutcome(activity, "failure"); return outcome; } @@ -172,10 +145,7 @@ protected internal override Outcome Execute( } } - if (activity is not null) - { - activity.SetTag("outcome", "retry_exhausted"); - } + StrategyActivity.SetOutcome(activity, "retry_exhausted"); return outcome; } @@ -199,6 +169,8 @@ protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { + using var activity = StrategyActivity.Start("Retry", context); + if (_options.MaxRetryAttempts == 0) { return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); @@ -215,6 +187,7 @@ protected internal override async ValueTask> ExecuteAsync( // Check predicate against the full outcome (exception OR result). if (!_options.ShouldHandleOutcome(outcome)) { + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } @@ -246,6 +219,7 @@ await Task.Delay(delay, _timeProvider, context.CancellationToken) } } + StrategyActivity.SetOutcome(activity, "retry_exhausted"); return outcome; } @@ -253,6 +227,8 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { + using var activity = StrategyActivity.Start("Retry", context); + if (_options.MaxRetryAttempts == 0) { return callback(context); @@ -268,6 +244,7 @@ protected internal override Outcome Execute( if (!_options.ShouldHandleOutcome(outcome)) { + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } @@ -297,6 +274,7 @@ protected internal override Outcome Execute( } } + StrategyActivity.SetOutcome(activity, "retry_exhausted"); return outcome; } } diff --git a/src/Resilion/Telemetry/StrategyActivity.cs b/src/Resilion/Telemetry/StrategyActivity.cs new file mode 100644 index 0000000..a69bf98 --- /dev/null +++ b/src/Resilion/Telemetry/StrategyActivity.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; + +namespace Resilion; + +/// +/// Shared span scaffolding for strategies. +/// +/// +/// This exists so the tag set is written in exactly one place. Every strategy entry point — typed +/// and non-generic, async and synchronous — must emit an identically shaped span, and the original +/// gap this type closes was caused by the span code being duplicated per entry point and then +/// simply not copied into the typed ones. +/// +internal static class StrategyActivity +{ + /// + /// Starts a span for a strategy execution, tagged with the strategy, pipeline and operation. + /// Returns when nothing is listening, in which case the caller pays + /// nothing beyond the null check. + /// + internal static Activity? Start(string strategyName, ResilienceContext context) + { + var activity = ResilionTelemetry.ActivitySource.StartActivity(strategyName); + if (activity is not null) + { + activity.SetTag("strategy.name", strategyName); + activity.SetTag(ResilionTelemetry.PipelineNameTag, context.PipelineName); + activity.SetTag(ResilionTelemetry.OperationKeyTag, context.OperationKey); + } + + return activity; + } + + /// + /// Records how the strategy execution finished. The vocabulary is documented in + /// docs/telemetry.md and pinned by SpanOutcomeTag_MatchesDocumentedVocabulary. + /// + internal static void SetOutcome(Activity? activity, string outcome) + => activity?.SetTag("outcome", outcome); +} diff --git a/src/Resilion/Timeout/TimeoutStrategy.cs b/src/Resilion/Timeout/TimeoutStrategy.cs index c30ea8d..4711af1 100644 --- a/src/Resilion/Timeout/TimeoutStrategy.cs +++ b/src/Resilion/Timeout/TimeoutStrategy.cs @@ -20,27 +20,33 @@ protected internal override async ValueTask> ExecuteAsync>> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("Timeout"); - if (activity is not null) - { - activity.SetTag("strategy.name", "Timeout"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("Timeout", context); var timeout = ResolveTimeout(context); if (timeout == System.Threading.Timeout.InfiniteTimeSpan) { - if (activity is not null) - { - activity.SetTag("outcome", "no_timeout"); - } + StrategyActivity.SetOutcome(activity, "no_timeout"); return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); } var previousToken = context.CancellationToken; + var cause = new CancellationCause(); var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(previousToken); + cause.Cts = linkedCts; + + // Only pay for the registration when the caller's token can actually be cancelled. For a + // default CancellationToken — the common case — nothing but the timer can cancel the + // linked source, so the cause is unambiguous without it. + CancellationTokenRegistration userRegistration = default; + if (previousToken.CanBeCanceled) + { + userRegistration = previousToken.Register( + static state => ((CancellationCause)state!).SetUser(), + cause, + useSynchronizationContext: false); + } + var startTimestamp = _timeProvider.GetTimestamp(); ITimer? timer = null; @@ -49,9 +55,15 @@ protected internal override async ValueTask> ExecuteAsync { + var c = (CancellationCause)state!; + + // Record the cause before propagating it. Any thread that can observe the + // cancellation must already be able to see why it happened. + c.SetTimeout(); + try { - ((CancellationTokenSource)state!).Cancel(); + c.Cts.Cancel(); } catch { @@ -61,7 +73,7 @@ protected internal override async ValueTask> ExecuteAsync> ExecuteAsync(context, timeout, elapsed, oce).ConfigureAwait(false); - if (activity is not null) - { - activity.SetTag("outcome", "timeout"); - } + StrategyActivity.SetOutcome(activity, "timeout"); return result; } - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } - catch (OperationCanceledException oce) when (WasCancelledByTimeout(linkedCts, previousToken)) + catch (OperationCanceledException oce) when (WasCancelledByTimeout(cause)) { var elapsed = _timeProvider.GetElapsedTime(startTimestamp); var result = await HandleTimeout(context, timeout, elapsed, oce).ConfigureAwait(false); - if (activity is not null) - { - activity.SetTag("outcome", "timeout"); - } + StrategyActivity.SetOutcome(activity, "timeout"); return result; } } finally { context.CancellationToken = previousToken; + userRegistration.Dispose(); if (timer is not null) { @@ -117,27 +121,33 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { - using var activity = ResilionTelemetry.ActivitySource.StartActivity("Timeout"); - if (activity is not null) - { - activity.SetTag("strategy.name", "Timeout"); - activity.SetTag("pipeline.name", context.PipelineName); - activity.SetTag("operation.key", context.OperationKey); - } + using var activity = StrategyActivity.Start("Timeout", context); var timeout = ResolveTimeout(context); if (timeout == System.Threading.Timeout.InfiniteTimeSpan) { - if (activity is not null) - { - activity.SetTag("outcome", "no_timeout"); - } + StrategyActivity.SetOutcome(activity, "no_timeout"); return callback(context); } var previousToken = context.CancellationToken; + var cause = new CancellationCause(); var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(previousToken); + cause.Cts = linkedCts; + + // Only pay for the registration when the caller's token can actually be cancelled. For a + // default CancellationToken — the common case — nothing but the timer can cancel the + // linked source, so the cause is unambiguous without it. + CancellationTokenRegistration userRegistration = default; + if (previousToken.CanBeCanceled) + { + userRegistration = previousToken.Register( + static state => ((CancellationCause)state!).SetUser(), + cause, + useSynchronizationContext: false); + } + var startTimestamp = _timeProvider.GetTimestamp(); ITimer? timer = null; @@ -146,9 +156,15 @@ protected internal override Outcome Execute( timer = _timeProvider.CreateTimer( static state => { + var c = (CancellationCause)state!; + + // Record the cause before propagating it. Any thread that can observe the + // cancellation must already be able to see why it happened. + c.SetTimeout(); + try { - ((CancellationTokenSource)state!).Cancel(); + c.Cts.Cancel(); } catch { @@ -158,7 +174,7 @@ protected internal override Outcome Execute( // already been triggered via cancellation request, so suppressing is acceptable. } }, - linkedCts, + cause, timeout, System.Threading.Timeout.InfiniteTimeSpan); @@ -166,38 +182,30 @@ protected internal override Outcome Execute( var outcome = callback(context); if (outcome.Exception is OperationCanceledException oce - && WasCancelledByTimeout(linkedCts, previousToken)) + && WasCancelledByTimeout(cause)) { var elapsed = _timeProvider.GetElapsedTime(startTimestamp); HandleTimeoutSync(context, timeout, elapsed); - if (activity is not null) - { - activity.SetTag("outcome", "timeout"); - } + StrategyActivity.SetOutcome(activity, "timeout"); return Outcome.FromException( new TimeoutRejectedException(timeout, elapsed, oce)); } - if (activity is not null) - { - activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); - } + StrategyActivity.SetOutcome(activity, outcome.IsSuccess ? "success" : "failure"); return outcome; } - catch (OperationCanceledException oce) when (WasCancelledByTimeout(linkedCts, previousToken)) + catch (OperationCanceledException oce) when (WasCancelledByTimeout(cause)) { var elapsed = _timeProvider.GetElapsedTime(startTimestamp); HandleTimeoutSync(context, timeout, elapsed); - if (activity is not null) - { - activity.SetTag("outcome", "timeout"); - } + StrategyActivity.SetOutcome(activity, "timeout"); return Outcome.FromException( new TimeoutRejectedException(timeout, elapsed, oce)); } finally { context.CancellationToken = previousToken; + userRegistration.Dispose(); timer?.Dispose(); linkedCts.Dispose(); } @@ -215,14 +223,46 @@ private TimeSpan ResolveTimeout(ResilienceContext context) /// /// Determines whether the cancellation was caused by our timeout rather than the user's token. - /// In rare cases where user cancellation races with timeout, this may misclassify. + /// This is a single volatile read of a cause recorded at the moment cancellation happened, so + /// there is no time-of-check/time-of-use window: a later cancellation from the other source + /// cannot retroactively change the answer. + /// + private static bool WasCancelledByTimeout(CancellationCause cause) => cause.WasTimeout; + + /// + /// Records which cause cancelled the linked token first. The first writer wins via + /// compare-and-swap, which is what makes classification race-free. /// - private static bool WasCancelledByTimeout( - CancellationTokenSource linkedCts, - CancellationToken userToken) + /// + /// Reading linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested + /// instead is two separate reads, and the user token can change between them — misclassifying + /// user cancellation as a timeout. Pre-capturing the user token's state before the callback does + /// not fix it either; it only reverses the direction of the error, turning a real timeout into a + /// reported user cancellation when the user cancels after the timer fires. The question that + /// actually needs answering is "which happened first?", so that is what this records. + /// + /// Ordering is guaranteed: invokes callbacks in reverse + /// registration order, and the linked source's internal propagation registration is created by + /// CreateLinkedTokenSource — before ours. So on user cancellation SetUser runs + /// before linkedCts reports cancellation. The timer calls SetTimeout before + /// Cancel. Either way the cause is recorded before any consumer can observe the effect. + /// + /// + private sealed class CancellationCause { - // Check both atomically as possible: if linked CTS fired AND user token didn't. - return linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested; + private const int None = 0; + private const int Timeout = 1; + private const int User = 2; + + private int _cause; + + internal CancellationTokenSource Cts = null!; + + internal void SetTimeout() => Interlocked.CompareExchange(ref _cause, Timeout, None); + + internal void SetUser() => Interlocked.CompareExchange(ref _cause, User, None); + + internal bool WasTimeout => Volatile.Read(ref _cause) == Timeout; } /// diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 62fdd25..ca4eef8 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -18,15 +18,25 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + + + + diff --git a/tests/Resilion.Extensions.Tests/Configuration/StrategyConfigurationTests.cs b/tests/Resilion.Extensions.Tests/Configuration/StrategyConfigurationTests.cs new file mode 100644 index 0000000..08f57db --- /dev/null +++ b/tests/Resilion.Extensions.Tests/Configuration/StrategyConfigurationTests.cs @@ -0,0 +1,272 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Resilion.Extensions.Configuration; +using Xunit; + +namespace Resilion.Extensions.Tests; + +/// +/// The bindable DTO layer — future-plans #44. +/// +public class RetryDelayConfigurationTests +{ + [Fact] + public void DefaultKind_IsExponential() + => Assert.Equal(RetryDelayKind.Exponential, new RetryDelayConfiguration().Kind); + + [Theory] + [InlineData(RetryDelayKind.Constant)] + [InlineData(RetryDelayKind.Linear)] + [InlineData(RetryDelayKind.Exponential)] + public void ToRetryDelay_EveryKind_ProducesADelay(RetryDelayKind kind) + { + var config = new RetryDelayConfiguration { Kind = kind, BaseDelay = TimeSpan.FromSeconds(2) }; + + Assert.NotNull(config.ToRetryDelay()); + } + + [Theory] + [InlineData(RetryDelayKind.Constant)] + [InlineData(RetryDelayKind.Linear)] + [InlineData(RetryDelayKind.Exponential)] + public void ToRetryDelay_ProjectsToTheMatchingFactory(RetryDelayKind kind) + { + // RetryDelay is a record, so value equality compares the variant and its parameters — + // enough to prove the projection without reaching into the internal ComputeDelay. + var baseDelay = TimeSpan.FromSeconds(3); + var fromConfig = new RetryDelayConfiguration { Kind = kind, BaseDelay = baseDelay }.ToRetryDelay(); + + var fromFactory = kind switch + { + RetryDelayKind.Constant => RetryDelay.Constant(baseDelay), + RetryDelayKind.Linear => RetryDelay.Linear(baseDelay), + _ => RetryDelay.Exponential(baseDelay), + }; + + Assert.Equal(fromFactory, fromConfig); + } + + [Fact] + public void ToRetryDelay_DifferentKinds_AreNotEqual() + { + var baseDelay = TimeSpan.FromSeconds(3); + + Assert.NotEqual( + new RetryDelayConfiguration { Kind = RetryDelayKind.Linear, BaseDelay = baseDelay }.ToRetryDelay(), + new RetryDelayConfiguration { Kind = RetryDelayKind.Exponential, BaseDelay = baseDelay }.ToRetryDelay()); + } + + [Fact] + public void RetryDelayKind_HasNoCustomMember() + { + // Custom takes a delegate, which cannot come from configuration. Offering it would mean a + // bindable value that silently does nothing. + Assert.DoesNotContain("Custom", Enum.GetNames()); + } + + [Fact] + public void ToRetryDelay_UnknownKind_Throws() + => Assert.Throws( + () => new RetryDelayConfiguration { Kind = (RetryDelayKind)99 }.ToRetryDelay()); +} + +/// +/// Binding nothing must be identical to passing no options. Drift here is a silent behaviour +/// change, which is the whole reason these exist. +/// +public class StrategyConfigurationDefaultsTests +{ + [Fact] + public void Retry_DefaultsMatchTheOptionsRecord() + { + var fromConfig = new RetryStrategyConfiguration().ToOptions(); + var fromRecord = new RetryStrategyOptions(); + + Assert.Equal(fromRecord.MaxRetryAttempts, fromConfig.MaxRetryAttempts); + Assert.Equal(fromRecord.UseJitter, fromConfig.UseJitter); + Assert.Equal(fromRecord.MaxDelay, fromConfig.MaxDelay); + Assert.Equal(fromRecord.Delay, fromConfig.Delay); + } + + [Fact] + public void Timeout_DefaultsMatchTheOptionsRecord() + => Assert.Equal( + new TimeoutStrategyOptions().Timeout, + new TimeoutStrategyConfiguration().ToOptions().Timeout); + + [Fact] + public void CircuitBreaker_DefaultsMatchTheOptionsRecord() + { + var fromConfig = new CircuitBreakerStrategyConfiguration().ToOptions(); + var fromRecord = new CircuitBreakerStrategyOptions(); + + Assert.Equal(fromRecord.FailureRatioThreshold, fromConfig.FailureRatioThreshold); + Assert.Equal(fromRecord.SamplingDuration, fromConfig.SamplingDuration); + Assert.Equal(fromRecord.MinimumThroughput, fromConfig.MinimumThroughput); + Assert.Equal(fromRecord.BreakDuration, fromConfig.BreakDuration); + } + + [Fact] + public void Hedging_DefaultsMatchTheOptionsRecord() + { + var fromConfig = new HedgingStrategyConfiguration().ToOptions(); + var fromRecord = new HedgingStrategyOptions(); + + Assert.Equal(fromRecord.MaxHedgedAttempts, fromConfig.MaxHedgedAttempts); + Assert.Equal(fromRecord.HedgingDelay, fromConfig.HedgingDelay); + } + + [Fact] + public void ToOptions_LeavesDelegatesUnset_SoStrategyDefaultsApply() + { + Assert.Null(new RetryStrategyConfiguration().ToOptions().ShouldHandle); + Assert.Null(new RetryStrategyConfiguration().ToOptions().OnRetry); + Assert.Null(new CircuitBreakerStrategyConfiguration().ToOptions().ShouldHandle); + Assert.Null(new TimeoutStrategyConfiguration().ToOptions().TimeoutGenerator); + Assert.Null(new HedgingStrategyConfiguration().ToOptions().ShouldHandle); + } +} + +/// Binding from a real , and composing with delegates. +public class StrategyConfigurationBindingTests +{ + private static IConfigurationRoot Config(params (string Key, string Value)[] values) + => new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(v => v.Key, v => (string?)v.Value)) + .Build(); + + [Fact] + public void BindsFromConfiguration_IncludingTimeSpanStrings() + { + var config = Config( + ("Retry:MaxRetryAttempts", "7"), + ("Retry:UseJitter", "false"), + ("Retry:MaxDelay", "00:00:30"), + ("Retry:Delay:Kind", "Linear"), + ("Retry:Delay:BaseDelay", "00:00:05")); + + var bound = new RetryStrategyConfiguration(); + config.GetSection("Retry").Bind(bound); + + Assert.Equal(7, bound.MaxRetryAttempts); + Assert.False(bound.UseJitter); + Assert.Equal(TimeSpan.FromSeconds(30), bound.MaxDelay); + Assert.Equal(RetryDelayKind.Linear, bound.Delay.Kind); + Assert.Equal(TimeSpan.FromSeconds(5), bound.Delay.BaseDelay); + } + + [Fact] + public void MaxDelay_CapsEveryKind_IncludingConstant() + { + // The reason there is one cap on the strategy rather than one per backoff kind: + // RetryDelay.Constant's factory takes no maximum at all. + var options = new RetryStrategyConfiguration + { + MaxDelay = TimeSpan.FromSeconds(1), + Delay = new RetryDelayConfiguration + { + Kind = RetryDelayKind.Constant, + BaseDelay = TimeSpan.FromMinutes(5), + }, + }.ToOptions(); + + Assert.Equal(TimeSpan.FromSeconds(1), options.MaxDelay); + } + + [Fact] + public async Task Async_BoundOptions_DriveARealPipeline() + { + var config = Config( + ("Retry:MaxRetryAttempts", "2"), + ("Retry:Delay:Kind", "Constant"), + ("Retry:Delay:BaseDelay", "00:00:00")); + + var bound = new RetryStrategyConfiguration(); + config.GetSection("Retry").Bind(bound); + + var attempts = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(bound.ToOptions())); + + await Assert.ThrowsAsync(async () => + await pipeline.ExecuteAsync(ct => + { + attempts++; + throw new InvalidOperationException(); + })); + + Assert.Equal(3, attempts); // 1 initial + 2 retries + } + + [Fact] + public async Task Async_WithExpression_LayersDelegatesOntoBoundOptions() + { + // The documented composition: configuration carries the numbers, code carries the + // delegates. No extra API needed because the options are records. + var bound = new RetryStrategyConfiguration + { + MaxRetryAttempts = 1, + Delay = new RetryDelayConfiguration { Kind = RetryDelayKind.Constant, BaseDelay = TimeSpan.Zero }, + }; + + var attempts = 0; + var pipeline = Pipeline.Create(b => b.AddRetry( + bound.ToOptions() with { ShouldHandle = ex => ex is TimeoutException })); + + // Not a TimeoutException, so the predicate rejects it and no retry happens. + await Assert.ThrowsAsync(async () => + await pipeline.ExecuteAsync(ct => + { + attempts++; + throw new InvalidOperationException(); + })); + + Assert.Equal(1, attempts); + } + + [Fact] + public void ToOptions_InvalidValue_IsRejectedByTheRecordsOwnValidation() + { + // The DTO does not re-implement validation; the options record still owns it. + var options = new RetryStrategyConfiguration { MaxRetryAttempts = -1 }.ToOptions(); + + Assert.Throws( + () => Pipeline.Create(b => b.AddRetry(options))); + } + + [Fact] + public async Task Async_ConfigurationReload_RebuildsThePipeline() + { + // #44 composes with #41: bind the DTO, and the pipeline follows the file. + var source = new Dictionary { ["Retry:MaxRetryAttempts"] = "1" }; + var config = new ConfigurationBuilder().AddInMemoryCollection(source).Build(); + + var services = new ServiceCollection(); + services.Configure(config.GetSection("Retry")); + services.AddResiliencePipeline("api", (b, c) => + b.AddRetry(c.ToOptions() with { Delay = RetryDelay.None })); + + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService>(); + + var attempts = 0; + await Assert.ThrowsAsync(async () => + await registry.GetPipeline("api").ExecuteAsync(ct => + { + attempts++; + throw new InvalidOperationException(); + })); + Assert.Equal(2, attempts); + + config["Retry:MaxRetryAttempts"] = "4"; + config.Reload(); + + attempts = 0; + await Assert.ThrowsAsync(async () => + await registry.GetPipeline("api").ExecuteAsync(ct => + { + attempts++; + throw new InvalidOperationException(); + })); + Assert.Equal(5, attempts); + } +} diff --git a/tests/Resilion.Extensions.Tests/KeyedServicesTests.cs b/tests/Resilion.Extensions.Tests/KeyedServicesTests.cs new file mode 100644 index 0000000..311b40c --- /dev/null +++ b/tests/Resilion.Extensions.Tests/KeyedServicesTests.cs @@ -0,0 +1,202 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Resilion.Extensions.Tests; + +/// +/// Keyed DI resolution of named pipelines — future-plans #42. Adds no Resilion API; the +/// consumer-facing surface is the BCL's [FromKeyedServices]. +/// +public class KeyedServicesTests +{ + private sealed class Consumer + { + public Consumer([FromKeyedServices("api")] Pipeline pipeline) => Pipeline = pipeline; + + public Pipeline Pipeline { get; } + } + + private sealed class RetryOptions + { + public int MaxRetryAttempts { get; set; } = 1; + } + + // ─── Resolution ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_KeyedSingleton_Untyped_ResolvesAndExecutes() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + using var provider = services.BuildServiceProvider(); + + var pipeline = provider.GetRequiredKeyedService("api"); + + Assert.Equal("ok", await pipeline.ExecuteAsync(ct => new ValueTask("ok"))); + } + + [Fact] + public async Task Async_KeyedSingleton_Typed_ResolvesAndExecutes() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + using var provider = services.BuildServiceProvider(); + + var pipeline = provider.GetRequiredKeyedService>("api"); + + Assert.Equal("ok", await pipeline.ExecuteAsync(ct => new ValueTask("ok"))); + } + + [Fact] + public void KeyedSingleton_IsSameInstanceAsRegistryGetPipeline() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + using var provider = services.BuildServiceProvider(); + + var viaKey = provider.GetRequiredKeyedService("api"); + var viaRegistry = provider.GetRequiredService>().GetPipeline("api"); + + Assert.Same(viaRegistry, viaKey); + } + + [Fact] + public void KeyedSingleton_ResolvedTwice_IsSameInstance() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + using var provider = services.BuildServiceProvider(); + + Assert.Same( + provider.GetRequiredKeyedService("api"), + provider.GetRequiredKeyedService("api")); + } + + [Fact] + public void KeyedSingleton_TwoNames_ResolveDifferentPipelines() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("a", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + services.AddResiliencePipeline("b", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + using var provider = services.BuildServiceProvider(); + + Assert.NotSame( + provider.GetRequiredKeyedService("a"), + provider.GetRequiredKeyedService("b")); + } + + [Fact] + public void KeyedSingleton_SameNameDifferentResultTypes_ResolveIndependently() + { + // Typed pipelines are keyed by name AND result type in the registry; keyed DI mirrors that + // because the service type differs. + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider.GetRequiredKeyedService>("api")); + Assert.NotNull(provider.GetRequiredKeyedService>("api")); + } + + [Fact] + public void KeyedSingleton_UnregisteredKey_Throws() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + using var provider = services.BuildServiceProvider(); + + Assert.Throws( + () => provider.GetRequiredKeyedService("nope")); + } + + [Fact] + public async Task Async_FromKeyedServices_ConstructorInjection_ResolvesPipeline() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + services.AddSingleton(); + using var provider = services.BuildServiceProvider(); + + var consumer = provider.GetRequiredService(); + + Assert.Equal("ok", await consumer.Pipeline.ExecuteAsync(ct => new ValueTask("ok"))); + } + + [Fact] + public void AddResiliencePipeline_CalledTwiceSameName_RegistersOneKeyedDescriptor() + { + // TryAddKeyedSingleton, so the duplicate-key error stays with the registry where it belongs. + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + + var keyed = services.Count(d => + d.IsKeyedService && Equals(d.ServiceKey, "api") && d.ServiceType == typeof(Pipeline)); + + Assert.Equal(1, keyed); + } + + // ─── Disposal ─────────────────────────────────────────────────────────────────────────── + + [Fact] + public void ProviderDispose_WithKeyedPipeline_DoesNotThrow() + { + // The container disposes the keyed singleton AND the registry disposes every pipeline it + // created, so the same instance is disposed twice. Idempotent Dispose makes that safe. + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredKeyedService("api"); + + provider.Dispose(); + } + + [Fact] + public void RegistryDisposeThenProviderDispose_DoesNotThrow() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + var provider = services.BuildServiceProvider(); + + var registry = provider.GetRequiredService>(); + _ = provider.GetRequiredKeyedService("api"); + + registry.Dispose(); + provider.Dispose(); + } + + // ─── The documented incompatibility with reload ───────────────────────────────────────── + + [Fact] + public void KeyedPipeline_DoesNotObserveInvalidation_DocumentedLimitation() + { + // Inherent, not a bug: a keyed singleton resolves once for the container's lifetime, so it + // cannot see a reload. This test exists so the limitation is pinned rather than discovered. + // Reloadable pipelines must be resolved per call via IPipelineProvider. + var source = new Dictionary { ["Retry:MaxRetryAttempts"] = "1" }; + var config = new ConfigurationBuilder().AddInMemoryCollection(source).Build(); + + var services = new ServiceCollection(); + services.Configure(config.GetSection("Retry")); + services.AddResiliencePipeline("api", (b, o) => + b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts })); + using var provider = services.BuildServiceProvider(); + + var keyed = provider.GetRequiredKeyedService("api"); + var registry = provider.GetRequiredService>(); + var beforeReload = registry.GetPipeline("api"); + + config["Retry:MaxRetryAttempts"] = "5"; + config.Reload(); + + // The registry hands out a rebuilt pipeline... + Assert.NotSame(beforeReload, registry.GetPipeline("api")); + + // ...but the keyed singleton is still the original instance. + Assert.Same(keyed, provider.GetRequiredKeyedService("api")); + Assert.Same(beforeReload, keyed); + } +} diff --git a/tests/Resilion.Extensions.Tests/PipelineReloadTests.cs b/tests/Resilion.Extensions.Tests/PipelineReloadTests.cs new file mode 100644 index 0000000..6b570b8 --- /dev/null +++ b/tests/Resilion.Extensions.Tests/PipelineReloadTests.cs @@ -0,0 +1,332 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Resilion.Extensions.Tests; + +/// +/// Dynamic pipeline reload via — future-plans #41. +/// +public class PipelineReloadTests +{ + private sealed class RetryOptions + { + public int MaxRetryAttempts { get; set; } = 1; + } + + /// + /// Drives change notifications without a file watcher: a memory configuration source plus + /// Reload() is the smallest thing that makes IOptionsMonitor.OnChange fire. + /// + private static (ServiceProvider Provider, IConfigurationRoot Config) BuildProvider( + Action configureServices, + string initialAttempts = "1") + { + var source = new Dictionary { ["Retry:MaxRetryAttempts"] = initialAttempts }; + var config = new ConfigurationBuilder().AddInMemoryCollection(source).Build(); + + var services = new ServiceCollection(); + services.Configure(config.GetSection("Retry")); + configureServices(services); + + return (services.BuildServiceProvider(), config); + } + + private static void SetAttempts(IConfigurationRoot config, string value) + { + config["Retry:MaxRetryAttempts"] = value; + config.Reload(); + } + + // ─── Reload rebuilds ──────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_OptionsChange_NextGetPipelineReturnsRebuiltPipeline() + { + var (provider, config) = BuildProvider(s => + s.AddResiliencePipeline("api", (b, o) => b.AddRetry( + new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts, Delay = RetryDelay.None }))); + using var scope = provider; + + var registry = provider.GetRequiredService>(); + + var attempts = 0; + await Assert.ThrowsAsync(async () => + await registry.GetPipeline("api").ExecuteAsync(ct => + { + attempts++; + throw new InvalidOperationException(); + })); + Assert.Equal(2, attempts); // 1 initial + 1 retry + + SetAttempts(config, "3"); + + attempts = 0; + await Assert.ThrowsAsync(async () => + await registry.GetPipeline("api").ExecuteAsync(ct => + { + attempts++; + throw new InvalidOperationException(); + })); + Assert.Equal(4, attempts); // 1 initial + 3 retries + } + + [Fact] + public void ConfigureDelegate_ReadsFreshOptionsOnEveryRebuild() + { + var builds = 0; + var (provider, config) = BuildProvider(s => + s.AddResiliencePipeline("api", (b, o) => + { + builds++; + b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts }); + })); + using var scope = provider; + + var registry = provider.GetRequiredService>(); + + _ = registry.GetPipeline("api"); + _ = registry.GetPipeline("api"); + Assert.Equal(1, builds); // cached + + SetAttempts(config, "2"); + _ = registry.GetPipeline("api"); + Assert.Equal(2, builds); // rebuilt once, not per access + } + + [Fact] + public async Task Async_InFlightExecution_CompletesOnTheOldPipeline() + { + var (provider, config) = BuildProvider(s => + s.AddResiliencePipeline("api", (b, o) => b.AddRetry( + new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts, Delay = RetryDelay.None }))); + using var scope = provider; + + var registry = provider.GetRequiredService>(); + var pipeline = registry.GetPipeline("api"); + + var entered = new TaskCompletionSource(); + var release = new TaskCompletionSource(); + + var execution = pipeline.ExecuteAsync(async ct => + { + entered.SetResult(); + await release.Task; + return "ok"; + }).AsTask(); + + await entered.Task; + SetAttempts(config, "9"); // invalidates while the execution is mid-flight + release.SetResult(); + + Assert.Equal("ok", await execution); + } + + // ─── Invalidation semantics ───────────────────────────────────────────────────────────── + + [Fact] + public void InvalidatePipeline_UnknownKey_ReturnsFalse() + { + var registry = new ResiliencePipelineRegistry(); + + Assert.False(registry.InvalidatePipeline("nope")); + } + + [Fact] + public void InvalidatePipeline_RegisteredButNeverBuilt_ReturnsFalseAndDoesNotInvokeCallback() + { + var invoked = 0; + var registry = new ResiliencePipelineRegistry + { + OnPipelineReplaced = _ => invoked++, + }; + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + + // Nothing cached yet, so there is nothing to replace — the next access builds a current one. + Assert.False(registry.InvalidatePipeline("api")); + Assert.Equal(0, invoked); + } + + [Fact] + public void OnPipelineReplaced_ReceivesKeyResultTypeAndOldPipeline() + { + PipelineReplacedArgs? seen = null; + var registry = new ResiliencePipelineRegistry + { + OnPipelineReplaced = args => seen = args, + }; + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + + var original = registry.GetPipeline("api"); + Assert.True(registry.InvalidatePipeline("api")); + + Assert.NotNull(seen); + Assert.Equal("api", seen!.Value.Key); + Assert.Null(seen.Value.ResultType); // non-generic + Assert.Same(original, seen.Value.ReplacedPipeline); + } + + [Fact] + public void OnPipelineReplaced_Typed_ReportsResultType() + { + PipelineReplacedArgs? seen = null; + var registry = new ResiliencePipelineRegistry + { + OnPipelineReplaced = args => seen = args, + }; + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + + _ = registry.GetPipeline("api"); + Assert.True(registry.InvalidatePipeline("api")); + + Assert.NotNull(seen); + Assert.Equal(typeof(int), seen!.Value.ResultType); + } + + [Fact] + public async Task Async_ReplacedPipeline_IsNotDisposedByTheRegistry() + { + // The registry cannot know when in-flight executions finish, so disposal is the caller's + // call. An evicted pipeline must still be usable. + var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + + var original = registry.GetPipeline("api"); + registry.InvalidatePipeline("api"); + + Assert.Equal("ok", await original.ExecuteAsync(ct => new ValueTask("ok"))); + } + + [Fact] + public void InvalidatePipeline_Typed_OnlyAffectsThatResultType() + { + var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + + var intPipeline = registry.GetPipeline("api"); + var stringPipeline = registry.GetPipeline("api"); + + Assert.True(registry.InvalidatePipeline("api")); + + Assert.NotSame(intPipeline, registry.GetPipeline("api")); + Assert.Same(stringPipeline, registry.GetPipeline("api")); + } + + [Fact] + public void FactoryIsPreserved_AcrossInvalidation() + { + // Reload works precisely because the factory survives; builders are single-use. + var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + + for (var i = 0; i < 3; i++) + { + Assert.NotNull(registry.GetPipeline("api")); + registry.InvalidatePipeline("api"); + } + + Assert.NotNull(registry.GetPipeline("api")); + } + + // ─── TryGetPipeline ──────────────────────────────────────────────────────────── + + [Fact] + public void TryGetPipelineTyped_RegisteredKey_ReturnsTrue() + { + var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + + Assert.True(registry.TryGetPipeline("api", out var pipeline)); + Assert.NotNull(pipeline); + } + + [Fact] + public void TryGetPipelineTyped_WrongResultType_ReturnsFalseAndNull() + { + var registry = new ResiliencePipelineRegistry(); + registry.RegisterPipeline("api", b => b.AddTimeout(TimeSpan.FromSeconds(1))); + + Assert.False(registry.TryGetPipeline("api", out var pipeline)); + Assert.Null(pipeline); + } + + // ─── Wiring ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void RegistryConfigurators_RunBeforePipelineConfigurators() + { + // Ordering matters: a registry-level setting must be in place before any pipeline can be + // built, or there is a window where one is built without it. + var replaced = 0; + var (provider, config) = BuildProvider(s => + { + s.ConfigureResiliencePipelineRegistry(r => r.OnPipelineReplaced = _ => replaced++); + s.AddResiliencePipeline("api", (b, o) => b.AddRetry( + new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts })); + }); + using var scope = provider; + + var registry = provider.GetRequiredService>(); + _ = registry.GetPipeline("api"); + + SetAttempts(config, "2"); + + Assert.Equal(1, replaced); + } + + [Fact] + public void NamedOptions_OptionsNameOverload_BindsTheNamedInstance() + { + var services = new ServiceCollection(); + services.Configure("fast", o => o.MaxRetryAttempts = 7); + services.AddResiliencePipeline("api", "fast", (b, o) => + b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts, Delay = RetryDelay.None })); + + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService>(); + + var attempts = 0; + Assert.Throws(() => + registry.GetPipeline("api").Execute(ct => + { + attempts++; + throw new InvalidOperationException(); + })); + + Assert.Equal(8, attempts); // 1 initial + 7 retries + } + + [Fact] + public void RegistryDispose_DisposesReloadSubscriptions() + { + var (provider, config) = BuildProvider(s => + s.AddResiliencePipeline("api", (b, o) => b.AddRetry( + new RetryStrategyOptions { MaxRetryAttempts = o.MaxRetryAttempts }))); + + var registry = provider.GetRequiredService>(); + _ = registry.GetPipeline("api"); + + registry.Dispose(); + + // A change arriving after teardown must not throw out of the notification callback. + SetAttempts(config, "5"); + provider.Dispose(); + } + + [Fact] + public async Task Async_FailedLookupStillDoesNotPoisonCache_AfterInvalidate() + { + // Regression guard on the pre-existing invariant, re-checked around the new code path. + var registry = new ResiliencePipelineRegistry(); + + Assert.Throws(() => registry.GetPipeline("late")); + + registry.RegisterPipeline("late", b => b.AddTimeout(TimeSpan.FromSeconds(30))); + Assert.Equal("ok", await registry.GetPipeline("late").ExecuteAsync(ct => new ValueTask("ok"))); + + registry.InvalidatePipeline("late"); + Assert.Equal("ok", await registry.GetPipeline("late").ExecuteAsync(ct => new ValueTask("ok"))); + } +} diff --git a/tests/Resilion.Extensions.Tests/Resilion.Extensions.Tests.csproj b/tests/Resilion.Extensions.Tests/Resilion.Extensions.Tests.csproj index 049ef1c..37dd14e 100644 --- a/tests/Resilion.Extensions.Tests/Resilion.Extensions.Tests.csproj +++ b/tests/Resilion.Extensions.Tests/Resilion.Extensions.Tests.csproj @@ -11,8 +11,12 @@ - + + + + diff --git a/tests/Resilion.Http.Tests/CustomResilienceHandlerTests.cs b/tests/Resilion.Http.Tests/CustomResilienceHandlerTests.cs new file mode 100644 index 0000000..74fb831 --- /dev/null +++ b/tests/Resilion.Http.Tests/CustomResilienceHandlerTests.cs @@ -0,0 +1,345 @@ +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Resilion.Extensions; +using Xunit; + +namespace Resilion.Http.Tests; + +/// +/// AddResilienceHandler, and the overloads across all three +/// handler kinds. +/// +public class CustomResilienceHandlerTests +{ + private static readonly Uri Endpoint = new("https://example.test/data"); + + private sealed class Marker + { + public string Value { get; init; } = "from-di"; + } + + private static Task SendAsync(HttpClient client) + => client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, Endpoint), + HttpCompletionOption.ResponseHeadersRead); + + // ─── A caller-built pipeline ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_CustomPipeline_AppliesTheCallersStrategies() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.ServiceUnavailable, HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddResilienceHandler("custom", pb => pb.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + // Result-only: an exception would not match, so a silently skipped typed + // strategy would return the first 503 after one send. + ShouldHandle = o => o.TryGetResult(out var r) + && r is not null + && r.StatusCode == HttpStatusCode.ServiceUnavailable, + }))); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, stub.Sends); + } + + [Fact] + public async Task Async_CustomPipeline_CanUsePredicateBuilder() + { + // The sugar from #43 reaching its intended audience. + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.BadGateway, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddResilienceHandler("custom", pb => pb.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = new PredicateBuilder() + .Handle() + .HandleResult(r => (int)r.StatusCode >= 500), + }))); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + } + + [Fact] + public async Task Async_CustomPipeline_TimeProviderComesFromTheContainer() + { + var fakeTime = new FakeTimeProvider(); + var entered = new TaskCompletionSource(); + + var stub = new StubHttpMessageHandler(async (_, _, ct) => + { + entered.TrySetResult(); + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddResilienceHandler("custom", pb => pb.AddTimeout(TimeSpan.FromSeconds(1))), + s => s.AddSingleton(fakeTime)); + using var scope = provider; + + var request = SendAsync(client); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + fakeTime.Advance(TimeSpan.FromSeconds(2)); + + await Assert.ThrowsAsync(() => request); + } + + [Fact] + public async Task Async_CustomPipeline_CanOverrideTheTimeProvider() + { + // ConfigureCustom pre-seeds from the container, but the caller still owns the builder. + var containerTime = new FakeTimeProvider(); + var ownTime = new FakeTimeProvider(); + var entered = new TaskCompletionSource(); + + var stub = new StubHttpMessageHandler(async (_, _, ct) => + { + entered.TrySetResult(); + await Task.Delay(TimeSpan.FromMinutes(1), ownTime, ct); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddResilienceHandler("custom", pb => + { + pb.TimeProvider = ownTime; + pb.AddTimeout(TimeSpan.FromSeconds(1)); + }), + s => s.AddSingleton(containerTime)); + using var scope = provider; + + var request = SendAsync(client); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + // Advancing the container's clock must do nothing; only the caller's clock drives it. + containerTime.Advance(TimeSpan.FromSeconds(30)); + Assert.False(request.IsCompleted); + + ownTime.Advance(TimeSpan.FromSeconds(2)); + await Assert.ThrowsAsync(() => request); + } + + // ─── The IServiceProvider overloads ───────────────────────────────────────────────────── + + [Fact] + public async Task Async_CustomPipeline_ServiceProviderOverload_ResolvesSingletons() + { + var seen = string.Empty; + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddResilienceHandler("custom", (pb, sp) => + { + seen = sp.GetRequiredService().Value; + pb.AddTimeout(TimeSpan.FromSeconds(30)); + }), + s => s.AddSingleton(new Marker())); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("from-di", seen); + } + + [Fact] + public async Task Async_StandardHandler_ServiceProviderOverload_ResolvesSingletons() + { + var seen = string.Empty; + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardResilienceHandler((o, sp) => + { + seen = sp.GetRequiredService().Value; + o.Retry = o.Retry with { Delay = RetryDelay.None }; + }), + s => s.AddSingleton(new Marker())); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal("from-di", seen); + } + + [Fact] + public async Task Async_HedgingHandler_ServiceProviderOverload_ResolvesSingletons() + { + var seen = string.Empty; + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardHedgingHandler((o, sp) => + { + seen = sp.GetRequiredService().Value; + o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 1, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + }; + }), + s => s.AddSingleton(new Marker())); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal("from-di", seen); + } + + [Fact] + public async Task Async_CircuitBreakerState_SurvivesHandlerRotation() + { + // The invariant behind building the pipeline in the registry rather than in the handler + // factory. That factory re-runs on every IHttpClientFactory rotation, so a pipeline built + // there would reset the breaker on each one — a breaker that can never trip, with no + // symptom a user could attribute to this library. + // + // Deliberately slow: SetHandlerLifetime enforces a one-second minimum, and there is no way + // to force a rotation without waiting for one. Worth ~1.3s for this particular invariant. + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.InternalServerError); + + var services = new ServiceCollection(); + services.AddSingleton(new Marker()); + services.AddHttpClient(TestClientFactory.ClientName) + .ConfigurePrimaryHttpMessageHandler(() => stub) + .AddStandardResilienceHandler((o, sp) => + { + _ = sp.GetRequiredService(); + o.Retry = o.Retry with { MaxRetryAttempts = 0 }; + o.CircuitBreaker = o.CircuitBreaker with + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + SamplingDuration = TimeSpan.FromMinutes(5), + BreakDuration = TimeSpan.FromMinutes(5), + }; + }) + .SetHandlerLifetime(TimeSpan.FromSeconds(1)); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + + // Trip the breaker. + for (var i = 0; i < 2; i++) + { + (await SendAsync(factory.CreateClient(TestClientFactory.ClientName))).Dispose(); + } + + await Assert.ThrowsAsync( + () => SendAsync(factory.CreateClient(TestClientFactory.ClientName))); + + var sendsBeforeRotation = stub.Sends; + + // Let the handler expire, then force a fresh chain. + await Task.Delay(TimeSpan.FromMilliseconds(1300)); + + // Still open, and still not reaching the network. Also proves the root provider the + // configure delegate captured was not the handler's ~2-minute scope: resolving from a + // disposed scope would throw ObjectDisposedException instead. + await Assert.ThrowsAsync( + () => SendAsync(factory.CreateClient(TestClientFactory.ClientName))); + + Assert.Equal(sendsBeforeRotation, stub.Sends); + } + + // ─── Keying ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_TwoCustomHandlersOnOneClient_BothApply() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + { + b.AddResilienceHandler("outer", pb => pb.AddTimeout(TimeSpan.FromSeconds(30))); + b.AddResilienceHandler("inner", pb => pb.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = HttpResiliencePredicates.IsTransient, + })); + }); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + } + + [Fact] + public void Registration_SameCustomKeyTwice_ThrowsAtRegistrationTime() + { + var services = new ServiceCollection(); + var builder = services.AddHttpClient(TestClientFactory.ClientName); + + builder.AddResilienceHandler("dup", pb => pb.AddTimeout(TimeSpan.FromSeconds(1))); + + var ex = Assert.Throws(() => + builder.AddResilienceHandler("dup", pb => pb.AddTimeout(TimeSpan.FromSeconds(1)))); + + Assert.Contains("dup", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Registration_NullOrBlankKey_Throws(string? key) + { + var services = new ServiceCollection(); + var builder = services.AddHttpClient(TestClientFactory.ClientName); + + Assert.ThrowsAny(() => + builder.AddResilienceHandler(key!, pb => pb.AddTimeout(TimeSpan.FromSeconds(1)))); + } + + [Fact] + public async Task Async_CustomPipelineKey_DoesNotCollideWithAUserRegisteredPipeline() + { + // Ours is keyed ("api/custom", typeof(HttpResponseMessage)); a hand-registered untyped + // pipeline under the same name is a different entry entirely. + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddResilienceHandler("custom", pb => pb.AddTimeout(TimeSpan.FromSeconds(30))), + s => s.AddResiliencePipeline( + $"{TestClientFactory.ClientName}/custom", + pb => pb.AddTimeout(TimeSpan.FromSeconds(5)))); + using var scope = provider; + + using var response = await SendAsync(client); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Both resolve independently. + var registry = provider.GetRequiredService>(); + Assert.NotNull(registry.GetPipeline($"{TestClientFactory.ClientName}/custom")); + Assert.NotNull(registry.GetPipeline($"{TestClientFactory.ClientName}/custom")); + } +} diff --git a/tests/Resilion.Http.Tests/Infrastructure/StubHttpMessageHandler.cs b/tests/Resilion.Http.Tests/Infrastructure/StubHttpMessageHandler.cs new file mode 100644 index 0000000..445ab16 --- /dev/null +++ b/tests/Resilion.Http.Tests/Infrastructure/StubHttpMessageHandler.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http; + +namespace Resilion.Http.Tests; + +/// +/// A primary handler standing in for the network: it records every request it is asked to send and +/// answers from a caller-supplied delegate. +/// +internal sealed class StubHttpMessageHandler : HttpMessageHandler +{ + private readonly Func> _responder; + private readonly List _requests = []; + private int _sends; + + internal StubHttpMessageHandler( + Func> responder) + => _responder = responder; + + /// Answers with the given status codes in order, repeating the last one. + internal static StubHttpMessageHandler WithStatuses(params HttpStatusCode[] statuses) + => new((_, attempt, _) => Task.FromResult( + new HttpResponseMessage(statuses[Math.Min(attempt, statuses.Length - 1)]))); + + internal int Sends => Volatile.Read(ref _sends); + + /// Every request instance the handler was asked to send, in order. + internal IReadOnlyList Requests + { + get { lock (_requests) { return _requests.ToList(); } } + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var attempt = Interlocked.Increment(ref _sends) - 1; + lock (_requests) { _requests.Add(request); } + return _responder(request, attempt, cancellationToken); + } +} diff --git a/tests/Resilion.Http.Tests/Infrastructure/TestClientFactory.cs b/tests/Resilion.Http.Tests/Infrastructure/TestClientFactory.cs new file mode 100644 index 0000000..2151549 --- /dev/null +++ b/tests/Resilion.Http.Tests/Infrastructure/TestClientFactory.cs @@ -0,0 +1,34 @@ +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Resilion.Http.Tests; + +/// +/// Builds a real graph over a stub primary handler, so tests +/// exercise the same registration and resolution path an application would. +/// +internal static class TestClientFactory +{ + internal const string ClientName = "api"; + + internal static (ServiceProvider Provider, HttpClient Client) Build( + StubHttpMessageHandler stub, + Action configureResilience, + Action? configureServices = null, + string? clientName = null) + { + var services = new ServiceCollection(); + configureServices?.Invoke(services); + + var builder = services.AddHttpClient(clientName ?? ClientName) + .ConfigurePrimaryHttpMessageHandler(() => stub); + + configureResilience(builder); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService() + .CreateClient(clientName ?? ClientName); + + return (provider, client); + } +} diff --git a/tests/Resilion.Http.Tests/Infrastructure/TrackingHttpContent.cs b/tests/Resilion.Http.Tests/Infrastructure/TrackingHttpContent.cs new file mode 100644 index 0000000..a2ebc7d --- /dev/null +++ b/tests/Resilion.Http.Tests/Infrastructure/TrackingHttpContent.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Http; + +namespace Resilion.Http.Tests; + +/// +/// Content that counts how many times it was serialized, so request-replay behaviour can be +/// asserted rather than inferred. +/// +internal sealed class TrackingHttpContent : HttpContent +{ + private readonly byte[] _body; + private int _serializations; + + internal TrackingHttpContent(string body) => _body = System.Text.Encoding.UTF8.GetBytes(body); + + internal int Serializations => Volatile.Read(ref _serializations); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + Interlocked.Increment(ref _serializations); + return stream.WriteAsync(_body, 0, _body.Length); + } + + protected override bool TryComputeLength(out long length) + { + length = _body.Length; + return true; + } +} + +/// +/// Response content that records whether it was disposed, so leaked responses are detectable. +/// +internal sealed class DisposeTrackingContent : HttpContent +{ + private int _disposed; + + internal bool IsDisposed => Volatile.Read(ref _disposed) != 0; + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return true; + } + + protected override void Dispose(bool disposing) + { + Interlocked.Exchange(ref _disposed, 1); + base.Dispose(disposing); + } +} diff --git a/tests/Resilion.Http.Tests/RequestReplayTests.cs b/tests/Resilion.Http.Tests/RequestReplayTests.cs new file mode 100644 index 0000000..d66b3a0 --- /dev/null +++ b/tests/Resilion.Http.Tests/RequestReplayTests.cs @@ -0,0 +1,268 @@ +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Resilion.Http.Tests; + +/// +/// — whether each attempt sends the caller's request or a copy. +/// +/// +/// This is the difference between a retry that works and one that throws on the second attempt: +/// non-replayable content cannot be sent twice, and an inner handler that adds rather than sets a +/// header will reject a reused message. +/// +public class RequestReplayTests +{ + private static readonly Uri Endpoint = new("https://example.test/data"); + + private static HttpRequestMessage PostWith(HttpContent content) + => new(HttpMethod.Post, Endpoint) { Content = content }; + + // ─── ReuseRequest: the same instance, no buffering ────────────────────────────────────── + + [Fact] + public async Task Async_ReuseRequest_SendsTheSameInstanceOnEveryAttempt() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.RequestReplay = HttpRequestReplay.ReuseRequest; + o.Retry = o.Retry with { Delay = RetryDelay.None }; + })); + using var scope = provider; + + using var original = PostWith(new StringContent("body")); + using var response = await client.SendAsync(original, HttpCompletionOption.ResponseHeadersRead); + + Assert.Equal(3, stub.Requests.Count); + Assert.All(stub.Requests, r => Assert.Same(original, r)); + } + + [Fact] + public async Task Async_ReuseRequest_DoesNotBufferTheCallersContent() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + var content = new TrackingHttpContent("body"); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.RequestReplay = HttpRequestReplay.ReuseRequest; + o.Retry = o.Retry with { Delay = RetryDelay.None }; + })); + using var scope = provider; + + using var request = PostWith(content); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + // Nothing read the body, so the handler did not buffer it. That is the point of this mode: + // zero overhead, at the cost of requiring replayable content. + Assert.Equal(0, content.Serializations); + } + + // ─── CloneRequest: independent copies, buffered exactly once ──────────────────────────── + + [Fact] + public async Task Async_CloneRequest_SendsADistinctInstanceOnEveryAttempt() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.RequestReplay = HttpRequestReplay.CloneRequest; + o.Retry = o.Retry with { Delay = RetryDelay.None }; + })); + using var scope = provider; + + using var original = PostWith(new StringContent("body")); + using var response = await client.SendAsync(original, HttpCompletionOption.ResponseHeadersRead); + + var sent = stub.Requests; + Assert.Equal(3, sent.Count); + Assert.All(sent, r => Assert.NotSame(original, r)); + Assert.Equal(3, sent.Distinct().Count()); + } + + [Fact] + public async Task Async_CloneRequest_BuffersTheCallersContentExactlyOnce() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError, HttpStatusCode.OK); + var content = new TrackingHttpContent("body"); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.RequestReplay = HttpRequestReplay.CloneRequest; + o.Retry = o.Retry with { Delay = RetryDelay.None }; + })); + using var scope = provider; + + using var request = PostWith(content); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + // Once up front, not once per attempt: the clones share the captured byte array. + Assert.Equal(3, stub.Sends); + Assert.Equal(1, content.Serializations); + } + + [Fact] + public async Task Async_CloneRequest_EveryAttemptSeesTheSameBody() + { + var bodies = new List(); + var stub = new StubHttpMessageHandler(async (request, attempt, _) => + { + bodies.Add(request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync()); + + return new HttpResponseMessage( + attempt < 2 ? HttpStatusCode.InternalServerError : HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.RequestReplay = HttpRequestReplay.CloneRequest; + o.Retry = o.Retry with { Delay = RetryDelay.None }; + })); + using var scope = provider; + + using var request = PostWith(new StringContent("the-body")); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + Assert.Equal(3, bodies.Count); + Assert.All(bodies, b => Assert.Equal("the-body", b)); + } + + [Fact] + public async Task Async_CloneRequest_CopiesMethodUriVersionHeadersAndOptions() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + var optionKey = new HttpRequestOptionsKey("test-option"); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.RequestReplay = HttpRequestReplay.CloneRequest)); + using var scope = provider; + + using var original = PostWith(new StringContent("body")); + original.Version = new Version(2, 0); + original.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + original.Headers.TryAddWithoutValidation("X-Correlation-Id", "abc-123"); + original.Options.Set(optionKey, "option-value"); + original.Content!.Headers.TryAddWithoutValidation("X-Content-Marker", "marker"); + + using var response = await client.SendAsync(original, HttpCompletionOption.ResponseHeadersRead); + + var sent = Assert.Single(stub.Requests); + Assert.NotSame(original, sent); + Assert.Equal(HttpMethod.Post, sent.Method); + Assert.Equal(Endpoint, sent.RequestUri); + Assert.Equal(new Version(2, 0), sent.Version); + Assert.Equal(HttpVersionPolicy.RequestVersionOrLower, sent.VersionPolicy); + Assert.Equal("abc-123", sent.Headers.GetValues("X-Correlation-Id").Single()); + Assert.True(sent.Options.TryGetValue(optionKey, out var carried)); + Assert.Equal("option-value", carried); + Assert.Equal("marker", sent.Content!.Headers.GetValues("X-Content-Marker").Single()); + } + + [Fact] + public async Task Async_CloneRequest_LeavesTheCallersRequestUsable() + { + // The handler disposes its clones, never the caller's request — the caller owns that. + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.RequestReplay = HttpRequestReplay.CloneRequest)); + using var scope = provider; + + using var original = PostWith(new StringContent("body")); + using var response = await client.SendAsync(original, HttpCompletionOption.ResponseHeadersRead); + + // Still readable, so neither it nor its content was disposed underneath us. + Assert.Equal("body", await original.Content!.ReadAsStringAsync()); + } + + [Fact] + public async Task Async_CloneRequest_WithNoContent_Works() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.RequestReplay = HttpRequestReplay.CloneRequest; + o.Retry = o.Retry with { Delay = RetryDelay.None }; + })); + using var scope = provider; + + using var response = await client.GetAsync(Endpoint, HttpCompletionOption.ResponseHeadersRead); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + Assert.All(stub.Requests, r => Assert.Null(r.Content)); + } + + // ─── Hedging always clones, whatever the caller asked for ─────────────────────────────── + + [Fact] + public async Task Async_Hedging_AlwaysGivesEachAttemptItsOwnRequest() + { + // Not configurable: concurrent attempts cannot share one HttpRequestMessage. + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 2, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + })); + using var scope = provider; + + using var original = PostWith(new StringContent("body")); + using var response = await client.SendAsync(original, HttpCompletionOption.ResponseHeadersRead); + + var sent = stub.Requests; + Assert.Equal(2, sent.Count); + Assert.All(sent, r => Assert.NotSame(original, r)); + Assert.Equal(2, sent.Distinct().Count()); + } + + // ─── The custom handler takes the mode explicitly ─────────────────────────────────────── + + [Fact] + public async Task Async_CustomHandler_HonoursTheRequestedReplayMode() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddResilienceHandler( + "custom", + pb => pb.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = HttpResiliencePredicates.IsTransient, + }), + HttpRequestReplay.CloneRequest)); + using var scope = provider; + + using var original = PostWith(new StringContent("body")); + using var response = await client.SendAsync(original, HttpCompletionOption.ResponseHeadersRead); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + Assert.All(stub.Requests, r => Assert.NotSame(original, r)); + } +} diff --git a/tests/Resilion.Http.Tests/Resilion.Http.Tests.csproj b/tests/Resilion.Http.Tests/Resilion.Http.Tests.csproj new file mode 100644 index 0000000..1e5c00c --- /dev/null +++ b/tests/Resilion.Http.Tests/Resilion.Http.Tests.csproj @@ -0,0 +1,20 @@ + + + + Resilion.Http.Tests + Resilion.Http.Tests + + + + + + + + + + + + + + + diff --git a/tests/Resilion.Http.Tests/ResponseDisposalTests.cs b/tests/Resilion.Http.Tests/ResponseDisposalTests.cs new file mode 100644 index 0000000..059c6ff --- /dev/null +++ b/tests/Resilion.Http.Tests/ResponseDisposalTests.cs @@ -0,0 +1,360 @@ +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Resilion.Http.Tests; + +/// +/// Discarded responses must be disposed, or every retry leaks a connection. +/// +/// +/// The pipeline abandons non-final outcomes without disposing them — a retry overwrites its +/// outcome each iteration, hedging returns the first success and drops the rest — so the handler +/// owns this. It cannot be done with OnRetry, because a user-supplied handler can only be +/// replaced, not wrapped. +/// +/// All tests read with so +/// does not buffer and dispose the content itself, which would make the +/// assertions meaningless. +/// +/// +public class ResponseDisposalTests +{ + private static readonly Uri Endpoint = new("https://example.test/data"); + + private static (StubHttpMessageHandler Stub, List Contents) TrackingStub( + params HttpStatusCode[] statuses) + { + var contents = new List(); + var stub = new StubHttpMessageHandler((_, attempt, _) => + { + var content = new DisposeTrackingContent(); + lock (contents) { contents.Add(content); } + return Task.FromResult(new HttpResponseMessage( + statuses[Math.Min(attempt, statuses.Length - 1)]) { Content = content }); + }); + + return (stub, contents); + } + + private static Task SendAsync(HttpClient client) + => client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, Endpoint), + HttpCompletionOption.ResponseHeadersRead); + + // ─── Retry ────────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_Retry_DiscardedResponses_AreDisposed_AndTheWinnerIsNot() + { + var (stub, contents) = TrackingStub( + HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, contents.Count); + Assert.True(contents[0].IsDisposed, "first 500 was leaked"); + Assert.True(contents[1].IsDisposed, "second 500 was leaked"); + Assert.False(contents[2].IsDisposed, "the returned response must not be disposed"); + } + + [Fact] + public async Task Async_Retry_PreviousResponse_IsDisposedBeforeTheNextAttemptStarts() + { + // Eager disposal, not deferred-to-the-end: with three retries and exponential backoff, + // "at the end" can be tens of seconds of a connection held for nothing. + var contents = new List(); + var observations = new List(); + + var stub = new StubHttpMessageHandler((_, attempt, _) => + { + if (attempt > 0) + { + lock (contents) { observations.Add(contents[attempt - 1].IsDisposed); } + } + + var content = new DisposeTrackingContent(); + lock (contents) { contents.Add(content); } + return Task.FromResult(new HttpResponseMessage( + attempt < 2 ? HttpStatusCode.InternalServerError : HttpStatusCode.OK) + { Content = content }); + }); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(2, observations.Count); + Assert.All(observations, disposed => + Assert.True(disposed, "the previous attempt's response was still open")); + } + + [Fact] + public async Task Async_RetriesExhausted_AllButTheReturnedResponse_AreDisposed() + { + var (stub, contents) = TrackingStub(HttpStatusCode.InternalServerError); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var scope = provider; + + using var response = await SendAsync(client); + + // The final 500 is handed back to the caller, so only the earlier ones are disposed. + Assert.Equal(4, contents.Count); + for (var i = 0; i < 3; i++) + { + Assert.True(contents[i].IsDisposed, $"attempt {i} was leaked"); + } + + Assert.False(contents[3].IsDisposed); + } + + [Fact] + public async Task Async_TotalTimeoutThrows_ResponsesProducedBeforeIt_AreDisposed() + { + // When the pipeline throws there is no winner, so nothing may survive. Driven by the total + // timeout firing during a retry delay, after earlier attempts have already produced + // responses the handler is holding. + var fakeTime = new Microsoft.Extensions.Time.Testing.FakeTimeProvider(); + var (stub, contents) = TrackingStub(HttpStatusCode.InternalServerError); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardResilienceHandler(o => + { + o.TotalRequestTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(5) }; + o.AttemptTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(1) }; + o.Retry = o.Retry with + { + MaxRetryAttempts = 5, + Delay = RetryDelay.Constant(TimeSpan.FromSeconds(2)), + UseJitter = false, + }; + }), + s => s.AddSingleton(fakeTime)); + using var scope = provider; + + var request = SendAsync(client); + + // Advance past the total timeout while the retry loop is sleeping between attempts. + for (var i = 0; i < 4 && !request.IsCompleted; i++) + { + await Task.Delay(50); + fakeTime.Advance(TimeSpan.FromSeconds(2)); + } + + await Assert.ThrowsAsync(() => request); + + List seen; + lock (contents) { seen = contents.ToList(); } + + Assert.NotEmpty(seen); + Assert.All(seen, c => Assert.True(c.IsDisposed, "a response was leaked when the pipeline threw")); + } + + // ─── Hedging: attempts overlap, so disposal cannot be eager ───────────────────────────── + + [Fact] + public async Task Async_Hedging_LoserThatAnswered_IsDisposed() + { + // The loser must genuinely ANSWER, not be cancelled: a cancelled attempt produces no + // response and so nothing to dispose, which makes such a test vacuous. Sequential mode + // gives a deterministic ordering — attempt 0 answers 500, hedging treats that as a failure + // and launches attempt 1, which answers 200 and wins. Attempt 0's response was tracked + // before the handler returned, so the concurrent-cleanup path must dispose it. + var (stub, contents) = TrackingStub(HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 2, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + })); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + + List seen; + lock (contents) { seen = contents.ToList(); } + + Assert.Equal(2, seen.Count); + Assert.True(seen[0].IsDisposed, "the losing hedged response was leaked"); + Assert.False(seen[1].IsDisposed, "the returned response must not be disposed"); + } + + [Fact] + public async Task Async_Hedging_Parallel_LoserThatAnswered_IsDisposed() + { + // The genuinely parallel case, made deterministic without any Task.Delay. + // + // Both attempts launch at once (HedgingDelay = Zero) and each blocks on its own gate. The + // test releases attempt 0, which answers a handled failure; hedging therefore keeps + // waiting, and only then is attempt 1 released to answer the winning 200. + // + // The ordering is guaranteed rather than hoped for: the handler calls Track before its + // attempt task completes, so hedging cannot have observed attempt 0's failure unless + // attempt 0's response was already tracked. Both are therefore in the concurrent + // collection before the handler returns and closes the tracker. + var contents = new List(); + var gate0 = new TaskCompletionSource(); + var gate1 = new TaskCompletionSource(); + var answered0 = new TaskCompletionSource(); + + var stub = new StubHttpMessageHandler(async (_, attempt, _) => + { + if (attempt == 0) + { + await gate0.Task; + var failure = new DisposeTrackingContent(); + lock (contents) { contents.Add(failure); } + answered0.SetResult(); + return new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = failure }; + } + + await gate1.Task; + var success = new DisposeTrackingContent(); + lock (contents) { contents.Add(success); } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = success }; + }); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.Zero, + })); + using var scope = provider; + + var request = SendAsync(client); + + gate0.SetResult(); + await answered0.Task.WaitAsync(TimeSpan.FromSeconds(10)); + gate1.SetResult(); + + using var response = await request; + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List seen; + lock (contents) { seen = contents.ToList(); } + + Assert.Equal(2, seen.Count); + Assert.Equal(1, seen.Count(c => !c.IsDisposed)); + Assert.True(seen[0].IsDisposed, "the losing parallel hedged response was leaked"); + } + + [Fact] + public async Task Async_Hedging_MultipleLosersThatAnswered_AreAllDisposed() + { + // Exercises the cleanup loop over more than one entry: two losers, not one, so an + // implementation that disposed only the most recent would fail here. + var contents = new List(); + var gates = new[] { new TaskCompletionSource(), new TaskCompletionSource(), new TaskCompletionSource() }; + var answered = new[] { new TaskCompletionSource(), new TaskCompletionSource() }; + + var stub = new StubHttpMessageHandler(async (_, attempt, _) => + { + await gates[attempt].Task; + + var content = new DisposeTrackingContent(); + lock (contents) { contents.Add(content); } + + if (attempt < 2) + { + answered[attempt].SetResult(); + return new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = content }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + }); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.Zero, + })); + using var scope = provider; + + var request = SendAsync(client); + + // Both failures answer first, in order, then the winner. + for (var i = 0; i < 2; i++) + { + gates[i].SetResult(); + await answered[i].Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + + gates[2].SetResult(); + + using var response = await request; + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + List seen; + lock (contents) { seen = contents.ToList(); } + + Assert.Equal(3, seen.Count); + Assert.True(seen[0].IsDisposed, "first losing response was leaked"); + Assert.True(seen[1].IsDisposed, "second losing response was leaked"); + Assert.Equal(1, seen.Count(c => !c.IsDisposed)); + } + + [Fact] + public async Task Async_Hedging_StragglerCompletingAfterTheHandlerReturned_IsDisposed() + { + // The tracker's "closed" state: hedging cancels losers and waits only a bounded time + // before returning, so a straggler can produce a response after we have already answered. + var contents = new List(); + var release = new TaskCompletionSource(); + var straggled = new TaskCompletionSource(); + + var stub = new StubHttpMessageHandler(async (_, attempt, _) => + { + if (attempt == 0) + { + // Deliberately ignores cancellation, which is what makes it a straggler. + await release.Task; + var late = new DisposeTrackingContent(); + lock (contents) { contents.Add(late); } + straggled.SetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = late }; + } + + var content = new DisposeTrackingContent(); + lock (contents) { contents.Add(content); } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + }); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.Zero, + })); + using var scope = provider; + + using var response = await SendAsync(client); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Now let the abandoned attempt produce its response, after the handler has returned. + release.SetResult(); + await straggled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.Delay(200); + + var late = contents[^1]; + Assert.True(late.IsDisposed, "a response produced after completion was leaked"); + } +} diff --git a/tests/Resilion.Http.Tests/StandardHedgingHandlerTests.cs b/tests/Resilion.Http.Tests/StandardHedgingHandlerTests.cs new file mode 100644 index 0000000..715c81c --- /dev/null +++ b/tests/Resilion.Http.Tests/StandardHedgingHandlerTests.cs @@ -0,0 +1,282 @@ +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Http.Tests; + +/// +/// AddStandardHedgingHandler — the standard shape with hedging in place of retry. +/// +/// +/// Loser disposal is covered by and per-attempt request +/// cloning by ; this suite covers the delay modes, composition, +/// and registration. +/// +public class StandardHedgingHandlerTests +{ + private static readonly Uri Endpoint = new("https://example.test/data"); + + private static Task SendAsync(HttpClient client) + => client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, Endpoint), + HttpCompletionOption.ResponseHeadersRead); + + // ─── Latency mode: hedge only if the primary is slow ──────────────────────────────────── + + [Fact] + public async Task Async_PrimaryAnswersBeforeTheDelay_NoHedgeIsLaunched() + { + // The point of latency mode: a fast primary costs nothing extra. + var fakeTime = new FakeTimeProvider(); + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.FromSeconds(2), + }), + s => s.AddSingleton(fakeTime)); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, stub.Sends); + } + + [Fact] + public async Task Async_PrimarySlow_HedgedAttemptWinsAfterTheDelay() + { + var fakeTime = new FakeTimeProvider(); + var primaryEntered = new TaskCompletionSource(); + + var stub = new StubHttpMessageHandler(async (_, attempt, ct) => + { + if (attempt == 0) + { + primaryEntered.SetResult(); + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + } + + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromSeconds(2), + }), + s => s.AddSingleton(fakeTime)); + using var scope = provider; + + var request = SendAsync(client); + await primaryEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + // Nothing has hedged yet; advancing past the delay is what launches the second attempt. + Assert.Equal(1, stub.Sends); + fakeTime.Advance(TimeSpan.FromSeconds(3)); + + using var response = await request; + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + } + + // ─── Parallel mode ───────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_ParallelMode_LaunchesEveryAttemptImmediately() + { + var launched = new SemaphoreSlim(0); + var release = new TaskCompletionSource(); + + var stub = new StubHttpMessageHandler(async (_, _, _) => + { + launched.Release(); + await release.Task; + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.Zero, + })); + using var scope = provider; + + var request = SendAsync(client); + + // All three must be in flight before any of them answers. + for (var i = 0; i < 3; i++) + { + Assert.True(await launched.WaitAsync(TimeSpan.FromSeconds(10)), $"attempt {i} never launched"); + } + + release.SetResult(); + using var response = await request; + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, stub.Sends); + } + + // ─── Sequential mode ─────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_SequentialMode_LaunchesTheNextAttemptOnlyAfterTheLastFailed() + { + var inFlight = 0; + var maxConcurrent = 0; + + var stub = new StubHttpMessageHandler(async (_, attempt, _) => + { + var now = Interlocked.Increment(ref inFlight); + InterlockedMax(ref maxConcurrent, now); + await Task.Yield(); + Interlocked.Decrement(ref inFlight); + + return new HttpResponseMessage( + attempt < 2 ? HttpStatusCode.InternalServerError : HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 3, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + })); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, stub.Sends); + Assert.Equal(1, maxConcurrent); + + static void InterlockedMax(ref int target, int value) + { + int current; + while (value > (current = Volatile.Read(ref target))) + { + if (Interlocked.CompareExchange(ref target, value, current) == current) + { + return; + } + } + } + } + + // ─── Predicate defaults and composition ──────────────────────────────────────────────── + + [Fact] + public async Task Async_ReplacingHedgingOptionsWholesale_KeepsTransientDefault() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => o.Hedging = new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + })); + using var scope = provider; + + using var response = await SendAsync(client); + + // Without the null-ShouldHandle substitution the 500 would be treated as success. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + } + + [Fact] + public async Task Async_CircuitBreakerStillAppliesUnderHedging() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.InternalServerError); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => + { + o.Hedging = o.Hedging with + { + MaxHedgedAttempts = 1, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + }; + o.CircuitBreaker = o.CircuitBreaker with + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(30), + }; + })); + using var scope = provider; + + for (var i = 0; i < 2; i++) + { + (await SendAsync(client)).Dispose(); + } + + var before = stub.Sends; + await Assert.ThrowsAsync(() => SendAsync(client)); + Assert.Equal(before, stub.Sends); + } + + // ─── Registration and unsupported paths ──────────────────────────────────────────────── + + [Fact] + public void AttemptTimeoutLongerThanTotal_ThrowsWhenTheClientIsCreated() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var ex = Assert.Throws(() => TestClientFactory.Build(stub, b => + b.AddStandardHedgingHandler(o => + { + o.TotalRequestTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(1) }; + o.AttemptTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(30) }; + }))); + + Assert.Contains("can never fire", ex.Message); + } + + [Fact] + public void Registration_TwiceOnTheSameClient_ThrowsAtRegistrationTime() + { + var services = new ServiceCollection(); + var builder = services.AddHttpClient(TestClientFactory.ClientName); + + builder.AddStandardHedgingHandler(); + + Assert.Throws(() => builder.AddStandardHedgingHandler()); + } + + [Fact] + public void StandardAndHedgingOnTheSameClient_BothRegister() + { + // Different kinds, so different pipeline keys — this must NOT be rejected. + var services = new ServiceCollection(); + var builder = services.AddHttpClient(TestClientFactory.ClientName); + + builder.AddStandardResilienceHandler(); + builder.AddStandardHedgingHandler(); + + Assert.NotNull(services.BuildServiceProvider().GetRequiredService()); + } + + [Fact] + public void Sync_Send_ThrowsNotSupported() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + var (provider, client) = TestClientFactory.Build(stub, b => b.AddStandardHedgingHandler()); + using var scope = provider; + + using var request = new HttpRequestMessage(HttpMethod.Get, Endpoint); + Assert.Throws(() => client.Send(request)); + Assert.Equal(0, stub.Sends); + } +} diff --git a/tests/Resilion.Http.Tests/StandardResilienceHandlerTests.cs b/tests/Resilion.Http.Tests/StandardResilienceHandlerTests.cs new file mode 100644 index 0000000..1e0e2f5 --- /dev/null +++ b/tests/Resilion.Http.Tests/StandardResilienceHandlerTests.cs @@ -0,0 +1,388 @@ +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Http.Tests; + +/// +/// AddStandardResilienceHandler — future-plans #39. +/// +public class StandardResilienceHandlerTests +{ + private static readonly Uri Endpoint = new("https://example.test/data"); + + // ─── Retry on transient outcomes ──────────────────────────────────────────────────────── + // + // The first test is also the proof that the TYPED strategies bound: the pipeline is a + // Pipeline and the retry predicate is result-based, so if the typed + // strategy were skipped the first 500 would come straight back after one send. + + [Fact] + public async Task Async_TransientStatusCode_RetriesUntilSuccess() + { + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var _ = provider; + + var response = await client.GetAsync(Endpoint); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, stub.Sends); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.GatewayTimeout)] + [InlineData(HttpStatusCode.RequestTimeout)] + [InlineData(HttpStatusCode.TooManyRequests)] + public async Task Async_TransientStatusCodes_AreRetried(HttpStatusCode status) + { + var stub = StubHttpMessageHandler.WithStatuses(status, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var _ = provider; + + var response = await client.GetAsync(Endpoint); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.NotFound)] + public async Task Async_NonTransientStatusCode_DoesNotRetry(HttpStatusCode status) + { + var stub = StubHttpMessageHandler.WithStatuses(status); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var _ = provider; + + var response = await client.GetAsync(Endpoint); + + Assert.Equal(status, response.StatusCode); + Assert.Equal(1, stub.Sends); + } + + [Fact] + public async Task Async_HttpRequestException_IsRetried() + { + var stub = new StubHttpMessageHandler((_, attempt, _) => attempt < 2 + ? throw new HttpRequestException("connection reset") + : Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var _ = provider; + + var response = await client.GetAsync(Endpoint); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, stub.Sends); + } + + [Fact] + public async Task Async_RetriesExhausted_ReturnsLastResponse() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.InternalServerError); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var _ = provider; + + var response = await client.GetAsync(Endpoint); + + // The last response is returned rather than thrown: a 500 is a response, not a fault. + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal(4, stub.Sends); // 1 initial + 3 retries + } + + // ─── Cancellation is never transient ──────────────────────────────────────────────────── + + [Fact] + public async Task Async_CallerCancellation_IsNotRetriedAndIsNotReportedAsTimeout() + { + var stub = new StubHttpMessageHandler((_, _, ct) => Task.FromCanceled(ct)); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None })); + using var _ = provider; + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var ex = await Assert.ThrowsAnyAsync( + () => client.GetAsync(Endpoint, cts.Token)); + + Assert.IsNotType(ex); + Assert.True(stub.Sends <= 1); + } + + // ─── The null-ShouldHandle substitution ───────────────────────────────────────────────── + + [Fact] + public async Task Async_ReplacingRetryOptionsWholesale_KeepsTransientDefault() + { + // The trap this guards: a user replaces the record to change one number, and without the + // substitution silently loses 5xx handling entirely. + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + })); + using var _ = provider; + + var response = await client.GetAsync(Endpoint); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + } + + [Fact] + public async Task Async_ExplicitShouldHandle_OverridesTransientDefault() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.InternalServerError); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => o.Retry = o.Retry with + { + Delay = RetryDelay.None, + ShouldHandle = _ => false, + })); + using var _ = provider; + + await client.GetAsync(Endpoint); + + Assert.Equal(1, stub.Sends); + } + + // ─── Timeouts ─────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_AttemptTimeout_ThrowsTimeoutRejectedAfterRetrying() + { + var fakeTime = new FakeTimeProvider(); + var entered = new SemaphoreSlim(0); + + var stub = new StubHttpMessageHandler(async (_, _, ct) => + { + entered.Release(); + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardResilienceHandler(o => + { + o.AttemptTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(1) }; + o.Retry = o.Retry with { MaxRetryAttempts = 1, Delay = RetryDelay.None }; + }), + s => s.AddSingleton(fakeTime)); + using var _ = provider; + + var request = client.GetAsync(Endpoint); + + for (var attempt = 0; attempt < 2; attempt++) + { + await entered.WaitAsync(TimeSpan.FromSeconds(5)); + fakeTime.Advance(TimeSpan.FromSeconds(2)); + } + + var ex = await Assert.ThrowsAsync(() => request); + + Assert.Equal(TimeSpan.FromSeconds(1), ex.ConfiguredTimeout); + Assert.Equal(2, stub.Sends); + } + + [Fact] + public void AttemptTimeoutLongerThanTotal_ThrowsWhenTheClientIsCreated() + { + // Options are validated inside the registry's lazy build, and that build is triggered when + // IHttpClientFactory constructs the handler chain — i.e. at CreateClient, not at the first + // request. Worth pinning: it means a misconfiguration fails at client creation, which is + // early enough to surface during startup smoke tests rather than in traffic. + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var ex = Assert.Throws(() => TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.TotalRequestTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(1) }; + o.AttemptTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(30) }; + }))); + + Assert.Contains("can never fire", ex.Message); + } + + // ─── Circuit breaker ──────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_CircuitBreaker_OpensThenRejectsWithoutSending() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.InternalServerError); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + o.Retry = o.Retry with { MaxRetryAttempts = 0 }; + o.CircuitBreaker = o.CircuitBreaker with + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(30), + }; + })); + using var _ = provider; + + for (var i = 0; i < 2; i++) + { + await client.GetAsync(Endpoint); + } + + var sendsBeforeOpen = stub.Sends; + + await Assert.ThrowsAsync(() => client.GetAsync(Endpoint)); + + // Rejected without touching the network, which is the whole point. + Assert.Equal(sendsBeforeOpen, stub.Sends); + } + + // ─── Configuration and lifetime ───────────────────────────────────────────────────────── + + [Fact] + public async Task Async_ConfigureDelegate_RunsOnceAcrossManyRequests() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + var invocations = 0; + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddStandardResilienceHandler(o => + { + Interlocked.Increment(ref invocations); + o.Retry = o.Retry with { Delay = RetryDelay.None }; + })); + using var _ = provider; + + for (var i = 0; i < 5; i++) + { + await client.GetAsync(Endpoint); + } + + // The pipeline is cached, so options are materialised once — but note the handler resolves + // RequestReplay separately, so this asserts "small constant", not literally one. + Assert.True(invocations <= 2, $"configure ran {invocations} times"); + } + + [Fact] + public async Task Async_TimeProviderFromContainer_IsUsedByStrategies() + { + var fakeTime = new FakeTimeProvider(); + var entered = new SemaphoreSlim(0); + + var stub = new StubHttpMessageHandler(async (_, _, ct) => + { + entered.Release(); + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardResilienceHandler(o => + { + o.AttemptTimeout = new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(1) }; + o.Retry = o.Retry with { MaxRetryAttempts = 0 }; + }), + s => s.AddSingleton(fakeTime)); + using var _ = provider; + + var request = client.GetAsync(Endpoint); + await entered.WaitAsync(TimeSpan.FromSeconds(5)); + + // Nothing happens until the fake clock moves, which proves the strategies use it. + fakeTime.Advance(TimeSpan.FromSeconds(2)); + + await Assert.ThrowsAsync(() => request); + } + + [Fact] + public void Registration_TwiceOnTheSameClient_ThrowsAtRegistrationTime() + { + var services = new ServiceCollection(); + var builder = services.AddHttpClient(TestClientFactory.ClientName); + + builder.AddStandardResilienceHandler(); + + var ex = Assert.Throws(() => builder.AddStandardResilienceHandler()); + + Assert.Contains(TestClientFactory.ClientName, ex.Message); + } + + [Fact] + public async Task Async_TwoClients_HaveIndependentPipelines() + { + var failing = StubHttpMessageHandler.WithStatuses(HttpStatusCode.InternalServerError); + var healthy = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + + var services = new ServiceCollection(); + services.AddHttpClient("failing") + .ConfigurePrimaryHttpMessageHandler(() => failing) + .AddStandardResilienceHandler(o => + { + o.Retry = o.Retry with { MaxRetryAttempts = 0 }; + o.CircuitBreaker = o.CircuitBreaker with + { + FailureRatioThreshold = 0.5, MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(30), + }; + }); + services.AddHttpClient("healthy") + .ConfigurePrimaryHttpMessageHandler(() => healthy) + .AddStandardResilienceHandler(o => o.Retry = o.Retry with { MaxRetryAttempts = 0 }); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + + var failingClient = factory.CreateClient("failing"); + for (var i = 0; i < 2; i++) + { + await failingClient.GetAsync(Endpoint); + } + + await Assert.ThrowsAsync(() => failingClient.GetAsync(Endpoint)); + + // The other client's breaker is untouched. + var response = await factory.CreateClient("healthy").GetAsync(Endpoint); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public void Sync_Send_ThrowsNotSupported() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + var (provider, client) = TestClientFactory.Build(stub, b => b.AddStandardResilienceHandler()); + using var _ = provider; + + // Must throw rather than silently bypassing the pipeline via DelegatingHandler.Send. + using var request = new HttpRequestMessage(HttpMethod.Get, Endpoint); + var ex = Assert.Throws(() => client.Send(request)); + + Assert.Contains("SendAsync", ex.Message); + Assert.Equal(0, stub.Sends); + } +} diff --git a/tests/Resilion.Http.Tests/TypedStrategyBindingTests.cs b/tests/Resilion.Http.Tests/TypedStrategyBindingTests.cs new file mode 100644 index 0000000..5c36eee --- /dev/null +++ b/tests/Resilion.Http.Tests/TypedStrategyBindingTests.cs @@ -0,0 +1,281 @@ +using System.Diagnostics.Metrics; +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Resilion.Extensions; +using Xunit; + +namespace Resilion.Http.Tests; + +/// +/// The pipeline really is a of +/// , and its identity reaches telemetry. +/// +/// +/// A typed strategy reached by an execution of a different result type now throws rather than +/// skipping itself, so a binding mistake fails loudly. These tests pin the binding anyway: a +/// skipped retry or circuit breaker is a pipeline that silently does nothing, and this is the +/// cheapest place to catch that if the guard is ever relaxed. +/// +public class TypedStrategyBindingTests +{ + private static readonly Uri Endpoint = new("https://example.test/data"); + + private static Task SendAsync(HttpClient client) + => client.SendAsync( + new HttpRequestMessage(HttpMethod.Get, Endpoint), + HttpCompletionOption.ResponseHeadersRead); + + // ─── Registry keying ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Pipeline_IsRegisteredAsPipelineOfHttpResponseMessage() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + var (provider, _) = TestClientFactory.Build(stub, b => b.AddStandardResilienceHandler()); + using var scope = provider; + + var registry = provider.GetRequiredService>(); + + Assert.NotNull(registry.GetPipeline("api/standard")); + + // Typed pipelines are keyed by name AND result type, so another TResult is a miss. + Assert.Throws(() => registry.GetPipeline("api/standard")); + } + + [Fact] + public void Pipeline_Name_IsTheClientScopedKey() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.OK); + var (provider, _) = TestClientFactory.Build(stub, b => b.AddStandardResilienceHandler()); + using var scope = provider; + + var pipeline = provider.GetRequiredService>() + .GetPipeline("api/standard"); + + Assert.Equal("api/standard", pipeline.Name); + } + + // ─── The strategies actually bind ─────────────────────────────────────────────────────── + + [Fact] + public async Task Async_TypedRetry_IsNotSkipped_WhenExecutedThroughTheHandler() + { + // A result-only predicate: it can never match an exception, so the retry fires only if + // the typed strategy genuinely ran against an HttpResponseMessage outcome. + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.ServiceUnavailable, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddResilienceHandler("typed", pb => pb.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = o => o.IsSuccess + && o.TryGetResult(out var r) + && r is not null + && r.StatusCode == HttpStatusCode.ServiceUnavailable, + }))); + using var scope = provider; + + using var response = await SendAsync(client); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, stub.Sends); + } + + [Fact] + public async Task Async_TypedCircuitBreaker_IsNotSkipped_WhenExecutedThroughTheHandler() + { + var stub = StubHttpMessageHandler.WithStatuses(HttpStatusCode.ServiceUnavailable); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddResilienceHandler("typed-cb", pb => pb.AddCircuitBreaker( + new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(30), + ShouldHandle = o => o.IsSuccess + && o.TryGetResult(out var r) + && r is not null + && r.StatusCode == HttpStatusCode.ServiceUnavailable, + }))); + using var scope = provider; + + for (var i = 0; i < 2; i++) + { + (await SendAsync(client)).Dispose(); + } + + // Only reachable if the typed breaker recorded those result-based failures. + await Assert.ThrowsAsync(() => SendAsync(client)); + } + + // ─── Telemetry identity ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_RetryCounter_CarriesPipelineNameAndHttpMethod() + { + // A unique client name gives a unique pipeline.name, which is what the listener filters + // on: MeterListener is process-global and xunit runs classes in parallel, so an + // unfiltered listener would also count other tests' measurements. + const string Client = "telemetry-probe-client"; + const string PipelineName = Client + "/standard"; + + var observed = new List<(string? Pipeline, string? Operation)>(); + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == ResilionTelemetry.MeterName + && instrument.Name == "resilion.retry.attempts") + { + l.EnableMeasurementEvents(instrument); + } + }, + }; + listener.SetMeasurementEventCallback((_, _, tags, _) => + { + string? pipeline = null; + string? operation = null; + foreach (var tag in tags) + { + if (tag.Key == ResilionTelemetry.PipelineNameTag) { pipeline = tag.Value as string; } + else if (tag.Key == ResilionTelemetry.OperationKeyTag) { operation = tag.Value as string; } + } + + if (pipeline == PipelineName) + { + lock (observed) { observed.Add((pipeline, operation)); } + } + }); + listener.Start(); + + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None }), + clientName: Client); + using var scope = provider; + + (await SendAsync(client)).Dispose(); + + List<(string? Pipeline, string? Operation)> seen; + lock (observed) { seen = observed.ToList(); } + + Assert.NotEmpty(seen); + Assert.All(seen, m => + { + Assert.Equal(PipelineName, m.Pipeline); + + // The HTTP method, not the URI: bounded cardinality for the metric tag. + Assert.Equal("GET", m.Operation); + }); + } + + [Fact] + public async Task Async_OperationKey_ReflectsTheRequestMethod() + { + const string Client = "telemetry-method-client"; + const string PipelineName = Client + "/standard"; + + var methods = new List(); + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == ResilionTelemetry.MeterName + && instrument.Name == "resilion.retry.attempts") + { + l.EnableMeasurementEvents(instrument); + } + }, + }; + listener.SetMeasurementEventCallback((_, _, tags, _) => + { + string? pipeline = null; + string? operation = null; + foreach (var tag in tags) + { + if (tag.Key == ResilionTelemetry.PipelineNameTag) { pipeline = tag.Value as string; } + else if (tag.Key == ResilionTelemetry.OperationKeyTag) { operation = tag.Value as string; } + } + + if (pipeline == PipelineName) + { + lock (methods) { methods.Add(operation); } + } + }); + listener.Start(); + + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build( + stub, + b => b.AddStandardResilienceHandler(o => o.Retry = o.Retry with { Delay = RetryDelay.None }), + clientName: Client); + using var scope = provider; + + using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint) + { + Content = new StringContent("body"), + }; + (await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)).Dispose(); + + List seen; + lock (methods) { seen = methods.ToList(); } + + Assert.NotEmpty(seen); + Assert.All(seen, m => Assert.Equal("POST", m)); + } + + // ─── The request is reachable from a strategy callback ────────────────────────────────── + + [Fact] + public async Task Async_GetRequestMessage_ReturnsTheAttemptsRequest() + { + var seen = new List(); + var stub = StubHttpMessageHandler.WithStatuses( + HttpStatusCode.InternalServerError, HttpStatusCode.OK); + + var (provider, client) = TestClientFactory.Build(stub, b => + b.AddResilienceHandler("probe", pb => pb.AddRetry( + new RetryStrategyOptions + { + 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 => + { + lock (seen) { seen.Add(args.Context.GetRequestMessage()?.RequestUri); } + }), + }))); + using var scope = provider; + + (await SendAsync(client)).Dispose(); + + Assert.NotEmpty(seen); + Assert.All(seen, uri => Assert.Equal(Endpoint, uri)); + } + + [Fact] + public void GetRequestMessage_OutsideAnHttpPipeline_ReturnsNull() + { + var context = ResilienceContextPool.Shared.Rent(); + try + { + Assert.Null(context.GetRequestMessage()); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } +} diff --git a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStateProviderTests.cs b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStateProviderTests.cs new file mode 100644 index 0000000..757e576 --- /dev/null +++ b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStateProviderTests.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Tests; + +/// +/// — the only public way to observe a circuit's state. +/// +public class CircuitBreakerStateProviderTests +{ + [Fact] + public async Task Async_StateProvider_ReflectsClosedThenOpenThenHalfOpen() + { + var fakeTime = new FakeTimeProvider(); + var state = new CircuitBreakerStateProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + SamplingDuration = TimeSpan.FromSeconds(30), + BreakDuration = TimeSpan.FromSeconds(10), + StateProvider = state, + }); + }); + + Assert.Equal(CircuitState.Closed, state.State); + + for (var i = 0; i < 2; i++) + { + await Assert.ThrowsAsync(async () => + await pipeline.ExecuteAsync( + ct => throw new InvalidOperationException("boom"))); + } + + Assert.Equal(CircuitState.Open, state.State); + + // Past the break duration the circuit admits a trial call, which is the HalfOpen probe. + fakeTime.Advance(TimeSpan.FromSeconds(11)); + await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + + Assert.Equal(CircuitState.Closed, state.State); + } + + [Fact] + public void StateProvider_Unbound_ThrowsOnStateRead() + { + var state = new CircuitBreakerStateProvider(); + + var ex = Assert.Throws(() => state.State); + + Assert.Contains("not associated with", ex.Message); + } + + [Fact] + public void StateProvider_BoundTwice_Throws() + { + var state = new CircuitBreakerStateProvider(); + var options = new CircuitBreakerStrategyOptions { StateProvider = state }; + + _ = Pipeline.Create(b => b.AddCircuitBreaker(options)); + + var ex = Assert.Throws(() => + Pipeline.Create(b => b.AddCircuitBreaker(options))); + + Assert.Contains("already bound", ex.Message); + } + + [Fact] + public async Task Async_StateProvider_WorksOnTypedCircuitBreaker() + { + var state = new CircuitBreakerStateProvider(); + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker( + new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(10), + ShouldHandle = o => o.TryGetResult(out var r) && r == "fail", + StateProvider = state, + })); + + Assert.Equal(CircuitState.Closed, state.State); + + for (var i = 0; i < 2; i++) + { + await pipeline.ExecuteAsync(ct => new ValueTask("fail")); + } + + Assert.Equal(CircuitState.Open, state.State); + } + + [Fact] + public async Task Async_StateProvider_ReportsIsolatedAfterManualIsolate() + { + var state = new CircuitBreakerStateProvider(); + var control = new CircuitBreakerManualControl(); + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + ManualControl = control, + StateProvider = state, + })); + + await control.IsolateAsync(); + + Assert.Equal(CircuitState.Isolated, state.State); + + await control.ResetAsync(); + + Assert.Equal(CircuitState.Closed, state.State); + await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + } +} diff --git a/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs index a928d92..3dbf0c6 100644 --- a/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs +++ b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs @@ -537,4 +537,161 @@ public async Task Cleanup_NonCooperativeLosingAttempt_DoesNotHangIndefinitely() Assert.True(sw.Elapsed < TimeSpan.FromSeconds(15), $"Expected bounded cleanup, took {sw.Elapsed}."); } + + // ────────────────────────────────────────────────────────────────── + // HedgingDelayGenerator — future-plans #12 + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_HedgingDelayGenerator_OverridesStaticDelay() + { + var fakeTime = new FakeTimeProvider(); + var sends = 0; + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + // Would be sequential; the generator makes it parallel instead. + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + HedgingDelayGenerator = _ => TimeSpan.Zero, + }); + }); + + await pipeline.ExecuteAsync(ct => + { + Interlocked.Increment(ref sends); + return new ValueTask("ok"); + }); + + Assert.Equal(2, sends); + } + + [Fact] + public async Task Async_HedgingDelayGenerator_ReceivesAttemptNumberOfTheAttemptBeingLaunched() + { + var observed = new List(); + var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelayGenerator = args => + { + lock (observed) { observed.Add(args.AttemptNumber); } + return TimeSpan.Zero; + }, + })); + + await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + + // 0 is the primary, which is never "launched as a hedge", so the first hedge is 1. + Assert.Equal([1, 2], observed); + } + + [Fact] + public async Task Async_HedgingDelayGenerator_ReceivesExecutionContext() + { + ResilienceContext? seen = null; + var pipeline = Pipeline.Create(b => + { + b.Name = "generator-context"; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelayGenerator = args => { seen = args.Context; return TimeSpan.Zero; }, + }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + + Assert.NotNull(seen); + } + + [Fact] + public async Task Async_HedgingDelayGenerator_PerAttemptModeSelection() + { + // Attempt 1 parallel, attempt 2 sequential: the generator picks the mode per attempt. + var fakeTime = new FakeTimeProvider(); + var sends = 0; + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelayGenerator = args => args.AttemptNumber == 1 + ? TimeSpan.Zero + : System.Threading.Timeout.InfiniteTimeSpan, + }); + }); + + var result = await pipeline.ExecuteAsync(ct => + { + Interlocked.Increment(ref sends); + return new ValueTask("ok"); + }); + + Assert.Equal("ok", result); + // Two launched immediately; the third waits on its predecessor, which succeeds, so the + // loop returns without launching it. + Assert.Equal(2, sends); + } + + [Fact] + public async Task Async_HedgingDelayGenerator_NegativeValue_ClampedToZero() + { + var sends = 0; + var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelayGenerator = _ => TimeSpan.FromSeconds(-5), + })); + + await pipeline.ExecuteAsync(ct => + { + Interlocked.Increment(ref sends); + return new ValueTask("ok"); + }); + + // Clamped to zero, so it behaves as parallel rather than throwing. + Assert.Equal(2, sends); + } + + [Fact] + public void Sync_HedgingDelayGeneratorSet_Throws() + { + var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + HedgingDelayGenerator = _ => TimeSpan.Zero, + })); + + var ex = Assert.Throws(() => pipeline.Execute(ct => "ok")); + + Assert.Contains("HedgingDelayGenerator", ex.Message); + } + + [Fact] + public void Options_HedgingDelayGeneratorSet_SkipsStaticDelayValidation() + { + // A negative static delay is normally rejected, but is ignored when a generator is set. + var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromSeconds(-1), + HedgingDelayGenerator = _ => TimeSpan.Zero, + })); + + Assert.NotNull(pipeline); + } + + [Fact] + public void Options_NegativeStaticDelayWithoutGenerator_StillThrows() + => Assert.Throws(() => + Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromSeconds(-1), + }))); } diff --git a/tests/Resilion.Tests/PipelineTests.cs b/tests/Resilion.Tests/PipelineTests.cs index d5086fe..dabcaa2 100644 --- a/tests/Resilion.Tests/PipelineTests.cs +++ b/tests/Resilion.Tests/PipelineTests.cs @@ -543,9 +543,10 @@ public void Typed_BuildTwice_Throws() } /// -/// silently skips a typed strategy when -/// executed with a mismatched result type — this exercises that path directly (via -/// InternalsVisibleTo) and verifies it now at least warns via Debug.WriteLine. +/// throws when reached by an execution whose +/// result type differs from the strategy's, rather than silently skipping the strategy. Exercised +/// directly via InternalsVisibleTo, because no public API can currently produce a mismatch — +/// the guard exists so a future composition surface cannot reintroduce a silent wrong answer. /// public class TypedStrategyComponentMismatchTests { @@ -562,42 +563,113 @@ protected internal override ValueTask> ExecuteAsync( } } - private sealed class RecordingTraceListener : System.Diagnostics.TraceListener + // ─── Mismatch throws ──────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_TypeMismatch_Throws() { - public List Messages { get; } = []; - public override void Write(string? message) { } - public override void WriteLine(string? message) => Messages.Add(message ?? string.Empty); + var strategy = new PassthroughStringStrategy(); + var component = new TypedStrategyComponent(strategy, PipelineComponent.Empty); + var context = ResilienceContextPool.Shared.Rent(); + try + { + // Executed with TResult = int, but the strategy is Strategy — mismatch. + var ex = await Assert.ThrowsAsync(async () => + await component.ExecuteAsync( + ctx => new ValueTask>(Outcome.FromResult(42)), + context)); + + // The message must name both types, or it is undiagnosable in the field. + Assert.Contains("Strategy", ex.Message); + Assert.Contains("Int32", ex.Message); + + // The strategy must not have run, and the failure must not be silent. + Assert.False(strategy.WasCalled); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } } [Fact] - public async Task ExecuteAsync_TypeMismatch_SkipsStrategyAndWarns() + public void Sync_TypeMismatch_Throws() { var strategy = new PassthroughStringStrategy(); var component = new TypedStrategyComponent(strategy, PipelineComponent.Empty); + var context = ResilienceContextPool.Shared.Rent(); + try + { + var ex = Assert.Throws(() => + component.Execute(ctx => Outcome.FromResult(42), context)); + + Assert.Contains("Strategy", ex.Message); + Assert.Contains("Int32", ex.Message); + Assert.False(strategy.WasCalled); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + // ─── Matching type still takes the zero-allocation reinterpret-cast path ───────────────── - var listener = new RecordingTraceListener(); - System.Diagnostics.Trace.Listeners.Add(listener); + [Fact] + public async Task Async_TypeMatch_AppliesStrategy() + { + var strategy = new PassthroughStringStrategy(); + var component = new TypedStrategyComponent(strategy, PipelineComponent.Empty); var context = ResilienceContextPool.Shared.Rent(); try { - // Executed with TResult = int, but the strategy is Strategy — mismatch. - var outcome = await component.ExecuteAsync( - ctx => new ValueTask>(Outcome.FromResult(42)), + var outcome = await component.ExecuteAsync( + ctx => new ValueTask>(Outcome.FromResult("ok")), context); - // Skipped, not applied — the callback's result passes through untouched. - Assert.Equal(42, outcome.Result); - Assert.False(strategy.WasCalled); + Assert.Equal("ok", outcome.Result); + Assert.True(strategy.WasCalled); } finally { - System.Diagnostics.Trace.Listeners.Remove(listener); ResilienceContextPool.Shared.Return(context); } + } + + [Fact] + public void Sync_TypeMatch_AppliesStrategy() + { + var strategy = new PassthroughStringStrategy(); + var component = new TypedStrategyComponent(strategy, PipelineComponent.Empty); + var context = ResilienceContextPool.Shared.Rent(); + try + { + var outcome = component.Execute(ctx => Outcome.FromResult("ok"), context); + + Assert.Equal("ok", outcome.Result); + Assert.True(strategy.WasCalled); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + // ─── A typed pipeline never hits the mismatch path ─────────────────────────────────────── + + [Fact] + public async Task Async_TypedPipeline_NeverHitsMismatch() + { + // Pipeline only ever instantiates ExecuteAsync, so the guard is + // unreachable through the public API. If this ever throws, a composition surface has + // been added that can produce a mismatch — see future-plans #60. + var strategy = new PassthroughStringStrategy(); + var pipeline = Pipeline.Create(b => b.AddStrategy(strategy)); -#if DEBUG - Assert.Contains(listener.Messages, m => m.Contains("was skipped, not applied")); -#endif + var result = await pipeline.ExecuteAsync(static ct => new ValueTask("ok")); + + Assert.Equal("ok", result); + Assert.True(strategy.WasCalled); } } @@ -710,3 +782,92 @@ public async Task DisposeAsync_DefaultImplementation_FallsBackToSyncDispose() Assert.Contains("SyncOnly:disposed", log); } } + +/// +/// Idempotent disposal (prerequisite for keyed DI, future-plans #42) and the now-public +/// Pipeline.Name. +/// +public class PipelineDisposalAndNameTests +{ + private sealed class DisposeCountingStrategy : Strategy + { + public int DisposeCount { get; private set; } + + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + => callback(context); + + public override void Dispose() => DisposeCount++; + } + + [Fact] + public void Dispose_CalledTwice_IsNoOp() + { + var strategy = new DisposeCountingStrategy(); + var pipeline = Pipeline.Create(b => b.AddStrategy(strategy)); + + pipeline.Dispose(); + pipeline.Dispose(); + pipeline.Dispose(); + + Assert.Equal(1, strategy.DisposeCount); + } + + [Fact] + public async Task Async_DisposeAsync_CalledTwice_IsNoOp() + { + var strategy = new DisposeCountingStrategy(); + var pipeline = Pipeline.Create(b => b.AddStrategy(strategy)); + + await pipeline.DisposeAsync(); + await pipeline.DisposeAsync(); + + Assert.Equal(1, strategy.DisposeCount); + } + + [Fact] + public async Task Async_DisposeThenDisposeAsync_DisposesOnce() + { + // The two owners in the keyed-DI case need not agree on which overload they call. + var strategy = new DisposeCountingStrategy(); + var pipeline = Pipeline.Create(b => b.AddStrategy(strategy)); + + pipeline.Dispose(); + await pipeline.DisposeAsync(); + + Assert.Equal(1, strategy.DisposeCount); + } + + [Fact] + public void Typed_Dispose_CalledTwice_IsNoOp() + { + var strategy = new DisposeCountingStrategy(); + var pipeline = Pipeline.Create(b => b.AddStrategy(strategy)); + + pipeline.Dispose(); + pipeline.Dispose(); + + Assert.Equal(1, strategy.DisposeCount); + } + + [Fact] + public void Name_IsSetFromBuilder() + { + var pipeline = Pipeline.Create(b => { b.Name = "named"; }); + + Assert.Equal("named", pipeline.Name); + } + + [Fact] + public void Name_IsNullWhenUnset() + => Assert.Null(Pipeline.Create(b => { }).Name); + + [Fact] + public void Typed_Name_IsSetFromBuilder() + { + var pipeline = Pipeline.Create(b => { b.Name = "typed-named"; }); + + Assert.Equal("typed-named", pipeline.Name); + } +} diff --git a/tests/Resilion.Tests/PredicateBuilderTests.cs b/tests/Resilion.Tests/PredicateBuilderTests.cs new file mode 100644 index 0000000..7c33513 --- /dev/null +++ b/tests/Resilion.Tests/PredicateBuilderTests.cs @@ -0,0 +1,271 @@ +using Xunit; + +namespace Resilion.Tests; + +/// +/// and — future-plans #43. +/// +public class PredicateBuilderTests +{ + private class CustomException : Exception + { + public CustomException(string message = "custom", Exception? inner = null) + : base(message, inner) { } + } + + private sealed class DerivedCustomException : CustomException; + + // ─── Exception clauses ────────────────────────────────────────────────────────────────── + + [Fact] + public void Handle_MatchingExceptionType_ReturnsTrue() + { + Func, bool> predicate = new PredicateBuilder().Handle(); + + Assert.True(predicate(Outcome.FromException(new CustomException()))); + } + + [Fact] + public void Handle_NonMatchingExceptionType_ReturnsFalse() + { + Func, bool> predicate = new PredicateBuilder().Handle(); + + Assert.False(predicate(Outcome.FromException(new InvalidOperationException()))); + } + + [Fact] + public void Handle_DerivedExceptionType_Matches() + { + Func, bool> predicate = new PredicateBuilder().Handle(); + + Assert.True(predicate(Outcome.FromException(new DerivedCustomException()))); + } + + [Fact] + public void Handle_WithPredicate_AppliesPredicate() + { + Func, bool> predicate = new PredicateBuilder() + .Handle(ex => ex.Message == "yes"); + + Assert.True(predicate(Outcome.FromException(new CustomException("yes")))); + Assert.False(predicate(Outcome.FromException(new CustomException("no")))); + } + + [Fact] + public void Handle_OnSuccessOutcome_ReturnsFalse() + { + Func, bool> predicate = new PredicateBuilder().Handle(); + + Assert.False(predicate(Outcome.FromResult(1))); + } + + [Fact] + public void HandleInner_NestedException_Matches() + { + var wrapped = new InvalidOperationException("outer", new CustomException()); + var builder = new PredicateBuilder().HandleInner(); + Func, bool> predicate = builder; + + Assert.True(predicate(Outcome.FromException(wrapped))); + } + + [Fact] + public void HandleInner_AggregateException_FlattensAndMatches() + { + var aggregate = new AggregateException(new InvalidOperationException(), new CustomException()); + Func, bool> predicate = new PredicateBuilder().HandleInner(); + + Assert.True(predicate(Outcome.FromException(aggregate))); + } + + [Fact] + public void HandleInner_SelfReferencingChain_DoesNotHang() + { + // Malformed but observable in the wild: a bounded walk must terminate rather than spin. + var a = new CustomException("a"); + var wrapper = new InvalidOperationException("w", a); + Func, bool> predicate = new PredicateBuilder().HandleInner(); + + Assert.False(predicate(Outcome.FromException(wrapper))); + } + + [Fact] + public void Handle_DoesNotInheritTheDefaultCancellationCarveOut() + { + // The documented difference from the null-ShouldHandle default, which excludes OCE. + Func, bool> predicate = new PredicateBuilder().Handle(); + + Assert.True(predicate(Outcome.FromException(new OperationCanceledException()))); + } + + // ─── Result clauses ───────────────────────────────────────────────────────────────────── + + [Fact] + public void HandleResult_WithPredicate_MatchesOnResult() + { + Func, bool> predicate = new PredicateBuilder().HandleResult(r => r > 10); + + Assert.True(predicate(Outcome.FromResult(11))); + Assert.False(predicate(Outcome.FromResult(10))); + } + + [Fact] + public void HandleResult_WithValue_UsesDefaultEqualityComparer() + { + Func, bool> predicate = new PredicateBuilder().HandleResult("fail"); + + Assert.True(predicate(Outcome.FromResult("fail"))); + Assert.False(predicate(Outcome.FromResult("ok"))); + } + + [Fact] + public void HandleResult_OnFailureOutcome_ReturnsFalse() + { + // Result clauses must never observe a default value standing in for a failure. + Func, bool> predicate = new PredicateBuilder().HandleResult(0); + + Assert.False(predicate(Outcome.FromException(new CustomException()))); + } + + // ─── Composition and Build semantics ──────────────────────────────────────────────────── + + [Fact] + public void MultipleClauses_CombineWithOr() + { + Func, bool> predicate = new PredicateBuilder() + .Handle() + .HandleResult(r => r < 0); + + Assert.True(predicate(Outcome.FromException(new CustomException()))); + Assert.True(predicate(Outcome.FromResult(-1))); + Assert.False(predicate(Outcome.FromResult(1))); + Assert.False(predicate(Outcome.FromException(new InvalidOperationException()))); + } + + [Fact] + public void Build_WithNoClauses_Throws() + { + var ex = Assert.Throws(() => new PredicateBuilder().Build()); + + Assert.Contains("no clauses", ex.Message); + } + + [Fact] + public void ImplicitConversion_WithNoClauses_Throws() + { + Assert.Throws(() => + { + Func, bool> _ = new PredicateBuilder(); + }); + } + + [Fact] + public void Build_SnapshotsClauses_LaterMutationDoesNotAffectReturnedDelegate() + { + var builder = new PredicateBuilder().HandleResult(1); + var predicate = builder.Build(); + + builder.HandleResult(2); + + Assert.True(predicate(Outcome.FromResult(1))); + Assert.False(predicate(Outcome.FromResult(2))); + } + + [Fact] + public void Build_CalledTwice_ReturnsIndependentWorkingDelegates() + { + // Deliberately not single-use, unlike PipelineBuilder. + var builder = new PredicateBuilder().HandleResult(1); + + var first = builder.Build(); + var second = builder.Build(); + + Assert.True(first(Outcome.FromResult(1))); + Assert.True(second(Outcome.FromResult(1))); + } + + [Fact] + public void Handle_NullPredicate_Throws() + => Assert.Throws(() => + new PredicateBuilder().Handle(null!)); + + // ─── Non-generic builder ──────────────────────────────────────────────────────────────── + + [Fact] + public void NonGeneric_Handle_ProducesFuncOfException() + { + Func predicate = new PredicateBuilder().Handle(); + + Assert.True(predicate(new CustomException())); + Assert.False(predicate(new InvalidOperationException())); + } + + [Fact] + public void NonGeneric_HandleInner_WalksChain() + { + Func predicate = new PredicateBuilder().HandleInner(); + + Assert.True(predicate(new InvalidOperationException("outer", new CustomException()))); + } + + [Fact] + public void NonGeneric_MultipleClauses_CombineWithOr() + { + Func predicate = new PredicateBuilder() + .Handle() + .Handle(); + + Assert.True(predicate(new CustomException())); + Assert.True(predicate(new TimeoutException())); + Assert.False(predicate(new InvalidOperationException())); + } + + [Fact] + public void NonGeneric_Build_WithNoClauses_Throws() + => Assert.Throws(() => new PredicateBuilder().Build()); + + // ─── Works where it is meant to be used ───────────────────────────────────────────────── + + [Fact] + public async Task Async_TypedPipelineWithPredicateBuilder_RetriesAsExpected() + { + var attempts = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + ShouldHandle = new PredicateBuilder().HandleResult("fail"), + })); + + var result = await pipeline.ExecuteAsync( + ct => new ValueTask(++attempts < 3 ? "fail" : "ok")); + + Assert.Equal("ok", result); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task Async_UntypedPipelineWithNonGenericPredicateBuilder_RetriesAsExpected() + { + var attempts = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + ShouldHandle = new PredicateBuilder().Handle(), + })); + + var result = await pipeline.ExecuteAsync(ct => + { + if (++attempts < 3) + { + throw new CustomException(); + } + + return new ValueTask("ok"); + }); + + Assert.Equal("ok", result); + Assert.Equal(3, attempts); + } +} diff --git a/tests/Resilion.Tests/Resilion.Tests.csproj b/tests/Resilion.Tests/Resilion.Tests.csproj index d87f1f0..fb292f3 100644 --- a/tests/Resilion.Tests/Resilion.Tests.csproj +++ b/tests/Resilion.Tests/Resilion.Tests.csproj @@ -11,7 +11,6 @@ - diff --git a/tests/Resilion.Tests/Telemetry/ActivityTests.cs b/tests/Resilion.Tests/Telemetry/ActivityTests.cs new file mode 100644 index 0000000..ffb23d8 --- /dev/null +++ b/tests/Resilion.Tests/Telemetry/ActivityTests.cs @@ -0,0 +1,274 @@ +using System.Diagnostics; +using Xunit; + +namespace Resilion.Tests; + +/// +/// ActivitySource span coverage — future-plans #56. +/// +/// +/// Spans previously existed only on the non-generic strategies, so any pipeline built with +/// Pipeline.Create<TResult> emitted no retry or circuit-breaker spans at all while +/// docs/telemetry.md promised that every strategy execution creates one. The parity test +/// at the bottom is the one that would have caught that, and it is what makes the later retry-loop +/// deduplication (#27) safe to attempt. +/// +public class ActivityTests +{ + /// + /// An with Sample => AllData is mandatory: without a + /// listener StartActivity returns null and every assertion here would pass vacuously. + /// + private sealed class ActivityTracker : IDisposable + { + private readonly ActivityListener _listener; + + public List Stopped { get; } = []; + + /// + /// Only spans tagged with this pipeline name are recorded. + /// listeners are process-global and xunit runs test classes in parallel, so an unfiltered + /// tracker also sees spans emitted by other tests. Every pipeline built in this class is + /// named for exactly this reason. + /// + public ActivityTracker(string pipelineName) + { + _listener = new ActivityListener + { + ShouldListenTo = s => s.Name == ResilionTelemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = a => + { + var name = a.Tags.FirstOrDefault(t => t.Key == ResilionTelemetry.PipelineNameTag).Value; + if (name != pipelineName) + { + return; + } + + lock (Stopped) { Stopped.Add(a); } + }, + }; + ActivitySource.AddActivityListener(_listener); + } + + public IReadOnlyList Names() + { + lock (Stopped) { return Stopped.Select(a => a.OperationName).OrderBy(n => n).ToList(); } + } + + public string? TagOf(string span, string tag) + { + lock (Stopped) + { + return Stopped.First(a => a.OperationName == span) + .Tags.FirstOrDefault(t => t.Key == tag).Value; + } + } + + public void Dispose() => _listener.Dispose(); + } + + // ─── Typed pipelines emit spans (the #56 gap) ─────────────────────────────────────────── + + [Fact] + public async Task Async_TypedRetryPipeline_EmitsRetrySpan() + { + using var tracker = new ActivityTracker("typed-retry-async"); + var pipeline = Pipeline.Create(b => + { + b.Name = "typed-retry-async"; + b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = o => o.TryGetResult(out var r) && r == 0, + }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask(0)); + + Assert.Contains("Retry", tracker.Names()); + } + + [Fact] + public void Sync_TypedRetryPipeline_EmitsRetrySpan() + { + using var tracker = new ActivityTracker("typed-retry-sync"); + var pipeline = Pipeline.Create(b => + { + b.Name = "typed-retry-sync"; + b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = o => o.TryGetResult(out var r) && r == 0, + }); + }); + + pipeline.Execute(ct => 0); + + Assert.Contains("Retry", tracker.Names()); + } + + [Fact] + public async Task Async_TypedCircuitBreakerPipeline_EmitsCircuitBreakerSpan() + { + using var tracker = new ActivityTracker("typed-cb-async"); + var pipeline = Pipeline.Create(b => + { + b.Name = "typed-cb-async"; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions { MinimumThroughput = 100 }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask(1)); + + Assert.Contains("CircuitBreaker", tracker.Names()); + } + + [Fact] + public void Sync_TypedCircuitBreakerPipeline_EmitsCircuitBreakerSpan() + { + using var tracker = new ActivityTracker("typed-cb-sync"); + var pipeline = Pipeline.Create(b => + { + b.Name = "typed-cb-sync"; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions { MinimumThroughput = 100 }); + }); + + pipeline.Execute(ct => 1); + + Assert.Contains("CircuitBreaker", tracker.Names()); + } + + [Fact] + public void Sync_HedgingPipeline_EmitsHedgingSpan() + { + using var tracker = new ActivityTracker("sync-hedging"); + var pipeline = Pipeline.Create(b => + { + b.Name = "sync-hedging"; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + }); + }); + + pipeline.Execute(ct => 1); + + Assert.Contains("Hedging", tracker.Names()); + } + + // ─── Tag shape ────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task AllStrategySpans_CarryStrategyNamePipelineNameAndOperationKeyTags() + { + using var tracker = new ActivityTracker("tag-shape"); + var pipeline = Pipeline.Create(b => + { + b.Name = "tag-shape"; + b.AddTimeout(TimeSpan.FromSeconds(30)); + b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 0 }); + }); + + // ExecuteOutcomeAsync takes a caller-owned context, which is the only way to set + // OperationKey — and asserting on it is the point of this test. + var context = ResilienceContextPool.Shared.Rent(); + try + { + context.OperationKey = "tag-shape-op"; + await pipeline.ExecuteOutcomeAsync( + static (state, ctx) => new ValueTask>(Outcome.FromResult(1)), + 0, + context); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + + foreach (var span in new[] { "Timeout", "Retry" }) + { + Assert.Equal(span, tracker.TagOf(span, "strategy.name")); + Assert.Equal("tag-shape", tracker.TagOf(span, ResilionTelemetry.PipelineNameTag)); + Assert.Equal("tag-shape-op", tracker.TagOf(span, ResilionTelemetry.OperationKeyTag)); + } + } + + [Fact] + public async Task SpanOutcomeTag_MatchesDocumentedVocabulary() + { + // Keeps docs/telemetry.md and the code married. Any new outcome value must be documented. + var documented = new HashSet + { + "success", "failure", "exception", "timeout", "no_timeout", + "rejected", "retry_exhausted", "fallback_applied", "hedging_exhausted", + }; + + using var tracker = new ActivityTracker("outcome-vocab"); + var pipeline = Pipeline.Create(b => + { + b.Name = "outcome-vocab"; + b.AddTimeout(TimeSpan.FromSeconds(30)); + b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = o => o.TryGetResult(out var r) && r == 0, + }); + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions { MinimumThroughput = 100 }); + }); + + await pipeline.ExecuteAsync(ct => new ValueTask(0)); + + var observed = tracker.Stopped + .SelectMany(a => a.Tags.Where(t => t.Key == "outcome")) + .Select(t => t.Value!) + .ToList(); + + Assert.NotEmpty(observed); + foreach (var o in observed) + { + Assert.Contains(o, documented); + } + } + + // ─── The parity test that would have caught #56 ───────────────────────────────────────── + + [Fact] + public async Task TypedAndUntypedPipelines_EmitTheSameSpanNames() + { + // Same strategy set through both factories must produce the same spans. This is the + // invariant that was silently false: typed retry and typed circuit breaker emitted none. + IReadOnlyList untyped; + using (var tracker = new ActivityTracker("parity-untyped")) + { + var pipeline = Pipeline.Create(b => + { + b.Name = "parity-untyped"; + b.AddTimeout(TimeSpan.FromSeconds(30)); + b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 0 }); + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions { MinimumThroughput = 100 }); + }); + await pipeline.ExecuteAsync(ct => new ValueTask(1)); + untyped = tracker.Names(); + } + + IReadOnlyList typed; + using (var tracker = new ActivityTracker("parity-typed")) + { + var pipeline = Pipeline.Create(b => + { + b.Name = "parity-typed"; + b.AddTimeout(TimeSpan.FromSeconds(30)); + b.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 0 }); + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions { MinimumThroughput = 100 }); + }); + await pipeline.ExecuteAsync(ct => new ValueTask(1)); + typed = tracker.Names(); + } + + Assert.Equal(untyped, typed); + } +} diff --git a/tests/Resilion.Tests/Telemetry/TelemetryTests.cs b/tests/Resilion.Tests/Telemetry/TelemetryTests.cs index e664f1f..4394d98 100644 --- a/tests/Resilion.Tests/Telemetry/TelemetryTests.cs +++ b/tests/Resilion.Tests/Telemetry/TelemetryTests.cs @@ -19,7 +19,14 @@ private sealed class CounterTracker : IDisposable private readonly MeterListener _listener; private long _count; - public CounterTracker(string instrumentName) + /// The instrument to count. + /// + /// When set, only measurements tagged with this pipeline name are counted. + /// is process-global and xunit runs test classes in parallel, + /// so an unfiltered tracker also counts measurements emitted by other tests. Any test + /// asserting an exact count must filter. + /// + public CounterTracker(string instrumentName, string? pipelineName = null) { _listener = new MeterListener { @@ -32,10 +39,30 @@ public CounterTracker(string instrumentName) }, }; _listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => - Interlocked.Add(ref _count, measurement)); + { + if (pipelineName is not null && !HasPipelineName(tags, pipelineName)) + { + return; + } + + Interlocked.Add(ref _count, measurement); + }); _listener.Start(); } + private static bool HasPipelineName(ReadOnlySpan> tags, string expected) + { + foreach (var tag in tags) + { + if (tag.Key == ResilionTelemetry.PipelineNameTag && (tag.Value as string) == expected) + { + return true; + } + } + + return false; + } + public long Count => Interlocked.Read(ref _count); public void Dispose() => _listener.Dispose(); @@ -128,17 +155,182 @@ public async Task FallbackActivations_IncrementsWhenFallbackTriggers() [Fact] public async Task HedgingAttempts_IncrementsWhenHedgeLaunches() { - using var tracker = new CounterTracker("resilion.hedging.attempts"); + using var tracker = new CounterTracker("resilion.hedging.attempts", "hedge-async-exact"); - var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + var pipeline = Pipeline.Create(b => { - MaxHedgedAttempts = 2, - HedgingDelay = TimeSpan.Zero, // Parallel mode — hedge always launches. - })); + b.Name = "hedge-async-exact"; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.Zero, // Parallel mode — hedge always launches. + }); + }); await pipeline.ExecuteAsync(ct => new ValueTask("ok")); - Assert.True(tracker.Count >= 1); + // Exact, not `>= 1`: the whole defect class here is under-counting, and a `>=` assertion + // passes with the counter half-broken. + Assert.Equal(1, tracker.Count); + } + + // ─── future-plans #55: the sync path never incremented this counter at all ────────────── + + [Fact] + public void HedgingAttempts_IncrementsOnSyncSequentialHedge() + { + using var tracker = new CounterTracker("resilion.hedging.attempts", "hedge-sync"); + + var attempt = 0; + var pipeline = Pipeline.Create(b => + { + b.Name = "hedge-sync"; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + // The only sync-legal mode; parallel and latency modes throw on the sync path. + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + ShouldHandle = o => o.TryGetResult(out var r) && r == "fail", + }); + }); + + var result = pipeline.Execute(ct => ++attempt < 3 ? "fail" : "ok"); + + Assert.Equal("ok", result); + Assert.Equal(3, attempt); + + // Two hedge launches for three attempts: the first attempt is not itself a hedge. + Assert.Equal(2, tracker.Count); + } + + [Fact] + public async Task HedgingAttempts_SyncAndAsyncAgreeOnCount() + { + // Parity guard. The counter and the two execution paths must not be able to diverge again, + // and this is also part of the harness that makes the #27 retry-loop dedup safe. + static HedgingStrategyOptions Options() => new() + { + MaxHedgedAttempts = 3, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + ShouldHandle = o => o.TryGetResult(out var r) && r == "fail", + }; + + long syncCount; + using (var tracker = new CounterTracker("resilion.hedging.attempts", "parity-sync")) + { + var attempt = 0; + var pipeline = Pipeline.Create(b => + { + b.Name = "parity-sync"; + b.AddHedging(Options()); + }); + pipeline.Execute(ct => ++attempt < 3 ? "fail" : "ok"); + syncCount = tracker.Count; + } + + long asyncCount; + using (var tracker = new CounterTracker("resilion.hedging.attempts", "parity-async")) + { + var attempt = 0; + var pipeline = Pipeline.Create(b => + { + b.Name = "parity-async"; + b.AddHedging(Options()); + }); + await pipeline.ExecuteAsync(ct => new ValueTask(++attempt < 3 ? "fail" : "ok")); + asyncCount = tracker.Count; + } + + Assert.Equal(syncCount, asyncCount); + } + + // ─── future-plans #59: strategies nested inside hedging emitted pipeline.name = null ──── + + [Fact] + public async Task NestedStrategyInsideHedging_EmitsPipelineNameTag() + { + // Correlate on operation.key, not pipeline.name: pipeline.name is the value under test, and + // MeterListener is process-global, so filtering on it would either be circular or pick up + // measurements from other test classes running in parallel. + const string OperationKey = "nested-hedging-probe"; + + var pipelineNames = new List(); + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == ResilionTelemetry.MeterName + && instrument.Name == "resilion.retry.attempts") + { + l.EnableMeasurementEvents(instrument); + } + }, + }; + listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + { + string? pipelineName = null; + var mine = false; + foreach (var tag in tags) + { + if (tag.Key == ResilionTelemetry.OperationKeyTag && (tag.Value as string) == OperationKey) + { + mine = true; + } + else if (tag.Key == ResilionTelemetry.PipelineNameTag) + { + pipelineName = tag.Value as string; + } + } + + if (mine) + { + lock (pipelineNames) { pipelineNames.Add(pipelineName); } + } + }); + listener.Start(); + + var attempt = 0; + var pipeline = Pipeline.Create(b => + { + b.Name = "nested-pipeline"; + // MaxHedgedAttempts must be >= 2 so a hedge is actually launched: only then does the + // inner retry run on hedging's per-attempt context, which is the thing under test. + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, + ShouldHandle = o => o.TryGetResult(out var r) && r == "fail", + }); + b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + ShouldHandle = o => o.TryGetResult(out var r) && r == "fail", + }); + }); + + // ExecuteOutcomeAsync takes a caller-owned context, which is the only way to set OperationKey. + var context = ResilienceContextPool.Shared.Rent(); + try + { + context.OperationKey = OperationKey; + await pipeline.ExecuteOutcomeAsync( + // Fail enough times that the inner retry exhausts inside the first hedged attempt + // and hedging then launches a second attempt, whose retry also emits. + static (state, ctx) => new ValueTask>( + Outcome.FromResult(++state.Attempt < 5 ? "fail" : "ok")), + new Counter(), + context); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + + // The retry lives inside hedging, so its measurements come from a per-attempt context. + // Before the fix those carried pipeline.name = null. + Assert.NotEmpty(pipelineNames); + Assert.All(pipelineNames, n => Assert.Equal("nested-pipeline", n)); } [Fact] @@ -190,4 +382,10 @@ public void DeadInstruments_NoLongerExistOnTheMeter() Assert.DoesNotContain("resilion.strategy.executions", observedNames); Assert.DoesNotContain("resilion.strategy.duration", observedNames); } + + private sealed class Counter + { + public int Attempt; + } + } diff --git a/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs b/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs index 9c15171..2d75689 100644 --- a/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs +++ b/tests/Resilion.Tests/Timeout/TimeoutStrategyTests.cs @@ -291,4 +291,181 @@ public async Task DefaultOptions_Uses30SecondTimeout() var ex = await Assert.ThrowsAsync(() => task.AsTask()); Assert.Equal(TimeSpan.FromSeconds(30), ex.ConfiguredTimeout); } + + // ────────────────────────────────────────────────────────────────── + // Cancellation cause classification — future-plans #46 + // + // The old implementation read `linkedCts.IsCancellationRequested && + // !userToken.IsCancellationRequested` as two separate reads, so the user token could change + // between them. The two fixes previously proposed in future-plans.md (pre-capturing the user + // token's state, or a "user cancelled at some point" flag) only reverse the direction of the + // error. `Async_TimeoutFiresThenUserCancels_StillClassifiedAsTimeout` below is the case that + // fails under all three. + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Async_UserCancelsBeforeTimeoutFires_ThrowsOperationCanceledNotTimeoutRejected() + { + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(30)); + }); + + using var userCts = new CancellationTokenSource(); + + var task = pipeline.ExecuteAsync( + async ct => + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + return "unreachable"; + }, + userCts.Token).AsTask(); + + userCts.Cancel(); + + await Assert.ThrowsAnyAsync(() => task); + Assert.False(task.Exception?.InnerException is TimeoutRejectedException); + } + + [Fact] + public async Task Async_TimeoutFiresThenUserCancels_StillClassifiedAsTimeout() + { + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + using var userCts = new CancellationTokenSource(); + var callbackEntered = new TaskCompletionSource(); + + // The callback cancels the user's token from inside its own cancellation handler, i.e. + // strictly *after* the timeout has already fired. That is the race, made deterministic. + var task = pipeline.ExecuteAsync( + async ct => + { + callbackEntered.SetResult(); + try + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + } + catch (OperationCanceledException) + { + userCts.Cancel(); + throw; + } + + return "unreachable"; + }, + userCts.Token).AsTask(); + + await callbackEntered.Task; + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public void Sync_TimeoutFiresThenUserCancels_StillClassifiedAsTimeout() + { + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + using var userCts = new CancellationTokenSource(); + + Assert.Throws(() => pipeline.Execute( + ct => + { + // Advance past the timeout so the linked token is cancelled, then have the user + // cancel afterwards — the same ordering as the async case. + fakeTime.Advance(TimeSpan.FromSeconds(6)); + if (ct.IsCancellationRequested) + { + userCts.Cancel(); + ct.ThrowIfCancellationRequested(); + } + + return "unreachable"; + }, + userCts.Token)); + } + + [Fact] + public async Task Async_TimeoutWithNonCancellableToken_ClassifiesAsTimeout() + { + // Pins the CanBeCanceled fast path: no user registration is taken, so the cause can only + // ever be the timer. + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + var callbackEntered = new TaskCompletionSource(); + var task = pipeline.ExecuteAsync(async ct => + { + callbackEntered.SetResult(); + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + return "unreachable"; + }).AsTask(); + + await callbackEntered.Task; + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task Async_UserCancelsWithNoTimeoutConfigured_PropagatesOperationCanceled() + { + // Regression guard on the InfiniteTimeSpan early return, which never creates a cause. + var pipeline = Pipeline.Create(b => + b.AddTimeout(System.Threading.Timeout.InfiniteTimeSpan)); + + using var userCts = new CancellationTokenSource(); + var task = pipeline.ExecuteAsync( + async ct => + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + return "unreachable"; + }, + userCts.Token).AsTask(); + + userCts.Cancel(); + + await Assert.ThrowsAnyAsync(() => task); + } + + [Fact] + public async Task Async_TimeoutRejectedException_PreservesOriginalOceAsInnerException() + { + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + var callbackEntered = new TaskCompletionSource(); + var task = pipeline.ExecuteAsync(async ct => + { + callbackEntered.SetResult(); + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); + return "unreachable"; + }).AsTask(); + + await callbackEntered.Task; + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + var ex = await Assert.ThrowsAsync(() => task); + Assert.IsAssignableFrom(ex.InnerException); + } }