An AI-provider circuit breaker temporarily blocks new calls after a configured pattern of failures, then permits a recovery probe. In .NET, a shared Polly resilience pipeline can implement that behavior around the provider call. The breaker must persist across requests; creating a new breaker for every call discards the failure history it needs.
Suppose an assistant keeps sending generation requests while its provider is returning errors. Users wait, infrastructure remains busy, and repeated attempts obscure the fact that the dependency is unhealthy. A breaker gives the application a defined unavailable state and a controlled route back to service.
This tutorial uses a scripted HTTP handler, so no external provider is contacted. It tests opening, blocking, a failed recovery probe, and successful recovery.
How is a circuit breaker different from a retry or timeout?
These mechanisms solve different problems.
Mechanism |
Purpose |
Question it answers |
Timeout |
Bound a call's duration |
How long may this attempt take? |
Retry |
Make another attempt after a selected failure |
Should this operation be tried again? |
Circuit breaker |
Stop new attempts while a dependency appears unhealthy |
Should this call reach the dependency now? |
Polly's circuit-breaker documentation describes failure sampling, an open period, and recovery probes. A breaker does not itself retry a failed operation.
The example deliberately adds no retry strategy. That makes provider-call counts and state changes easy to interpret. A production combination needs a deliberate order and a clear definition of whether the breaker observes individual attempts or final operation outcomes.
What do you need to run the example?
The code was compiled with SDK 8.0.414 and C# 12, then executed on .NET 8.0.20 with Polly.Core 8.6.4 on Linux. These are the tested versions, not a claim that they are the newest releases.
Use a .NET 8 console project with a Polly.Core package reference pinned to 8.6.4. Replace Program.cs with the code below and run the project. The package's NuGet entry identifies the version used.
The fake handler returns four scripted statuses: 503, 503, 503, and 200. The extra application calls made while the breaker is open should never reach that handler.
Build one pipeline and reuse it
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Polly;
using Polly.CircuitBreaker;
var state = new CircuitBreakerStateProvider();
var transitions = new List<string>();
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 1.0,
MinimumThroughput = 2,
SamplingDuration = TimeSpan.FromSeconds(10),
BreakDuration = TimeSpan.FromSeconds(1),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => (int)r.StatusCode >= 500),
StateProvider = state,
OnOpened = _ => { transitions.Add("Open"); return default; },
OnHalfOpened = _ => { transitions.Add("HalfOpen"); return default; },
OnClosed = _ => { transitions.Add("Closed"); return default; }
}).Build();
using var handler = new ScriptedHandler(503, 503, 503, 200);
using var client = new HttpClient(handler);
var observed = new List<string>();
async Task Call(string label)
{
try
{
using var response = await pipeline.ExecuteAsync(async token =>
{
using var request = new HttpRequestMessage(
HttpMethod.Post, "https://provider.invalid/generate");
request.Content = new StringContent("{\"input\":\"synthetic\"}");
return await client.SendAsync(request, token);
}, CancellationToken.None);
observed.Add($"{label}: {(int)response.StatusCode}; {state.CircuitState}");
}
catch (BrokenCircuitException)
{
observed.Add($"{label}: blocked; {state.CircuitState}");
}
}
await Call("first");
await Call("second");
await Call("while-open");
if (handler.Calls != 2) throw new Exception("Open circuit reached provider");
await Task.Delay(1200);
await Call("failed-probe");
await Call("blocked-again");
if (handler.Calls != 3) throw new Exception("Failed probe did not reopen");
await Task.Delay(1200);
await Call("successful-probe");
string events = string.Join(",", transitions);
if (events != "Open,HalfOpen,Open,HalfOpen,Closed" || handler.Calls != 4)
throw new Exception("Unexpected state transitions");
foreach (string line in observed) Console.WriteLine(line);
Console.WriteLine("transitions:events");Console.WriteLine("provider calls: {handler.Calls}");
sealed class ScriptedHandler(params int[] statuses) : HttpMessageHandler
{
public int Calls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new HttpResponseMessage((HttpStatusCode)statuses[Calls++]));
}
}
Join the conversation! Your thoughts help the community grow.