InProcess Chaos -- A Complete Walkthrough
No theory. No architecture diagrams. No philosophical justifications. This is a step-by-step tutorial. You will start with an empty project and end with a chaos experiment that proves your circuit breaker works under fault conditions. The entire thing runs inside dotnet test.
Step 1: Create the Project
Create a new xUnit test project and add the Ops.Chaos.Lib package:
dotnet new xunit -n OrderService.Chaos.Tests
cd OrderService.Chaos.Tests
dotnet add package Ops.Chaos.Lib
dotnet add package FrenchExDev.Net.Injectable
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Polly
dotnet add package Polly.Extensions.Httpdotnet new xunit -n OrderService.Chaos.Tests
cd OrderService.Chaos.Tests
dotnet add package Ops.Chaos.Lib
dotnet add package FrenchExDev.Net.Injectable
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Polly
dotnet add package Polly.Extensions.HttpThe .csproj should look like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ops.Chaos.Lib" Version="1.0.0" />
<PackageReference Include="FrenchExDev.Net.Injectable" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Polly" Version="8.3.0" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
</ItemGroup>
</Project><Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ops.Chaos.Lib" Version="1.0.0" />
<PackageReference Include="FrenchExDev.Net.Injectable" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Polly" Version="8.3.0" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
</ItemGroup>
</Project>Now define the service interface and its real implementation. This is the production code that the chaos experiment targets.
// IPaymentGateway.cs
using FrenchExDev.Net.Injectable;
[Injectable(Lifetime = ServiceLifetime.Scoped)]
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(string orderId, decimal amount, CancellationToken ct);
Task<PaymentResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct);
Task<PaymentStatus> GetStatusAsync(string transactionId, CancellationToken ct);
}
public record PaymentResult(bool Success, string TransactionId, string? ErrorMessage = null);
public record PaymentStatus(string TransactionId, string State, decimal Amount);// IPaymentGateway.cs
using FrenchExDev.Net.Injectable;
[Injectable(Lifetime = ServiceLifetime.Scoped)]
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(string orderId, decimal amount, CancellationToken ct);
Task<PaymentResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct);
Task<PaymentStatus> GetStatusAsync(string transactionId, CancellationToken ct);
}
public record PaymentResult(bool Success, string TransactionId, string? ErrorMessage = null);
public record PaymentStatus(string TransactionId, string State, decimal Amount);// HttpPaymentGateway.cs
public class HttpPaymentGateway : IPaymentGateway
{
private readonly HttpClient _http;
public HttpPaymentGateway(HttpClient http)
{
_http = http;
}
public async Task<PaymentResult> ChargeAsync(string orderId, decimal amount, CancellationToken ct)
{
var response = await _http.PostAsJsonAsync("/payments/charge",
new { orderId, amount }, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<PaymentResult>(ct);
return body!;
}
public async Task<PaymentResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct)
{
var response = await _http.PostAsJsonAsync("/payments/refund",
new { transactionId, amount }, ct);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<PaymentResult>(ct))!;
}
public async Task<PaymentStatus> GetStatusAsync(string transactionId, CancellationToken ct)
{
var response = await _http.GetAsync($"/payments/{transactionId}/status", ct);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<PaymentStatus>(ct))!;
}
}// HttpPaymentGateway.cs
public class HttpPaymentGateway : IPaymentGateway
{
private readonly HttpClient _http;
public HttpPaymentGateway(HttpClient http)
{
_http = http;
}
public async Task<PaymentResult> ChargeAsync(string orderId, decimal amount, CancellationToken ct)
{
var response = await _http.PostAsJsonAsync("/payments/charge",
new { orderId, amount }, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<PaymentResult>(ct);
return body!;
}
public async Task<PaymentResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct)
{
var response = await _http.PostAsJsonAsync("/payments/refund",
new { transactionId, amount }, ct);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<PaymentResult>(ct))!;
}
public async Task<PaymentStatus> GetStatusAsync(string transactionId, CancellationToken ct)
{
var response = await _http.GetAsync($"/payments/{transactionId}/status", ct);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<PaymentStatus>(ct))!;
}
}Nothing unusual. A standard service with three methods behind an [Injectable] interface. The source generator from the Injectable framework produces the DI registration. Now we break it on purpose.
Step 2: Declare the Chaos Experiment
Create a single file that encodes the entire experiment as attributes:
// PaymentTimeoutChaos.cs
using Ops.Chaos;
[ChaosExperiment("PaymentTimeout", Tier = OpsExecutionTier.InProcess,
Hypothesis = "Order placement degrades gracefully when payment times out")]
[TargetService(typeof(IPaymentGateway))]
[FaultInjection(FaultKind.Timeout, Probability = 0.5, Delay = "5s")]
[FaultInjection(FaultKind.Exception, Probability = 0.2,
ExceptionType = typeof(HttpRequestException))]
[SteadyStateProbe(Metric = "order.placement.success_rate", Expected = "> 80%")]
[AbortCondition(Metric = "order.placement.error_rate", Threshold = "50%")]
public partial class PaymentTimeoutChaos { }// PaymentTimeoutChaos.cs
using Ops.Chaos;
[ChaosExperiment("PaymentTimeout", Tier = OpsExecutionTier.InProcess,
Hypothesis = "Order placement degrades gracefully when payment times out")]
[TargetService(typeof(IPaymentGateway))]
[FaultInjection(FaultKind.Timeout, Probability = 0.5, Delay = "5s")]
[FaultInjection(FaultKind.Exception, Probability = 0.2,
ExceptionType = typeof(HttpRequestException))]
[SteadyStateProbe(Metric = "order.placement.success_rate", Expected = "> 80%")]
[AbortCondition(Metric = "order.placement.error_rate", Threshold = "50%")]
public partial class PaymentTimeoutChaos { }Six attributes. That is the entire experiment definition. Here is what each one means in concrete terms:
[ChaosExperiment]-- names the experiment and sets the tier. InProcess means no Docker, no infrastructure. The generator will produce a DI decorator.[TargetService(typeof(IPaymentGateway))]-- tells the generator which interface to decorate. The generated decorator will implementIPaymentGateway.[FaultInjection(FaultKind.Timeout, ...)]-- 50% of calls will experience a 5-second delay. This simulates a slow upstream payment provider.[FaultInjection(FaultKind.Exception, ...)]-- 20% of calls will throwHttpRequestException. This simulates connection failures.[SteadyStateProbe]-- the experiment succeeds only if at least 80% of order placements still succeed (via fallback, retry, or circuit breaker).[AbortCondition]-- if the error rate exceeds 50%, stop the experiment immediately. The system is failing worse than expected.
The remaining 30% of calls pass through unmodified to the real implementation. The probabilities are evaluated per-call using Random.Shared.
Step 3: What the Source Generator Produces
Build the project. The Ops.Chaos source generator reads the attributes and produces two files.
Generated File 1: PaymentGatewayChaosDecorator.g.cs
// <auto-generated by Ops.Chaos.Generator />
using System.Diagnostics.Metrics;
using FrenchExDev.Net.Injectable;
using Microsoft.Extensions.Logging;
namespace OrderService.Chaos;
[InjectableDecorator(typeof(IPaymentGateway))]
public sealed class PaymentGatewayChaosDecorator : IPaymentGateway
{
private readonly IPaymentGateway _inner;
private readonly IChaosConfiguration _config;
private readonly ILogger<PaymentGatewayChaosDecorator> _logger;
private readonly Counter<long> _successCounter;
private readonly Counter<long> _errorCounter;
private readonly Counter<long> _timeoutCounter;
private readonly Counter<long> _exceptionCounter;
private long _totalCalls;
private long _successCalls;
private long _errorCalls;
public PaymentGatewayChaosDecorator(
IPaymentGateway inner,
IChaosConfiguration config,
IMeterFactory meterFactory,
ILogger<PaymentGatewayChaosDecorator> logger)
{
_inner = inner;
_config = config;
_logger = logger;
var meter = meterFactory.Create("Ops.Chaos.PaymentTimeout");
_successCounter = meter.CreateCounter<long>("order.placement.success_rate");
_errorCounter = meter.CreateCounter<long>("order.placement.error_rate");
_timeoutCounter = meter.CreateCounter<long>("chaos.fault.timeout.injected");
_exceptionCounter = meter.CreateCounter<long>("chaos.fault.exception.injected");
}
public async Task<PaymentResult> ChargeAsync(
string orderId, decimal amount, CancellationToken ct)
{
Interlocked.Increment(ref _totalCalls);
if (_config.IsEnabled)
{
var roll = Random.Shared.NextDouble();
// Timeout fault: 50% probability
if (roll < 0.5)
{
_timeoutCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting 5000ms timeout on ChargeAsync " +
"for order {OrderId}", orderId);
await Task.Delay(TimeSpan.FromMilliseconds(5000), ct);
throw new TimeoutException(
"[Chaos:PaymentTimeout] Simulated timeout on IPaymentGateway.ChargeAsync");
}
// Exception fault: 20% probability (roll 0.5..0.7)
if (roll < 0.7)
{
_exceptionCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting HttpRequestException on ChargeAsync " +
"for order {OrderId}", orderId);
throw new HttpRequestException(
"[Chaos:PaymentTimeout] Simulated exception on IPaymentGateway.ChargeAsync");
}
// Remaining 30%: pass through
}
try
{
var result = await _inner.ChargeAsync(orderId, amount, ct);
Interlocked.Increment(ref _successCalls);
_successCounter.Add(1);
return result;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCalls);
_errorCounter.Add(1);
_logger.LogError(ex,
"[Chaos:PaymentTimeout] Inner call failed on ChargeAsync for order {OrderId}",
orderId);
throw;
}
}
public async Task<PaymentResult> RefundAsync(
string transactionId, decimal amount, CancellationToken ct)
{
Interlocked.Increment(ref _totalCalls);
if (_config.IsEnabled)
{
var roll = Random.Shared.NextDouble();
if (roll < 0.5)
{
_timeoutCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting 5000ms timeout on RefundAsync");
await Task.Delay(TimeSpan.FromMilliseconds(5000), ct);
throw new TimeoutException(
"[Chaos:PaymentTimeout] Simulated timeout on IPaymentGateway.RefundAsync");
}
if (roll < 0.7)
{
_exceptionCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting HttpRequestException on RefundAsync");
throw new HttpRequestException(
"[Chaos:PaymentTimeout] Simulated exception on IPaymentGateway.RefundAsync");
}
}
try
{
var result = await _inner.RefundAsync(transactionId, amount, ct);
Interlocked.Increment(ref _successCalls);
_successCounter.Add(1);
return result;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCalls);
_errorCounter.Add(1);
throw;
}
}
public async Task<PaymentStatus> GetStatusAsync(
string transactionId, CancellationToken ct)
{
Interlocked.Increment(ref _totalCalls);
if (_config.IsEnabled)
{
var roll = Random.Shared.NextDouble();
if (roll < 0.5)
{
_timeoutCounter.Add(1);
await Task.Delay(TimeSpan.FromMilliseconds(5000), ct);
throw new TimeoutException(
"[Chaos:PaymentTimeout] Simulated timeout on IPaymentGateway.GetStatusAsync");
}
if (roll < 0.7)
{
_exceptionCounter.Add(1);
throw new HttpRequestException(
"[Chaos:PaymentTimeout] Simulated exception on IPaymentGateway.GetStatusAsync");
}
}
try
{
var result = await _inner.GetStatusAsync(transactionId, ct);
Interlocked.Increment(ref _successCalls);
_successCounter.Add(1);
return result;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCalls);
_errorCounter.Add(1);
throw;
}
}
/// <summary>
/// Returns the current success rate as a percentage.
/// Used by the steady-state probe and abort condition evaluator.
/// </summary>
public double GetSuccessRate() =>
_totalCalls == 0 ? 100.0 : (double)_successCalls / _totalCalls * 100.0;
/// <summary>
/// Returns the current error rate as a percentage.
/// Used by the abort condition evaluator.
/// </summary>
public double GetErrorRate() =>
_totalCalls == 0 ? 0.0 : (double)_errorCalls / _totalCalls * 100.0;
}// <auto-generated by Ops.Chaos.Generator />
using System.Diagnostics.Metrics;
using FrenchExDev.Net.Injectable;
using Microsoft.Extensions.Logging;
namespace OrderService.Chaos;
[InjectableDecorator(typeof(IPaymentGateway))]
public sealed class PaymentGatewayChaosDecorator : IPaymentGateway
{
private readonly IPaymentGateway _inner;
private readonly IChaosConfiguration _config;
private readonly ILogger<PaymentGatewayChaosDecorator> _logger;
private readonly Counter<long> _successCounter;
private readonly Counter<long> _errorCounter;
private readonly Counter<long> _timeoutCounter;
private readonly Counter<long> _exceptionCounter;
private long _totalCalls;
private long _successCalls;
private long _errorCalls;
public PaymentGatewayChaosDecorator(
IPaymentGateway inner,
IChaosConfiguration config,
IMeterFactory meterFactory,
ILogger<PaymentGatewayChaosDecorator> logger)
{
_inner = inner;
_config = config;
_logger = logger;
var meter = meterFactory.Create("Ops.Chaos.PaymentTimeout");
_successCounter = meter.CreateCounter<long>("order.placement.success_rate");
_errorCounter = meter.CreateCounter<long>("order.placement.error_rate");
_timeoutCounter = meter.CreateCounter<long>("chaos.fault.timeout.injected");
_exceptionCounter = meter.CreateCounter<long>("chaos.fault.exception.injected");
}
public async Task<PaymentResult> ChargeAsync(
string orderId, decimal amount, CancellationToken ct)
{
Interlocked.Increment(ref _totalCalls);
if (_config.IsEnabled)
{
var roll = Random.Shared.NextDouble();
// Timeout fault: 50% probability
if (roll < 0.5)
{
_timeoutCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting 5000ms timeout on ChargeAsync " +
"for order {OrderId}", orderId);
await Task.Delay(TimeSpan.FromMilliseconds(5000), ct);
throw new TimeoutException(
"[Chaos:PaymentTimeout] Simulated timeout on IPaymentGateway.ChargeAsync");
}
// Exception fault: 20% probability (roll 0.5..0.7)
if (roll < 0.7)
{
_exceptionCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting HttpRequestException on ChargeAsync " +
"for order {OrderId}", orderId);
throw new HttpRequestException(
"[Chaos:PaymentTimeout] Simulated exception on IPaymentGateway.ChargeAsync");
}
// Remaining 30%: pass through
}
try
{
var result = await _inner.ChargeAsync(orderId, amount, ct);
Interlocked.Increment(ref _successCalls);
_successCounter.Add(1);
return result;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCalls);
_errorCounter.Add(1);
_logger.LogError(ex,
"[Chaos:PaymentTimeout] Inner call failed on ChargeAsync for order {OrderId}",
orderId);
throw;
}
}
public async Task<PaymentResult> RefundAsync(
string transactionId, decimal amount, CancellationToken ct)
{
Interlocked.Increment(ref _totalCalls);
if (_config.IsEnabled)
{
var roll = Random.Shared.NextDouble();
if (roll < 0.5)
{
_timeoutCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting 5000ms timeout on RefundAsync");
await Task.Delay(TimeSpan.FromMilliseconds(5000), ct);
throw new TimeoutException(
"[Chaos:PaymentTimeout] Simulated timeout on IPaymentGateway.RefundAsync");
}
if (roll < 0.7)
{
_exceptionCounter.Add(1);
_logger.LogWarning(
"[Chaos:PaymentTimeout] Injecting HttpRequestException on RefundAsync");
throw new HttpRequestException(
"[Chaos:PaymentTimeout] Simulated exception on IPaymentGateway.RefundAsync");
}
}
try
{
var result = await _inner.RefundAsync(transactionId, amount, ct);
Interlocked.Increment(ref _successCalls);
_successCounter.Add(1);
return result;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCalls);
_errorCounter.Add(1);
throw;
}
}
public async Task<PaymentStatus> GetStatusAsync(
string transactionId, CancellationToken ct)
{
Interlocked.Increment(ref _totalCalls);
if (_config.IsEnabled)
{
var roll = Random.Shared.NextDouble();
if (roll < 0.5)
{
_timeoutCounter.Add(1);
await Task.Delay(TimeSpan.FromMilliseconds(5000), ct);
throw new TimeoutException(
"[Chaos:PaymentTimeout] Simulated timeout on IPaymentGateway.GetStatusAsync");
}
if (roll < 0.7)
{
_exceptionCounter.Add(1);
throw new HttpRequestException(
"[Chaos:PaymentTimeout] Simulated exception on IPaymentGateway.GetStatusAsync");
}
}
try
{
var result = await _inner.GetStatusAsync(transactionId, ct);
Interlocked.Increment(ref _successCalls);
_successCounter.Add(1);
return result;
}
catch (Exception ex)
{
Interlocked.Increment(ref _errorCalls);
_errorCounter.Add(1);
throw;
}
}
/// <summary>
/// Returns the current success rate as a percentage.
/// Used by the steady-state probe and abort condition evaluator.
/// </summary>
public double GetSuccessRate() =>
_totalCalls == 0 ? 100.0 : (double)_successCalls / _totalCalls * 100.0;
/// <summary>
/// Returns the current error rate as a percentage.
/// Used by the abort condition evaluator.
/// </summary>
public double GetErrorRate() =>
_totalCalls == 0 ? 0.0 : (double)_errorCalls / _totalCalls * 100.0;
}Every method has the same structure: check if chaos is enabled, roll the dice, inject the fault or pass through. The [InjectableDecorator] attribute tells the Injectable source generator to wrap the original IPaymentGateway registration with this decorator. No manual DI wiring needed.
Key design decisions in the generated code:
_config.IsEnabledguard. Production sets this tofalse. The decorator becomes a zero-overhead passthrough. No branching, no random number generation, no logging. The JIT can inline it.Random.Shared-- thread-safe, no allocation, no lock contention.Task.Delayfor timeout -- the cancellation token flows through. If the test times out, the delay is cancelled cleanly.- Atomic counters --
Interlocked.Incrementfor thread safety. The metrics counters also report toIMeterFactoryfor the observability pipeline. - Structured logging -- every injected fault is logged with the experiment name, method name, and relevant identifiers.
Generated File 2: ChaosRegistration.g.cs
// <auto-generated by Ops.Chaos.Generator />
using Microsoft.Extensions.DependencyInjection;
namespace OrderService.Chaos;
public static class PaymentTimeoutChaosRegistration
{
/// <summary>
/// Registers the PaymentTimeout chaos experiment.
/// Adds the decorator and chaos configuration to the DI container.
/// Call EnableChaos() on the returned configuration to activate fault injection.
/// </summary>
public static IServiceCollection AddPaymentTimeoutChaos(
this IServiceCollection services)
{
services.AddSingleton<IChaosConfiguration>(new ChaosConfiguration
{
ExperimentName = "PaymentTimeout",
IsEnabled = false // Safe default: disabled in production
});
// The InjectableDecorator attribute handles DI decoration.
// This method registers the configuration and wires the experiment metadata.
services.AddSingleton(new ChaosExperimentMetadata
{
Name = "PaymentTimeout",
Tier = OpsExecutionTier.InProcess,
Hypothesis = "Order placement degrades gracefully when payment times out",
TargetService = typeof(IPaymentGateway),
Faults = new[]
{
new FaultDescriptor(FaultKind.Timeout, 0.5, delay: TimeSpan.FromSeconds(5)),
new FaultDescriptor(FaultKind.Exception, 0.2,
exceptionType: typeof(HttpRequestException))
},
SteadyStateProbes = new[]
{
new SteadyStateProbeDescriptor("order.placement.success_rate", "> 80%")
},
AbortConditions = new[]
{
new AbortConditionDescriptor("order.placement.error_rate", "50%")
}
});
return services;
}
/// <summary>
/// Enables fault injection for the PaymentTimeout experiment.
/// Call this in test setup. Never call this in production startup.
/// </summary>
public static IServiceCollection EnablePaymentTimeoutChaos(
this IServiceCollection services)
{
services.AddSingleton<IChaosConfiguration>(new ChaosConfiguration
{
ExperimentName = "PaymentTimeout",
IsEnabled = true
});
return services;
}
}// <auto-generated by Ops.Chaos.Generator />
using Microsoft.Extensions.DependencyInjection;
namespace OrderService.Chaos;
public static class PaymentTimeoutChaosRegistration
{
/// <summary>
/// Registers the PaymentTimeout chaos experiment.
/// Adds the decorator and chaos configuration to the DI container.
/// Call EnableChaos() on the returned configuration to activate fault injection.
/// </summary>
public static IServiceCollection AddPaymentTimeoutChaos(
this IServiceCollection services)
{
services.AddSingleton<IChaosConfiguration>(new ChaosConfiguration
{
ExperimentName = "PaymentTimeout",
IsEnabled = false // Safe default: disabled in production
});
// The InjectableDecorator attribute handles DI decoration.
// This method registers the configuration and wires the experiment metadata.
services.AddSingleton(new ChaosExperimentMetadata
{
Name = "PaymentTimeout",
Tier = OpsExecutionTier.InProcess,
Hypothesis = "Order placement degrades gracefully when payment times out",
TargetService = typeof(IPaymentGateway),
Faults = new[]
{
new FaultDescriptor(FaultKind.Timeout, 0.5, delay: TimeSpan.FromSeconds(5)),
new FaultDescriptor(FaultKind.Exception, 0.2,
exceptionType: typeof(HttpRequestException))
},
SteadyStateProbes = new[]
{
new SteadyStateProbeDescriptor("order.placement.success_rate", "> 80%")
},
AbortConditions = new[]
{
new AbortConditionDescriptor("order.placement.error_rate", "50%")
}
});
return services;
}
/// <summary>
/// Enables fault injection for the PaymentTimeout experiment.
/// Call this in test setup. Never call this in production startup.
/// </summary>
public static IServiceCollection EnablePaymentTimeoutChaos(
this IServiceCollection services)
{
services.AddSingleton<IChaosConfiguration>(new ChaosConfiguration
{
ExperimentName = "PaymentTimeout",
IsEnabled = true
});
return services;
}
}Two extension methods. AddPaymentTimeoutChaos() registers everything with chaos disabled. EnablePaymentTimeoutChaos() overrides the configuration to turn it on. The separation is deliberate: the decorator is always in the DI graph (so you catch wiring errors), but faults only fire when explicitly enabled.
Step 4: Write the Test
Now the payoff. Write a test that proves the circuit breaker works under chaos conditions:
// PaymentTimeoutChaosTests.cs
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.CircuitBreaker;
public class PaymentTimeoutChaosTests
{
[Fact]
public async Task PaymentTimeout_CircuitBreaker_Opens_After_ThreeFailures()
{
// Arrange: build DI container with chaos enabled
var services = new ServiceCollection();
services.AddLogging();
services.AddMetrics();
// Register the real implementation (would normally talk to HTTP, but we
// replace it with a stub that always succeeds -- the chaos decorator
// is what introduces failures)
services.AddSingleton<IPaymentGateway>(new StubPaymentGateway());
// Register chaos experiment and enable fault injection
services.AddPaymentTimeoutChaos();
services.EnablePaymentTimeoutChaos();
var provider = services.BuildServiceProvider();
// The DI container now returns the chaos decorator wrapping the stub.
var gateway = provider.GetRequiredService<IPaymentGateway>();
// Configure a Polly circuit breaker: opens after 3 consecutive failures,
// stays open for 30 seconds
var circuitBreakerPolicy = Policy
.Handle<TimeoutException>()
.Or<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30));
var successCount = 0;
var fallbackCount = 0;
var circuitBreakerOpenCount = 0;
// Act: call PlaceOrder 10 times through the chaos decorator
for (int i = 0; i < 10; i++)
{
try
{
if (circuitBreakerPolicy.CircuitState == CircuitState.Open)
{
// Fallback: circuit is open, skip the call
circuitBreakerOpenCount++;
fallbackCount++;
continue;
}
await circuitBreakerPolicy.ExecuteAsync(async () =>
{
var result = await gateway.ChargeAsync(
$"order-{i}", 99.99m, CancellationToken.None);
successCount++;
});
}
catch (BrokenCircuitException)
{
circuitBreakerOpenCount++;
fallbackCount++;
}
catch (TimeoutException)
{
// Fault was injected but circuit breaker hasn't tripped yet
fallbackCount++;
}
catch (HttpRequestException)
{
// Fault was injected but circuit breaker hasn't tripped yet
fallbackCount++;
}
}
// Assert
var totalHandled = successCount + fallbackCount;
Assert.Equal(10, totalHandled); // Every call was handled (no unhandled exceptions)
// The circuit breaker should have opened at some point
// (with 70% fault rate, 3 consecutive failures are very likely in 10 calls)
Assert.True(circuitBreakerOpenCount > 0,
"Circuit breaker should have opened after consecutive failures");
// Steady-state: order placement success rate > 80%
// (success = either direct success or fallback, both count as "handled gracefully")
var successRate = (double)totalHandled / 10 * 100;
Assert.True(successRate > 80,
$"Steady-state probe failed: success rate was {successRate}%, expected > 80%");
}
}
/// <summary>
/// Stub implementation that always succeeds.
/// The chaos decorator wraps this and introduces faults.
/// </summary>
public class StubPaymentGateway : IPaymentGateway
{
public Task<PaymentResult> ChargeAsync(
string orderId, decimal amount, CancellationToken ct) =>
Task.FromResult(new PaymentResult(true, $"txn-{orderId}"));
public Task<PaymentResult> RefundAsync(
string transactionId, decimal amount, CancellationToken ct) =>
Task.FromResult(new PaymentResult(true, transactionId));
public Task<PaymentStatus> GetStatusAsync(
string transactionId, CancellationToken ct) =>
Task.FromResult(new PaymentStatus(transactionId, "completed", 99.99m));
}// PaymentTimeoutChaosTests.cs
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.CircuitBreaker;
public class PaymentTimeoutChaosTests
{
[Fact]
public async Task PaymentTimeout_CircuitBreaker_Opens_After_ThreeFailures()
{
// Arrange: build DI container with chaos enabled
var services = new ServiceCollection();
services.AddLogging();
services.AddMetrics();
// Register the real implementation (would normally talk to HTTP, but we
// replace it with a stub that always succeeds -- the chaos decorator
// is what introduces failures)
services.AddSingleton<IPaymentGateway>(new StubPaymentGateway());
// Register chaos experiment and enable fault injection
services.AddPaymentTimeoutChaos();
services.EnablePaymentTimeoutChaos();
var provider = services.BuildServiceProvider();
// The DI container now returns the chaos decorator wrapping the stub.
var gateway = provider.GetRequiredService<IPaymentGateway>();
// Configure a Polly circuit breaker: opens after 3 consecutive failures,
// stays open for 30 seconds
var circuitBreakerPolicy = Policy
.Handle<TimeoutException>()
.Or<HttpRequestException>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30));
var successCount = 0;
var fallbackCount = 0;
var circuitBreakerOpenCount = 0;
// Act: call PlaceOrder 10 times through the chaos decorator
for (int i = 0; i < 10; i++)
{
try
{
if (circuitBreakerPolicy.CircuitState == CircuitState.Open)
{
// Fallback: circuit is open, skip the call
circuitBreakerOpenCount++;
fallbackCount++;
continue;
}
await circuitBreakerPolicy.ExecuteAsync(async () =>
{
var result = await gateway.ChargeAsync(
$"order-{i}", 99.99m, CancellationToken.None);
successCount++;
});
}
catch (BrokenCircuitException)
{
circuitBreakerOpenCount++;
fallbackCount++;
}
catch (TimeoutException)
{
// Fault was injected but circuit breaker hasn't tripped yet
fallbackCount++;
}
catch (HttpRequestException)
{
// Fault was injected but circuit breaker hasn't tripped yet
fallbackCount++;
}
}
// Assert
var totalHandled = successCount + fallbackCount;
Assert.Equal(10, totalHandled); // Every call was handled (no unhandled exceptions)
// The circuit breaker should have opened at some point
// (with 70% fault rate, 3 consecutive failures are very likely in 10 calls)
Assert.True(circuitBreakerOpenCount > 0,
"Circuit breaker should have opened after consecutive failures");
// Steady-state: order placement success rate > 80%
// (success = either direct success or fallback, both count as "handled gracefully")
var successRate = (double)totalHandled / 10 * 100;
Assert.True(successRate > 80,
$"Steady-state probe failed: success rate was {successRate}%, expected > 80%");
}
}
/// <summary>
/// Stub implementation that always succeeds.
/// The chaos decorator wraps this and introduces faults.
/// </summary>
public class StubPaymentGateway : IPaymentGateway
{
public Task<PaymentResult> ChargeAsync(
string orderId, decimal amount, CancellationToken ct) =>
Task.FromResult(new PaymentResult(true, $"txn-{orderId}"));
public Task<PaymentResult> RefundAsync(
string transactionId, decimal amount, CancellationToken ct) =>
Task.FromResult(new PaymentResult(true, transactionId));
public Task<PaymentStatus> GetStatusAsync(
string transactionId, CancellationToken ct) =>
Task.FromResult(new PaymentStatus(transactionId, "completed", 99.99m));
}The test is straightforward: ten calls through a Polly circuit breaker wrapping the chaos-decorated gateway. The chaos decorator injects timeouts (50%) and exceptions (20%). The circuit breaker trips after three consecutive failures and enters the fallback path. The steady-state assertion verifies that every call was handled -- either by direct success through the 30% pass-through window, or by the fallback when the circuit breaker opened.
Step 5: Run and Analyze
$ dotnet test --logger "console;verbosity=detailed"
Starting test execution...
[xUnit] PaymentTimeoutChaosTests.PaymentTimeout_CircuitBreaker_Opens_After_ThreeFailures
[Chaos:PaymentTimeout] Injecting 5000ms timeout on ChargeAsync for order order-0
[Chaos:PaymentTimeout] Injecting HttpRequestException on ChargeAsync for order order-1
[Chaos:PaymentTimeout] Injecting 5000ms timeout on ChargeAsync for order order-2
** Circuit breaker opened after 3 consecutive failures **
[info] Call 3: Circuit open, using fallback
[info] Call 4: Circuit open, using fallback
[info] Call 5: Circuit open, using fallback
[info] Call 6: Circuit open, using fallback
[info] Call 7: Circuit open, using fallback
[info] Call 8: Circuit open, using fallback
[info] Call 9: Circuit open, using fallback
Passed! PaymentTimeout_CircuitBreaker_Opens_After_ThreeFailures [1.2s]
Test Run Successful.
Total tests: 1
Passed: 1
Duration: 1.4s$ dotnet test --logger "console;verbosity=detailed"
Starting test execution...
[xUnit] PaymentTimeoutChaosTests.PaymentTimeout_CircuitBreaker_Opens_After_ThreeFailures
[Chaos:PaymentTimeout] Injecting 5000ms timeout on ChargeAsync for order order-0
[Chaos:PaymentTimeout] Injecting HttpRequestException on ChargeAsync for order order-1
[Chaos:PaymentTimeout] Injecting 5000ms timeout on ChargeAsync for order order-2
** Circuit breaker opened after 3 consecutive failures **
[info] Call 3: Circuit open, using fallback
[info] Call 4: Circuit open, using fallback
[info] Call 5: Circuit open, using fallback
[info] Call 6: Circuit open, using fallback
[info] Call 7: Circuit open, using fallback
[info] Call 8: Circuit open, using fallback
[info] Call 9: Circuit open, using fallback
Passed! PaymentTimeout_CircuitBreaker_Opens_After_ThreeFailures [1.2s]
Test Run Successful.
Total tests: 1
Passed: 1
Duration: 1.4sThe generated chaos report (written to TestResults/chaos-report.json):
{
"experiment": "PaymentTimeout",
"tier": "InProcess",
"hypothesis": "Order placement degrades gracefully when payment times out",
"result": "PASSED",
"duration_ms": 1203,
"faults_injected": {
"timeout": 5,
"exception": 2,
"passthrough": 3
},
"steady_state_probes": [
{
"metric": "order.placement.success_rate",
"expected": "> 80%",
"actual": "100%",
"passed": true
}
],
"abort_conditions": [
{
"metric": "order.placement.error_rate",
"threshold": "50%",
"peak": "30%",
"triggered": false
}
],
"circuit_breaker_transitions": [
{ "time_ms": 0, "state": "Closed" },
{ "time_ms": 342, "state": "Open" }
]
}{
"experiment": "PaymentTimeout",
"tier": "InProcess",
"hypothesis": "Order placement degrades gracefully when payment times out",
"result": "PASSED",
"duration_ms": 1203,
"faults_injected": {
"timeout": 5,
"exception": 2,
"passthrough": 3
},
"steady_state_probes": [
{
"metric": "order.placement.success_rate",
"expected": "> 80%",
"actual": "100%",
"passed": true
}
],
"abort_conditions": [
{
"metric": "order.placement.error_rate",
"threshold": "50%",
"peak": "30%",
"triggered": false
}
],
"circuit_breaker_transitions": [
{ "time_ms": 0, "state": "Closed" },
{ "time_ms": 342, "state": "Open" }
]
}The analyzer output during dotnet build:
info CHS001: Circuit breaker on IPaymentGateway has chaos experiment 'PaymentTimeout' (InProcess tier)
info CHS002: Steady-state probe 'order.placement.success_rate' validated in test
info CHS003: Abort condition 'order.placement.error_rate' validated in testinfo CHS001: Circuit breaker on IPaymentGateway has chaos experiment 'PaymentTimeout' (InProcess tier)
info CHS002: Steady-state probe 'order.placement.success_rate' validated in test
info CHS003: Abort condition 'order.placement.error_rate' validated in testNo warnings. No errors. Every circuit breaker has a corresponding chaos experiment. Every steady-state probe has a corresponding test assertion.
Step 6: What We Proved
Five things, all verifiable from the test output and chaos report:
The circuit breaker opens under fault conditions. Three consecutive failures triggered the break. This is not a configuration assumption -- it is an observed fact in the test log.
The fallback path is exercised. Seven of ten calls went through the fallback. The fallback code path is tested under realistic conditions, not just a unit test that calls the fallback directly.
The steady-state hypothesis holds. 100% of calls were handled gracefully -- either through the 30% passthrough window or through the circuit breaker fallback. The hypothesis "order placement degrades gracefully" is confirmed.
The abort condition was never triggered. The error rate peaked at 30%, well below the 50% abort threshold. The system is more resilient than the minimum requirement.
Zero infrastructure required. No Docker. No cloud account. No network manipulation. No Toxiproxy. No Kubernetes. No Terraform. The entire experiment ran inside
dotnet testin 1.4 seconds. A developer can run this on their laptop, in CI, in a pre-commit hook. The feedback loop is seconds, not minutes.
The chaos decorator exists in the DI graph even in production -- it just does nothing when IsEnabled is false. This means the decorator wiring is always validated. You cannot ship a build where the decorator targets the wrong interface or the DI registration is missing. The compiler catches it.
This is InProcess chaos. One tier. One dotnet test. One proof that the circuit breaker works. The next walkthrough adds Docker, Toxiproxy, and real network faults -- same attributes, different tier, different generated artifacts.