diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml
index 6793fe5..992d132 100644
--- a/.github/workflows/build-samples.yml
+++ b/.github/workflows/build-samples.yml
@@ -19,6 +19,7 @@ on:
- "blazor/ssr-interactive-islands/**"
- "cloud-native/dockerize-aspnet-core-clean-images/**"
- "cloud-native/health-resilience-zero-downtime/**"
+ - "cloud-native/polly-resilience/**"
- ".github/workflows/build-samples.yml"
pull_request:
@@ -38,6 +39,7 @@ on:
- "blazor/ssr-interactive-islands/**"
- "cloud-native/dockerize-aspnet-core-clean-images/**"
- "cloud-native/health-resilience-zero-downtime/**"
+ - "cloud-native/polly-resilience/**"
- ".github/workflows/build-samples.yml"
workflow_dispatch:
@@ -656,3 +658,38 @@ jobs:
cloud-native/health-resilience-zero-downtime/ResilientOrdersMinimal.slnx
--configuration Release
--no-build
+
+ test-polly-fallback-bulkhead:
+ name: Test Polly fallback and concurrency sample
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v5
+
+ - name: Install .NET 10 SDK
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Restore
+ run: >
+ dotnet restore
+ cloud-native/polly-resilience/PollyCatalogResilience.slnx
+
+ - name: Build
+ run: >
+ dotnet build
+ cloud-native/polly-resilience/PollyCatalogResilience.slnx
+ --configuration Release
+ --no-restore
+
+ - name: Test
+ run: >
+ dotnet test
+ cloud-native/polly-resilience/PollyCatalogResilience.slnx
+ --configuration Release
+ --no-build
diff --git a/README.md b/README.md
index 8a7034c..ba3cf29 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The
| [`blazor/ssr-interactive-islands`](blazor/ssr-interactive-islands/) | Focused .NET 10 catalog demonstrating static SSR, streaming review updates, a serializable render-mode boundary, an Interactive Server cart island, and component/integration testing | [Blazor SSR & Interactive Islands: Streaming Rendering, Auto Render Mode & Progressive Enhancement](https://www.dotnet-guide.com/tutorials/blazor/ssr-interactive-islands/) |
| [`cloud-native/dockerize-aspnet-core-clean-images`](cloud-native/dockerize-aspnet-core-clean-images/) | Focused .NET 10 container sample demonstrating a multi-stage Dockerfile, locked restore, runtime-only image, non-root execution, port 8080, runtime configuration, health checks, hardened Compose settings, and CI smoke testing | [Dockerizing ASP.NET Core: Multi-Stage Builds, Clean Images & a Production-Ready Ship Workflow](https://www.dotnet-guide.com/tutorials/cloud-native/dockerize-aspnet-core-clean-images/) |
| [`cloud-native/health-resilience-zero-downtime`](cloud-native/health-resilience-zero-downtime/) | Focused .NET 10 Orders API demonstrating tagged liveness/readiness checks, structured health output, shutdown readiness draining, typed HttpClient retries, circuit breaking, attempt timeouts, and deterministic integration testing | [.NET 8 Cloud-Native: Health Probes, Polly Resilience & Zero-Downtime Kubernetes Deployments](https://www.dotnet-guide.com/tutorials/cloud-native/health-resilience-zero-downtime/) |
+| [`cloud-native/polly-resilience`](cloud-native/polly-resilience/) | Focused .NET 10 Polly v8 catalog sample demonstrating explicit stale-cache fallback, timeout-driven degradation, outbound concurrency isolation, strategy event counters, and deterministic integration testing | [Polly Resilience (Polly v8): Timeouts, Retries, Circuits, Bulkheads, Hedging](https://www.dotnet-guide.com/tutorials/cloud-native/polly-resilience/) |
## Companion articles
- [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/)
@@ -224,27 +225,48 @@ tutorials/
| | `-- ContainerizedApiMinimal.Tests/
| | |-- ContainerizedApiMinimal.Tests.csproj
| | `-- ContainerizedApiTests.cs
-| `-- health-resilience-zero-downtime/
+| |-- health-resilience-zero-downtime/
+| | |-- README.md
+| | |-- ResilientOrdersMinimal.slnx
+| | |-- src/
+| | | `-- ResilientOrdersMinimal/
+| | | |-- ResilientOrdersMinimal.csproj
+| | | |-- Program.cs
+| | | |-- Health/
+| | | | |-- HealthResponseWriter.cs
+| | | | |-- TrafficReadinessHealthCheck.cs
+| | | | `-- TrafficReadinessState.cs
+| | | |-- Hosting/
+| | | | `-- ShutdownReadinessService.cs
+| | | `-- Payments/
+| | | |-- PaymentGatewayClient.cs
+| | | |-- PaymentSimulationState.cs
+| | | `-- SimulatedPaymentHandler.cs
+| | `-- tests/
+| | `-- ResilientOrdersMinimal.Tests/
+| | |-- ResilientOrdersMinimal.Tests.csproj
+| | `-- HealthAndResilienceTests.cs
+| `-- polly-resilience/
+| |-- PollyCatalogResilience.slnx
| |-- README.md
-| |-- ResilientOrdersMinimal.slnx
| |-- src/
-| | `-- ResilientOrdersMinimal/
-| | |-- ResilientOrdersMinimal.csproj
+| | `-- PollyCatalogResilience/
+| | |-- PollyCatalogResilience.csproj
| | |-- Program.cs
-| | |-- Health/
-| | | |-- HealthResponseWriter.cs
-| | | |-- TrafficReadinessHealthCheck.cs
-| | | `-- TrafficReadinessState.cs
-| | |-- Hosting/
-| | | `-- ShutdownReadinessService.cs
-| | `-- Payments/
-| | |-- PaymentGatewayClient.cs
-| | |-- PaymentSimulationState.cs
-| | `-- SimulatedPaymentHandler.cs
+| | |-- Models/
+| | | `-- CatalogModels.cs
+| | |-- Resilience/
+| | | |-- CatalogPipelineFactory.cs
+| | | |-- CatalogResilienceService.cs
+| | | `-- ResilienceTelemetry.cs
+| | `-- Services/
+| | |-- CatalogCache.cs
+| | |-- CatalogDependency.cs
+| | `-- CatalogHoldGate.cs
| `-- tests/
-| `-- ResilientOrdersMinimal.Tests/
-| |-- ResilientOrdersMinimal.Tests.csproj
-| `-- HealthAndResilienceTests.cs
+| `-- PollyCatalogResilience.Tests/
+| |-- PollyCatalogResilience.Tests.csproj
+| `-- CatalogResilienceTests.cs
|-- aspnet-core/
| |-- api-security-in-practice/
| | |-- ApiSecurityMinimal.slnx
diff --git a/cloud-native/polly-resilience/PollyCatalogResilience.slnx b/cloud-native/polly-resilience/PollyCatalogResilience.slnx
new file mode 100644
index 0000000..ad016f5
--- /dev/null
+++ b/cloud-native/polly-resilience/PollyCatalogResilience.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/cloud-native/polly-resilience/README.md b/cloud-native/polly-resilience/README.md
new file mode 100644
index 0000000..2bae520
--- /dev/null
+++ b/cloud-native/polly-resilience/README.md
@@ -0,0 +1,266 @@
+# Polly Fallback and Concurrency Isolation
+
+A focused .NET 10 companion demonstrating a generic Polly v8 pipeline with
+explicit stale-cache fallback, a bounded timeout, outbound concurrency
+isolation, strategy event counters, and deterministic integration tests.
+
+## Full tutorial
+
+[Polly Resilience (Polly v8): Timeouts, Retries, Circuits, Bulkheads, Hedging](https://www.dotnet-guide.com/tutorials/cloud-native/polly-resilience/)
+
+## Framework note
+
+The tutorial explains Polly v8 resilience patterns on .NET 8.
+
+This companion targets .NET 10 so it can use the DOTNET GUIDE repository's
+current SDK and CI workflow. The fallback, timeout, concurrency-limiter,
+cancellation, stale-cache, and integration-testing concepts demonstrated here
+are the same Polly v8 patterns.
+
+## Why this sample is narrow
+
+The repository already contains:
+
+```text
+cloud-native/health-resilience-zero-downtime/
+```
+
+That sample covers retries, attempt timeouts, circuit breaking, health checks,
+and shutdown readiness.
+
+This companion concentrates on different Polly strategies:
+
+- fallback;
+- graceful degradation;
+- concurrency isolation;
+- strategy callback visibility.
+
+## Packages
+
+```text
+Polly 8.7.0
+Polly.RateLimiting 8.7.0
+```
+
+The rate-limiter strategy is packaged separately from Polly's core strategies.
+
+## Pipeline order
+
+```text
+fallback
+ -> timeout
+ -> concurrency limiter
+ -> catalog dependency
+```
+
+Strategies execute in registration order from outermost to innermost.
+
+Fallback is outermost so it can replace:
+
+- dependency failures;
+- timeout rejections;
+- concurrency-limiter rejections.
+
+## Concurrency settings
+
+```text
+PermitLimit = 1
+QueueLimit = 0
+```
+
+Only one protected dependency operation can execute at a time.
+
+A second concurrent operation is rejected immediately and receives the stale
+fallback snapshot.
+
+This is a deliberately small teaching limit, not a production capacity
+recommendation.
+
+## Fallback transparency
+
+Fallback responses include:
+
+```text
+X-Resilience-Fallback: true
+X-Resilience-Reason: dependency-failure | timeout | bulkhead-rejected
+```
+
+The JSON payload also contains:
+
+```text
+source: stale-cache
+isStale: true
+degradedReason: ...
+```
+
+The sample does not disguise stale content as a fresh response.
+
+## Cancellation boundary
+
+The timeout strategy can stop the simulated slow dependency because it passes
+the strategy cancellation token to `Task.Delay`.
+
+Caller cancellation is not included in the fallback predicate and must remain a
+cancellation.
+
+The integration suite proves caller cancellation propagates without producing
+stale fallback data or incrementing resilience counters.
+
+## Deterministic simulation
+
+The dependency modes are:
+
+```text
+live
+failure
+slow
+hold
+```
+
+`hold` exists for the concurrency integration test.
+
+The dependency performs no network request.
+
+## Prerequisite
+
+- .NET 10 SDK
+
+## Restore, build, and test
+
+```powershell
+dotnet restore `
+ .\PollyCatalogResilience.slnx
+
+dotnet build `
+ .\PollyCatalogResilience.slnx `
+ --configuration Release `
+ --no-restore
+
+dotnet test `
+ .\PollyCatalogResilience.slnx `
+ --configuration Release `
+ --no-build
+```
+
+## Run
+
+```powershell
+dotnet run `
+ --project .\src\PollyCatalogResilience\PollyCatalogResilience.csproj `
+ --urls http://localhost:5160
+```
+
+## Live response
+
+```powershell
+Invoke-RestMethod `
+ -Uri "http://localhost:5160/api/catalog"
+```
+
+## Dependency-failure fallback
+
+```powershell
+$response = Invoke-WebRequest `
+ -Uri "http://localhost:5160/api/catalog?mode=failure"
+
+$response.Headers
+$response.Content | ConvertFrom-Json
+```
+
+## Timeout fallback
+
+```powershell
+$response = Invoke-WebRequest `
+ -Uri "http://localhost:5160/api/catalog?mode=slow&delayMilliseconds=1000"
+
+$response.Headers
+$response.Content | ConvertFrom-Json
+```
+
+## Strategy counters
+
+```powershell
+Invoke-RestMethod `
+ -Uri "http://localhost:5160/resilience/status"
+```
+
+## Cache boundary
+
+The stale snapshot is fixed demonstration data.
+
+A production cache requires decisions about:
+
+- freshness limits;
+- invalidation;
+- refresh;
+- distributed consistency;
+- tenant isolation;
+- authorization;
+- observability.
+
+## Telemetry boundary
+
+The in-memory counters make Polly callbacks visible to the sample tests.
+
+They are not an observability backend.
+
+Use a reviewed telemetry pipeline for production metrics and traces.
+
+## Project structure
+
+```text
+PollyCatalogResilience.slnx
+README.md
+src/
+`-- PollyCatalogResilience/
+ |-- PollyCatalogResilience.csproj
+ |-- Program.cs
+ |-- Models/
+ | `-- CatalogModels.cs
+ |-- Resilience/
+ | |-- CatalogPipelineFactory.cs
+ | |-- CatalogResilienceService.cs
+ | `-- ResilienceTelemetry.cs
+ `-- Services/
+ |-- CatalogCache.cs
+ |-- CatalogDependency.cs
+ `-- CatalogHoldGate.cs
+tests/
+`-- PollyCatalogResilience.Tests/
+ |-- PollyCatalogResilience.Tests.csproj
+ `-- CatalogResilienceTests.cs
+```
+
+## Deliberately omitted
+
+- retries;
+- circuit breakers;
+- hedging;
+- external HTTP calls;
+- distributed caching;
+- OpenTelemetry;
+- chaos testing;
+- load testing;
+- Docker;
+- Kubernetes;
+- production tuning.
+
+These remain in the full tutorial or other focused companions.
+
+## Verification
+
+- Companion target framework: .NET 10
+- Tutorial framework: .NET 8
+- Polly: 8.7.0
+- Polly.RateLimiting: 8.7.0
+- Timeout: 500 milliseconds
+- Permit limit: 1
+- Queue limit: 0
+- External services required: none
+- Database required: none
+- Container runtime required: none
+- Expected tests: 6
+- Last reviewed: 2026-08-04
+
+This sample is educational. Production limits and fallback rules must be based
+on measured capacity, data freshness requirements, and dependency contracts.
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Models/CatalogModels.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Models/CatalogModels.cs
new file mode 100644
index 0000000..08dcbd5
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Models/CatalogModels.cs
@@ -0,0 +1,27 @@
+namespace PollyCatalogResilience.Models;
+
+public enum CatalogSimulationMode
+{
+ Live,
+ Failure,
+ Slow,
+ Hold
+}
+
+public sealed record CatalogProduct(
+ int Id,
+ string Name,
+ decimal Price);
+
+public sealed record CatalogSnapshot(
+ DateTimeOffset GeneratedAtUtc,
+ string Source,
+ bool IsStale,
+ string? DegradedReason,
+ CatalogProduct[] Products);
+
+public sealed record ResilienceStatus(
+ long Fallbacks,
+ long Timeouts,
+ long Rejections,
+ string LastFallbackReason);
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/PollyCatalogResilience.csproj b/cloud-native/polly-resilience/src/PollyCatalogResilience/PollyCatalogResilience.csproj
new file mode 100644
index 0000000..f7637b7
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/PollyCatalogResilience.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Program.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Program.cs
new file mode 100644
index 0000000..151644e
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Program.cs
@@ -0,0 +1,188 @@
+using Polly;
+using PollyCatalogResilience.Models;
+using PollyCatalogResilience.Resilience;
+using PollyCatalogResilience.Services;
+
+var builder =
+ WebApplication.CreateBuilder(args);
+
+builder.Services.AddSingleton<
+ CatalogCache>();
+
+builder.Services.AddSingleton<
+ CatalogHoldGate>();
+
+builder.Services.AddSingleton<
+ CatalogDependency>();
+
+builder.Services.AddSingleton<
+ ResilienceTelemetry>();
+
+builder.Services.AddSingleton<
+ CatalogPipelineFactory>();
+
+builder.Services.AddSingleton(
+ services =>
+ services
+ .GetRequiredService<
+ CatalogPipelineFactory>()
+ .Create());
+
+builder.Services.AddSingleton<
+ CatalogResilienceService>();
+
+var app =
+ builder.Build();
+
+app.MapGet(
+ "/",
+ () =>
+ TypedResults.Ok(
+ new
+ {
+ name =
+ "PollyCatalogResilience",
+
+ pipeline =
+ new[]
+ {
+ "fallback",
+ "timeout",
+ "concurrency-limiter"
+ },
+
+ endpoints =
+ new[]
+ {
+ "GET /api/catalog",
+ "GET /resilience/status"
+ },
+
+ note =
+ "The catalog dependency and stale cache are deterministic in-process teaching components."
+ }));
+
+app.MapGet(
+ "/api/catalog",
+ GetCatalogAsync);
+
+app.MapGet(
+ "/resilience/status",
+ (
+ ResilienceTelemetry telemetry) =>
+ TypedResults.Ok(
+ telemetry.Snapshot()));
+
+app.Run();
+
+static async Task
+ GetCatalogAsync(
+ string? mode,
+ int? delayMilliseconds,
+ HttpContext httpContext,
+ CatalogResilienceService service,
+ CancellationToken cancellationToken)
+{
+ if (!TryParseMode(
+ mode,
+ out CatalogSimulationMode
+ simulationMode))
+ {
+ return Results.ValidationProblem(
+ new Dictionary<
+ string,
+ string[]>
+ {
+ [nameof(mode)] =
+ [
+ "Use live, failure, slow, or hold."
+ ]
+ });
+ }
+
+ int delay =
+ delayMilliseconds
+ ?? (
+ simulationMode
+ == CatalogSimulationMode.Slow
+ ? 1_000
+ : 0);
+
+ if (delay is < 0 or > 5_000)
+ {
+ return Results.ValidationProblem(
+ new Dictionary<
+ string,
+ string[]>
+ {
+ [nameof(
+ delayMilliseconds)] =
+ [
+ "Use a value from 0 through 5000."
+ ]
+ });
+ }
+
+ CatalogSnapshot snapshot =
+ await service.GetSnapshotAsync(
+ simulationMode,
+ delay,
+ cancellationToken);
+
+ if (snapshot.IsStale)
+ {
+ httpContext.Response.Headers
+ .TryAdd(
+ "X-Resilience-Fallback",
+ "true");
+
+ httpContext.Response.Headers
+ .TryAdd(
+ "X-Resilience-Reason",
+ snapshot.DegradedReason);
+ }
+
+ return TypedResults.Ok(
+ snapshot);
+}
+
+static bool TryParseMode(
+ string? value,
+ out CatalogSimulationMode mode)
+{
+ string normalized =
+ string.IsNullOrWhiteSpace(
+ value)
+ ? "live"
+ : value.Trim()
+ .ToLowerInvariant();
+
+ mode =
+ normalized switch
+ {
+ "live" =>
+ CatalogSimulationMode.Live,
+
+ "failure" =>
+ CatalogSimulationMode.Failure,
+
+ "slow" =>
+ CatalogSimulationMode.Slow,
+
+ "hold" =>
+ CatalogSimulationMode.Hold,
+
+ _ =>
+ default
+ };
+
+ return normalized
+ is "live"
+ or "failure"
+ or "slow"
+ or "hold";
+}
+
+public partial class Program
+{
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/CatalogPipelineFactory.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/CatalogPipelineFactory.cs
new file mode 100644
index 0000000..bc45450
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/CatalogPipelineFactory.cs
@@ -0,0 +1,116 @@
+using System.Threading.RateLimiting;
+using Polly;
+using Polly.Fallback;
+using Polly.RateLimiting;
+using Polly.Timeout;
+using PollyCatalogResilience.Models;
+using PollyCatalogResilience.Services;
+
+namespace PollyCatalogResilience.Resilience;
+
+public sealed class CatalogPipelineFactory(
+ CatalogCache cache,
+ ResilienceTelemetry telemetry)
+{
+ public ResiliencePipeline
+ Create() =>
+ new ResiliencePipelineBuilder<
+ CatalogSnapshot>()
+ .AddFallback(
+ new FallbackStrategyOptions<
+ CatalogSnapshot>
+ {
+ ShouldHandle =
+ new PredicateBuilder<
+ CatalogSnapshot>()
+ .Handle<
+ HttpRequestException>()
+ .Handle<
+ TimeoutRejectedException>()
+ .Handle<
+ RateLimiterRejectedException>(),
+
+ FallbackAction =
+ arguments =>
+ Outcome
+ .FromResultAsValueTask(
+ cache.CreateFallback(
+ GetReason(
+ arguments
+ .Outcome
+ .Exception))),
+
+ OnFallback =
+ arguments =>
+ {
+ telemetry.RecordFallback(
+ GetReason(
+ arguments
+ .Outcome
+ .Exception));
+
+ return default;
+ }
+ })
+ .AddTimeout(
+ new TimeoutStrategyOptions
+ {
+ Timeout =
+ TimeSpan
+ .FromMilliseconds(
+ 500),
+
+ OnTimeout =
+ arguments =>
+ {
+ telemetry
+ .RecordTimeout();
+
+ return default;
+ }
+ })
+ .AddRateLimiter(
+ new RateLimiterStrategyOptions
+ {
+ DefaultRateLimiterOptions =
+ new ConcurrencyLimiterOptions
+ {
+ PermitLimit =
+ 1,
+
+ QueueLimit =
+ 0,
+
+ QueueProcessingOrder =
+ QueueProcessingOrder
+ .OldestFirst
+ },
+
+ OnRejected =
+ arguments =>
+ {
+ telemetry
+ .RecordRejection();
+
+ return default;
+ }
+ })
+ .Build();
+
+ private static string GetReason(
+ Exception? exception) =>
+ exception switch
+ {
+ TimeoutRejectedException =>
+ "timeout",
+
+ RateLimiterRejectedException =>
+ "bulkhead-rejected",
+
+ HttpRequestException =>
+ "dependency-failure",
+
+ _ =>
+ "unknown"
+ };
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/CatalogResilienceService.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/CatalogResilienceService.cs
new file mode 100644
index 0000000..870ded9
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/CatalogResilienceService.cs
@@ -0,0 +1,45 @@
+using Polly;
+using PollyCatalogResilience.Models;
+using PollyCatalogResilience.Services;
+
+namespace PollyCatalogResilience.Resilience;
+
+public sealed class CatalogResilienceService(
+ ResiliencePipeline<
+ CatalogSnapshot> pipeline,
+ CatalogDependency dependency)
+{
+ public ValueTask
+ GetSnapshotAsync(
+ CatalogSimulationMode mode,
+ int delayMilliseconds,
+ CancellationToken cancellationToken)
+ {
+ var state =
+ new CatalogExecutionState(
+ dependency,
+ mode,
+ delayMilliseconds);
+
+ return pipeline.ExecuteAsync(
+ static (
+ execution,
+ token) =>
+ execution
+ .Dependency
+ .GetSnapshotAsync(
+ execution.Mode,
+ execution
+ .DelayMilliseconds,
+ token),
+
+ state,
+ cancellationToken);
+ }
+
+ private readonly record struct
+ CatalogExecutionState(
+ CatalogDependency Dependency,
+ CatalogSimulationMode Mode,
+ int DelayMilliseconds);
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/ResilienceTelemetry.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/ResilienceTelemetry.cs
new file mode 100644
index 0000000..0d24c2d
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Resilience/ResilienceTelemetry.cs
@@ -0,0 +1,54 @@
+using PollyCatalogResilience.Models;
+
+namespace PollyCatalogResilience.Resilience;
+
+public sealed class ResilienceTelemetry
+{
+ private long _fallbacks;
+ private long _timeouts;
+ private long _rejections;
+
+ private string? _lastFallbackReason;
+
+ public void RecordFallback(
+ string reason)
+ {
+ Interlocked.Increment(
+ ref _fallbacks);
+
+ Volatile.Write(
+ ref _lastFallbackReason,
+ reason);
+ }
+
+ public void RecordTimeout()
+ {
+ Interlocked.Increment(
+ ref _timeouts);
+ }
+
+ public void RecordRejection()
+ {
+ Interlocked.Increment(
+ ref _rejections);
+ }
+
+ public ResilienceStatus Snapshot() =>
+ new(
+ Fallbacks:
+ Interlocked.Read(
+ ref _fallbacks),
+
+ Timeouts:
+ Interlocked.Read(
+ ref _timeouts),
+
+ Rejections:
+ Interlocked.Read(
+ ref _rejections),
+
+ LastFallbackReason:
+ Volatile.Read(
+ ref _lastFallbackReason)
+ ?? "none");
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogCache.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogCache.cs
new file mode 100644
index 0000000..5352ac7
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogCache.cs
@@ -0,0 +1,54 @@
+using PollyCatalogResilience.Models;
+
+namespace PollyCatalogResilience.Services;
+
+public sealed class CatalogCache
+{
+ private static readonly
+ CatalogSnapshot CachedSnapshot =
+ new(
+ GeneratedAtUtc:
+ new DateTimeOffset(
+ 2026,
+ 8,
+ 1,
+ 0,
+ 0,
+ 0,
+ TimeSpan.Zero),
+
+ Source:
+ "stale-cache",
+
+ IsStale:
+ true,
+
+ DegradedReason:
+ null,
+
+ Products:
+ [
+ new CatalogProduct(
+ 1,
+ "Mechanical Keyboard",
+ 89.00m),
+
+ new CatalogProduct(
+ 2,
+ "USB-C Dock",
+ 129.00m),
+
+ new CatalogProduct(
+ 3,
+ "Monitor Arm",
+ 79.00m)
+ ]);
+
+ public CatalogSnapshot CreateFallback(
+ string reason) =>
+ CachedSnapshot with
+ {
+ DegradedReason =
+ reason
+ };
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogDependency.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogDependency.cs
new file mode 100644
index 0000000..3576b2a
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogDependency.cs
@@ -0,0 +1,78 @@
+using PollyCatalogResilience.Models;
+
+namespace PollyCatalogResilience.Services;
+
+public sealed class CatalogDependency(
+ CatalogHoldGate holdGate)
+{
+ public async ValueTask
+ GetSnapshotAsync(
+ CatalogSimulationMode mode,
+ int delayMilliseconds,
+ CancellationToken cancellationToken)
+ {
+ switch (mode)
+ {
+ case CatalogSimulationMode.Live:
+ break;
+
+ case CatalogSimulationMode.Failure:
+ throw new HttpRequestException(
+ "The demonstration catalog dependency failed.");
+
+ case CatalogSimulationMode.Slow:
+ await Task.Delay(
+ delayMilliseconds,
+ cancellationToken);
+
+ break;
+
+ case CatalogSimulationMode.Hold:
+ await holdGate.WaitAsync(
+ cancellationToken);
+
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(
+ nameof(mode),
+ mode,
+ "Unknown simulation mode.");
+ }
+
+ return CreateLiveSnapshot();
+ }
+
+ private static CatalogSnapshot
+ CreateLiveSnapshot() =>
+ new(
+ GeneratedAtUtc:
+ DateTimeOffset.UtcNow,
+
+ Source:
+ "live-dependency",
+
+ IsStale:
+ false,
+
+ DegradedReason:
+ null,
+
+ Products:
+ [
+ new CatalogProduct(
+ 1,
+ "Mechanical Keyboard",
+ 85.00m),
+
+ new CatalogProduct(
+ 2,
+ "USB-C Dock",
+ 125.00m),
+
+ new CatalogProduct(
+ 3,
+ "Monitor Arm",
+ 75.00m)
+ ]);
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogHoldGate.cs b/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogHoldGate.cs
new file mode 100644
index 0000000..d7b45a3
--- /dev/null
+++ b/cloud-native/polly-resilience/src/PollyCatalogResilience/Services/CatalogHoldGate.cs
@@ -0,0 +1,38 @@
+namespace PollyCatalogResilience.Services;
+
+public sealed class CatalogHoldGate
+{
+ private readonly
+ TaskCompletionSource
+ _entered =
+ new(
+ TaskCreationOptions
+ .RunContinuationsAsynchronously);
+
+ private readonly
+ TaskCompletionSource
+ _released =
+ new(
+ TaskCreationOptions
+ .RunContinuationsAsynchronously);
+
+ public Task Entered =>
+ _entered.Task;
+
+ public async ValueTask WaitAsync(
+ CancellationToken cancellationToken)
+ {
+ _entered.TrySetResult(
+ true);
+
+ await _released.Task
+ .WaitAsync(
+ cancellationToken);
+ }
+
+ public void Release()
+ {
+ _released.TrySetResult(
+ true);
+ }
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/tests/PollyCatalogResilience.Tests/CatalogResilienceTests.cs b/cloud-native/polly-resilience/tests/PollyCatalogResilience.Tests/CatalogResilienceTests.cs
new file mode 100644
index 0000000..b365971
--- /dev/null
+++ b/cloud-native/polly-resilience/tests/PollyCatalogResilience.Tests/CatalogResilienceTests.cs
@@ -0,0 +1,553 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using PollyCatalogResilience.Models;
+using PollyCatalogResilience.Resilience;
+using PollyCatalogResilience.Services;
+
+namespace PollyCatalogResilience.Tests;
+
+public sealed class CatalogResilienceTests
+{
+ [Fact]
+ public async Task
+ Root_describes_the_pipeline()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ string body =
+ await client.GetStringAsync(
+ "/",
+ ct);
+
+ Assert.Contains(
+ "PollyCatalogResilience",
+ body,
+ StringComparison.Ordinal);
+
+ Assert.Contains(
+ "concurrency-limiter",
+ body,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task
+ Live_catalog_returns_fresh_data()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ HttpResponseMessage response =
+ await client.GetAsync(
+ "/api/catalog",
+ ct);
+
+ CatalogSnapshot? snapshot =
+ await response.Content
+ .ReadFromJsonAsync<
+ CatalogSnapshot>(
+ cancellationToken:
+ ct);
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ response.StatusCode);
+
+ Assert.NotNull(
+ snapshot);
+
+ Assert.False(
+ snapshot.IsStale);
+
+ Assert.Equal(
+ "live-dependency",
+ snapshot.Source);
+
+ Assert.Equal(
+ 3,
+ snapshot.Products.Length);
+
+ Assert.False(
+ response.Headers.Contains(
+ "X-Resilience-Fallback"));
+
+ Assert.False(
+ response.Headers.Contains(
+ "X-Resilience-Reason"));
+
+ ResilienceStatus status =
+ (await client.GetFromJsonAsync<
+ ResilienceStatus>(
+ "/resilience/status",
+ cancellationToken:
+ ct))!;
+
+ Assert.NotNull(
+ status);
+
+ Assert.Equal(
+ 0,
+ status.Fallbacks);
+
+ Assert.Equal(
+ 0,
+ status.Timeouts);
+
+ Assert.Equal(
+ 0,
+ status.Rejections);
+
+ Assert.Equal(
+ "none",
+ status.LastFallbackReason);
+ }
+
+ [Fact]
+ public async Task
+ Dependency_failure_uses_marked_stale_fallback()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ HttpResponseMessage response =
+ await client.GetAsync(
+ "/api/catalog?mode=failure",
+ ct);
+
+ CatalogSnapshot? snapshot =
+ await response.Content
+ .ReadFromJsonAsync<
+ CatalogSnapshot>(
+ cancellationToken:
+ ct);
+
+ ResilienceStatus status =
+ factory.Services
+ .GetRequiredService<
+ ResilienceTelemetry>()
+ .Snapshot();
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ response.StatusCode);
+
+ Assert.NotNull(
+ snapshot);
+
+ Assert.True(
+ snapshot.IsStale);
+
+ Assert.Equal(
+ "dependency-failure",
+ snapshot.DegradedReason);
+
+ Assert.Equal(
+ "true",
+ response.Headers
+ .GetValues(
+ "X-Resilience-Fallback")
+ .Single());
+
+ Assert.Equal(
+ "dependency-failure",
+ response.Headers
+ .GetValues(
+ "X-Resilience-Reason")
+ .Single());
+
+ Assert.Equal(
+ 1,
+ status.Fallbacks);
+
+ Assert.Equal(
+ 0,
+ status.Timeouts);
+
+ Assert.Equal(
+ 0,
+ status.Rejections);
+ }
+
+ [Fact]
+ public async Task
+ Slow_dependency_times_out_and_uses_fallback()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ HttpResponseMessage response =
+ await client.GetAsync(
+ "/api/catalog?mode=slow&delayMilliseconds=1000",
+ ct);
+
+ CatalogSnapshot? snapshot =
+ await response.Content
+ .ReadFromJsonAsync<
+ CatalogSnapshot>(
+ cancellationToken:
+ ct);
+
+ ResilienceStatus status =
+ factory.Services
+ .GetRequiredService<
+ ResilienceTelemetry>()
+ .Snapshot();
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ response.StatusCode);
+
+ Assert.NotNull(
+ snapshot);
+
+ Assert.True(
+ snapshot.IsStale);
+
+ Assert.Equal(
+ "stale-cache",
+ snapshot.Source);
+
+ Assert.Equal(
+ "timeout",
+ snapshot.DegradedReason);
+
+ Assert.Equal(
+ "true",
+ response.Headers
+ .GetValues(
+ "X-Resilience-Fallback")
+ .Single());
+
+ Assert.Equal(
+ "timeout",
+ response.Headers
+ .GetValues(
+ "X-Resilience-Reason")
+ .Single());
+
+ Assert.Equal(
+ 1,
+ status.Timeouts);
+
+ Assert.Equal(
+ 1,
+ status.Fallbacks);
+
+ Assert.Equal(
+ 0,
+ status.Rejections);
+
+ Assert.Equal(
+ "timeout",
+ status.LastFallbackReason);
+
+ ResilienceStatus publicStatus =
+ (await client.GetFromJsonAsync<
+ ResilienceStatus>(
+ "/resilience/status",
+ cancellationToken:
+ ct))!;
+
+ Assert.NotNull(
+ publicStatus);
+
+ Assert.Equal(
+ status.Fallbacks,
+ publicStatus.Fallbacks);
+
+ Assert.Equal(
+ status.Timeouts,
+ publicStatus.Timeouts);
+
+ Assert.Equal(
+ status.Rejections,
+ publicStatus.Rejections);
+
+ Assert.Equal(
+ status.LastFallbackReason,
+ publicStatus.LastFallbackReason);
+ }
+
+ [Fact]
+ public async Task
+ Concurrent_operation_is_rejected_and_falls_back()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CatalogHoldGate gate =
+ factory.Services
+ .GetRequiredService<
+ CatalogHoldGate>();
+
+ ResilienceTelemetry telemetry =
+ factory.Services
+ .GetRequiredService<
+ ResilienceTelemetry>();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ Task
+ heldRequest =
+ client.GetAsync(
+ "/api/catalog?mode=hold",
+ ct);
+
+ try
+ {
+ await gate.Entered
+ .WaitAsync(
+ TimeSpan.FromSeconds(
+ 5),
+ ct);
+
+ HttpResponseMessage rejected =
+ await client.GetAsync(
+ "/api/catalog?mode=live",
+ ct);
+
+ Assert.Equal(
+ "true",
+ rejected.Headers
+ .GetValues(
+ "X-Resilience-Fallback")
+ .Single());
+
+ Assert.Equal(
+ "bulkhead-rejected",
+ rejected.Headers
+ .GetValues(
+ "X-Resilience-Reason")
+ .Single());
+
+ CatalogSnapshot? fallback =
+ await rejected.Content
+ .ReadFromJsonAsync<
+ CatalogSnapshot>(
+ cancellationToken:
+ ct);
+
+ gate.Release();
+
+ HttpResponseMessage accepted =
+ await heldRequest;
+
+ CatalogSnapshot? live =
+ await accepted.Content
+ .ReadFromJsonAsync<
+ CatalogSnapshot>(
+ cancellationToken:
+ ct);
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ rejected.StatusCode);
+
+ Assert.NotNull(
+ fallback);
+
+ Assert.True(
+ fallback.IsStale);
+
+ Assert.Equal(
+ "stale-cache",
+ fallback.Source);
+
+ Assert.Equal(
+ "bulkhead-rejected",
+ fallback.DegradedReason);
+
+ Assert.Equal(
+ HttpStatusCode.OK,
+ accepted.StatusCode);
+
+ Assert.NotNull(
+ live);
+
+ Assert.Equal(
+ "live-dependency",
+ live.Source);
+
+ Assert.False(
+ live.IsStale);
+
+ Assert.False(
+ accepted.Headers.Contains(
+ "X-Resilience-Fallback"));
+
+ ResilienceStatus status =
+ (await client.GetFromJsonAsync<
+ ResilienceStatus>(
+ "/resilience/status",
+ cancellationToken:
+ ct))!;
+
+ Assert.NotNull(
+ status);
+
+ Assert.Equal(
+ 1,
+ status.Fallbacks);
+
+ Assert.Equal(
+ 1,
+ status.Rejections);
+
+ Assert.Equal(
+ 0,
+ status.Timeouts);
+
+ Assert.Equal(
+ "bulkhead-rejected",
+ status.LastFallbackReason);
+
+ ResilienceStatus inMemory =
+ telemetry.Snapshot();
+
+ Assert.Equal(
+ status.Fallbacks,
+ inMemory.Fallbacks);
+
+ Assert.Equal(
+ status.Timeouts,
+ inMemory.Timeouts);
+
+ Assert.Equal(
+ status.Rejections,
+ inMemory.Rejections);
+
+ Assert.Equal(
+ status.LastFallbackReason,
+ inMemory.LastFallbackReason);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ [Fact]
+ public async Task
+ Invalid_mode_and_delay_return_validation_errors()
+ {
+ using var factory =
+ new WebApplicationFactory<
+ Program>();
+
+ using HttpClient client =
+ factory.CreateClient();
+
+ CancellationToken ct =
+ TestContext.Current
+ .CancellationToken;
+
+ HttpResponseMessage mode =
+ await client.GetAsync(
+ "/api/catalog?mode=unknown",
+ ct);
+
+ HttpResponseMessage delay =
+ await client.GetAsync(
+ "/api/catalog?mode=slow&delayMilliseconds=6000",
+ ct);
+
+ Assert.Equal(
+ HttpStatusCode.BadRequest,
+ mode.StatusCode);
+
+ Assert.Equal(
+ HttpStatusCode.BadRequest,
+ delay.StatusCode);
+
+ ResilienceTelemetry telemetry =
+ factory.Services
+ .GetRequiredService<
+ ResilienceTelemetry>();
+
+ ResilienceStatus beforeCancellation =
+ telemetry.Snapshot();
+
+ using var cts =
+ new CancellationTokenSource();
+
+ cts.Cancel();
+
+ CatalogResilienceService service =
+ factory.Services
+ .GetRequiredService<
+ CatalogResilienceService>();
+
+ await Assert.ThrowsAsync<
+ OperationCanceledException>(
+ async () =>
+ await service
+ .GetSnapshotAsync(
+ CatalogSimulationMode
+ .Slow,
+ 1000,
+ cts.Token)
+ .AsTask());
+
+ ResilienceStatus afterCancellation =
+ telemetry.Snapshot();
+
+ Assert.Equal(
+ beforeCancellation.Fallbacks,
+ afterCancellation.Fallbacks);
+
+ Assert.Equal(
+ beforeCancellation.Timeouts,
+ afterCancellation.Timeouts);
+
+ Assert.Equal(
+ beforeCancellation.Rejections,
+ afterCancellation.Rejections);
+
+ Assert.Equal(
+ beforeCancellation
+ .LastFallbackReason,
+ afterCancellation
+ .LastFallbackReason);
+ }
+}
\ No newline at end of file
diff --git a/cloud-native/polly-resilience/tests/PollyCatalogResilience.Tests/PollyCatalogResilience.Tests.csproj b/cloud-native/polly-resilience/tests/PollyCatalogResilience.Tests/PollyCatalogResilience.Tests.csproj
new file mode 100644
index 0000000..3f9233b
--- /dev/null
+++ b/cloud-native/polly-resilience/tests/PollyCatalogResilience.Tests/PollyCatalogResilience.Tests.csproj
@@ -0,0 +1,49 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ Exe
+
+
+
+
+
+
+
+
+
+
+ all
+
+ runtime;
+ build;
+ native;
+ contentfiles;
+ analyzers;
+ buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file