Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ jobs:
PACKAGE_VERSION: ${{ steps.gitversion.outputs.semVer }}
run: ./scripts/Verify-Packages.ps1 -PackagesPath artifacts/package/release -Version $env:PACKAGE_VERSION

- name: Compile and execute documentation snippets
if: matrix.os == 'ubuntu-latest'
shell: pwsh
env:
PACKAGE_VERSION: ${{ steps.gitversion.outputs.semVer }}
run: ./scripts/Verify-DocSnippets.ps1 -PackagesPath artifacts/package/release -Version $env:PACKAGE_VERSION

- name: Upload packages
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.11" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />

<!-- Benchmarks -->
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
Expand Down
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Shield.CircuitBreaker(o =>
o.OnStateChanged = c => logger.LogWarning("Circuit {From} -> {To}", c.From, c.To);
});

monitor.State; // Closed / Open / HalfOpen / Isolated
_ = monitor.State; // Closed / Open / HalfOpen / Isolated
monitor.Isolate(); // force open (maintenance switch)
monitor.Reset(); // close and clear metrics
```
Expand Down Expand Up @@ -173,11 +173,13 @@ var shield = Shield.For<Config>()
.Fallback(Config.Default);

// Or compute it, with access to the typed failure:
.Fallback((outcome, ct) =>
{
logger.LogError(outcome.Exception, "Using cached config");
return new ValueTask<Config>(cache.Get());
});
var computed = Shield.For<Config>()
.When<HttpRequestException>()
.Fallback((outcome, ct) =>
{
logger.LogError(outcome.Exception, "Using cached config");
return new ValueTask<Config>(cache.Get());
});

// Void executions have their own fallback on the plain Shield:
Shield.When<MessagingException>()
Expand Down Expand Up @@ -237,6 +239,7 @@ The same shield serves any result type, sync or async. (One exception: hedging i

## Dependency injection

<!-- doc-test-tail-declaration: split-before=public sealed class -->
```csharp
services.AddShield("github", Shield.Timeout(TimeSpan.FromSeconds(10)).Retry(3));
services.AddShield<HttpResponseMessage>("downstream",
Expand All @@ -248,7 +251,10 @@ services.AddShield("github", builder.Configuration.GetSection("Resilience:GitHub
// Consume via the registry…
var shield = registry.GetShield("github"); // IKevlarRegistry
// …or as a keyed service
public sealed class GitHubClient([FromKeyedServices("github")] Shield shield) { }
public sealed class GitHubClient([FromKeyedServices("github")] Shield shield)
{
public Shield Resilience { get; } = shield;
}
```

## HTTP
Expand All @@ -275,6 +281,7 @@ And `dotnet add package Kevlar.Analyzers` adds compile-time checks — starting

Everything in Kevlar is a `Strategy` — middleware over an `Outcome<T>` pipeline. Write your own:

<!-- doc-test-declaration: split-before=var shield -->
```csharp
public sealed class LoggingStrategy(ILogger logger) : Strategy
{
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ var background = Shield.Timeout(TimeSpan.FromSeconds(30)).Retry(5).Wrap(downstr

A [handling clause](handling-failures.md) applies to the strategy it precedes *and* to every reactive strategy chained after it, until you write a new clause:

<!-- doc-test-ignore: Uses an ellipsis to illustrate a fallback body defined by the application. -->
```csharp
Shield
.When<HttpRequestException>()
Expand All @@ -90,6 +91,7 @@ Shield

One ordering is always a bug: a `Fallback` chained *after* (inside) a retry, hedge or circuit breaker that shares its handling clause. The fallback recovers every failure before the outer strategy sees one, silently disabling it. Kevlar refuses to build that chain:

<!-- doc-test-run: invalid-composition -->
```csharp
Shield.For<int>().Retry(3).Fallback(-1);
// InvalidOperationException: … makes Retry(3, …) unreachable.
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/custom-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Everything in Kevlar is a `Strategy` — middleware over an `Outcome<T>` pipelin

## A logging strategy

<!-- doc-test-declaration: split-before=var shield -->
```csharp
public sealed class LoggingStrategy(ILogger logger) : Strategy
{
Expand All @@ -29,6 +30,7 @@ var shield = Shield.Use(new LoggingStrategy(logger)).Retry(3);

Override `Describe()` so `shield.ToString()` names your strategy meaningfully in [pipeline descriptions](observability.md#pipeline-descriptions):

<!-- doc-test-strategy-member -->
```csharp
public override string Describe() => "Logging";
```
Expand Down Expand Up @@ -60,6 +62,7 @@ The power is in how many times you call `next`:

Strategies return failures as `Outcome<T>` values rather than throwing, so outer strategies can react to them cheaply:

<!-- doc-test-strategy-member -->
```csharp
public override async ValueTask<Outcome<T>> ExecuteAsync<T, TState>(
Continuation<T, TState> next, KevlarContext context)
Expand Down Expand Up @@ -96,6 +99,7 @@ The context flows through the whole pipeline:
- `context.IsSynchronous` — `true` under `Execute`; branch on it if your strategy would otherwise block or break a sync caller (hedging throws for sync callers this way).
- `context.Properties` — a typed property bag: `Set(key, value)`, `TryGet(key, out value)`, `GetOrDefault(key)`, keyed by `KevlarKey<T>`:

<!-- doc-test-declaration: split-before=context.Properties -->
```csharp
static readonly KevlarKey<string> TenantId = new("tenant-id");

Expand Down
4 changes: 3 additions & 1 deletion docs/docs/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Because shields are immutable and thread-safe, each named shield is a singleton

## Consuming via the registry

<!-- doc-test-ignore: Application client type requires the host's FetchUserAsync implementation. -->
```csharp
public sealed class GitHubClient(IKevlarRegistry registry)
{
Expand Down Expand Up @@ -79,10 +80,11 @@ The schema is `ShieldDefinition`: optional `Timeout`, `Retry`, `CircuitBreaker`,

Named shields are also registered as keyed services, so you can skip the registry entirely:

<!-- doc-test-declaration -->
```csharp
public sealed class GitHubClient([FromKeyedServices("github")] Shield shield)
{
// ...
public Shield Resilience { get; } = shield;
}
```

Expand Down
1 change: 1 addition & 0 deletions docs/docs/executing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The only exception: [hedging](strategies/hedging.md) is inherently concurrent an

`Task` and `ValueTask` delegates both work — your existing `Task`-returning methods flow straight in, no wrapping:

<!-- doc-test-ignore: Method declaration uses an ellipsis for the application implementation. -->
```csharp
Task<User> LoadUserAsync(int id, CancellationToken ct) => ...; // ordinary Task method

Expand Down
1 change: 1 addition & 0 deletions docs/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Three things to notice:

Shields are immutable and thread-safe. Build one, store it in a `static readonly` field or register it in [DI](dependency-injection.md), and use it for every call to that dependency:

<!-- doc-test-declaration: split-before=// Any result -->
```csharp
private static readonly Shield GitHubShield = Shield
.Timeout(TimeSpan.FromSeconds(10))
Expand Down
1 change: 1 addition & 0 deletions docs/docs/handling-failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Shield.For<User?>().WhenDefault().Retry(2); // retry when the result is null /

A clause applies to the strategy it creates *and* to every reactive strategy chained after it, until you write a new clause:

<!-- doc-test-ignore: Uses an ellipsis for the application-specific fallback implementation. -->
```csharp
Shield
.When<HttpRequestException>() // clause #1
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/library-authors.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ sidebar_position: 10

You ship a library that talks to something flaky — an HTTP API, a database, a queue — and you want *your users* to decide how resilient those calls are. The integration surface is one parameter:

<!-- doc-test-ignore: Library type depends on the author's Report model and FetchReportAsync transport. -->
```csharp
public sealed class ReportsClient
{
Expand Down Expand Up @@ -50,6 +51,7 @@ And for testing, you don't need a mock: `Shield.Empty` is the no-op, and fault i

If callers should be able to react to *result values* — retry on `null`, hedge on an error status — accept a `Shield<T>` instead:

<!-- doc-test-ignore: Constructor fragment intended to appear inside the library's ProfileClient type. -->
```csharp
public ProfileClient(Shield<Profile?>? shield = null)
=> _shield = shield ?? Shield<Profile?>.Empty;
Expand All @@ -64,6 +66,7 @@ var client = new ProfileClient(

`Shield.Empty` is the right default when resilience is genuinely optional. If your library *should* retry out of the box, default to a real shield instead — and think about where its state lives:

<!-- doc-test-ignore: Constructor fragment intended to appear inside the library's ReportsClient type. -->
```csharp
public ReportsClient(HttpClient http, Shield? shield = null)
=> _shield = shield ?? Shield.Retry(3).Timeout(TimeSpan.FromSeconds(10));
Expand Down
1 change: 1 addition & 0 deletions docs/docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Shields are observable without any setup: they describe themselves as strings, p

`shield.ToString()` prints the whole pipeline, outermost strategy first, with each strategy's configuration:

<!-- doc-test-run: pipeline-description -->
```csharp
var shield = Shield
.Timeout(TimeSpan.FromSeconds(30))
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/strategies/circuit-breaker.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ var shield = Shield.CircuitBreaker(o =>
o.OnStateChanged = c => logger.LogWarning("Circuit {From} -> {To}", c.From, c.To);
});

monitor.State; // Closed / Open / HalfOpen / Isolated
_ = monitor.State; // Closed / Open / HalfOpen / Isolated
monitor.StateChanged += e => metrics.Record(e.To);
monitor.Isolate(); // force open (maintenance switch)
monitor.Reset(); // close and clear metrics
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/strategies/fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A void fallback guards void executions only; executing a result-returning delega

## Three shapes

<!-- doc-test-ignore: Alternative fluent fragments require the typed builder introduced by the surrounding prose. -->
```csharp
// 1. A constant value:
.Fallback(Config.Default)
Expand All @@ -45,6 +46,7 @@ A void fallback guards void executions only; executing a result-returning delega

Every overload takes an optional `onFallback` callback:

<!-- doc-test-ignore: Fluent fragment requires the typed builder introduced by the surrounding prose. -->
```csharp
.Fallback(Config.Default,
onFallback: e => metrics.Increment("config.fallback"))
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/strategies/retry.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ Backoff.Constant(TimeSpan.FromSeconds(1)); // 1s, 1s, 1s, ...
Backoff.Linear(TimeSpan.FromMilliseconds(500)); // 500ms, 1s, 1.5s, ...
Backoff.Exponential(TimeSpan.FromSeconds(1)); // ~1s, ~2s, ~4s, ... (jittered)
Backoff.Custom(attempt => TimeSpan.FromMilliseconds(100 * attempt)); // attempt is 1-based
Backoff.None; // no delay between attempts
Backoff.Default; // what bare Retry(n) uses
_ = Backoff.None; // no delay between attempts
_ = Backoff.Default; // what bare Retry(n) uses
```

- `Exponential(initialDelay, factor = 2.0, maxDelay = null, jitter = true)` — jitter scales each delay by a random factor in [0.5, 1.5) to avoid synchronized retry storms. `Backoff.Default` = `Exponential(250ms, maxDelay: 30s)`.
Expand Down
18 changes: 18 additions & 0 deletions docs/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ dotnet stryker --config-file stryker-config.json
Pop-Location
```

## Documentation snippet gates

Every C# fence in the README and Docusaurus documentation is compiled on pull requests. The harness extracts source directly from Markdown, generates isolated call sites, and restores Kevlar from the locally packed `.nupkg` files. This keeps documentation as the single source of truth and catches stale package IDs, API names, and overloads.

Complete snippets need no marker. A fragment that intentionally depends on omitted application code must place an explicit reason immediately before its fence:

```markdown
<!-- doc-test-ignore: Application transport implementation is omitted. -->
```

Class-level declarations use `doc-test-declaration`; mixed blocks can split declarations from call sites with `doc-test-tail-declaration`; isolated custom strategy members use `doc-test-strategy-member`; safe behavioral samples use `doc-test-run`. Unknown or malformed directives fail validation. Shell `dotnet add package` IDs and `dotnet run --project` paths are validated separately.

After packing with a chosen version, run the same package-consumer check locally:

```powershell
./scripts/Verify-DocSnippets.ps1 -PackagesPath artifacts/package/release -Version $packageVersion
```

Every delay, timeout and time window in Kevlar runs on a `TimeProvider`. Swap in a fake and your tests never actually wait:

```csharp
Expand Down
Loading
Loading