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