@@ -44,6 +44,7 @@ private enum State
4444 private State _state = State . Closed ;
4545 private int _consecutiveFailures ;
4646 private long _openedAtMs ;
47+ private bool _halfOpenProbeInFlight ;
4748
4849 /// <param name="inner">The client to protect.</param>
4950 /// <param name="failureThreshold">Consecutive transient failures that trip the breaker (min 1).</param>
@@ -89,25 +90,40 @@ public async Task<LlmCompletionResult> CompleteAsync(
8990 } ;
9091 }
9192
92- LlmCompletionResult result ;
93+ bool classified = false ;
9394 try
9495 {
95- result = await _inner . CompleteAsync ( request , cancellationToken ) . ConfigureAwait ( false ) ;
96- }
97- catch ( OperationCanceledException )
98- {
99- // WHY: Cancellation is caller intent, not a backend fault — do not count it, do not swallow it.
100- throw ;
96+ LlmCompletionResult result ;
97+ try
98+ {
99+ result = await _inner . CompleteAsync ( request , cancellationToken ) . ConfigureAwait ( false ) ;
100+ }
101+ catch ( OperationCanceledException )
102+ {
103+ // WHY: Cancellation is caller intent, not a backend fault — do not count it, do not swallow it.
104+ throw ;
105+ }
106+ catch ( Exception ex )
107+ {
108+ // WHY: An unexpected throw from the inner client is treated as a transient backend fault.
109+ RecordFailure ( ) ;
110+ classified = true ;
111+ throw new LlmClientException ( ex . Message , LlmErrorCode . ProviderError ) ;
112+ }
113+
114+ RecordResult ( result ? . Ok ?? false , result ? . ErrorCode ?? LlmErrorCode . ProviderError ) ;
115+ classified = true ;
116+ return result ;
101117 }
102- catch ( Exception ex )
118+ finally
103119 {
104- // WHY: An unexpected throw from the inner client is treated as a transient backend fault.
105- RecordFailure ( ) ;
106- throw new LlmClientException ( ex . Message , LlmErrorCode . ProviderError ) ;
120+ if ( ! classified )
121+ {
122+ // WHY: The call ended without a health verdict (cancellation): release the half-open
123+ // probe slot so the breaker is not stuck waiting for a result that never comes.
124+ ReleaseHalfOpenProbe ( ) ;
125+ }
107126 }
108-
109- RecordResult ( result ? . Ok ?? false , result ? . ErrorCode ?? LlmErrorCode . ProviderError ) ;
110- return result ;
111127 }
112128
113129 public async IAsyncEnumerable < LlmStreamChunk > CompleteStreamingAsync (
@@ -129,6 +145,8 @@ public async IAsyncEnumerable<LlmStreamChunk> CompleteStreamingAsync(
129145 bool sawTerminalFailure = false ;
130146 LlmErrorCode terminalCode = LlmErrorCode . None ;
131147 bool sawAnyChunk = false ;
148+ bool streamEnded = false ;
149+ bool classified = false ;
132150
133151 IAsyncEnumerator < LlmStreamChunk > e =
134152 _inner . CompleteStreamingAsync ( request , cancellationToken ) . GetAsyncEnumerator ( cancellationToken ) ;
@@ -156,6 +174,7 @@ public async IAsyncEnumerable<LlmStreamChunk> CompleteStreamingAsync(
156174 catch ( Exception ex )
157175 {
158176 RecordFailure ( ) ;
177+ classified = true ;
159178 moveError = ex . Message ;
160179 chunk = default ;
161180 }
@@ -180,16 +199,32 @@ public async IAsyncEnumerable<LlmStreamChunk> CompleteStreamingAsync(
180199
181200 yield return chunk ;
182201 }
202+
203+ streamEnded = true ;
183204 }
184205 finally
185206 {
186207 await e . DisposeAsync ( ) . ConfigureAwait ( false ) ;
187- }
188208
189- // WHY: Classify the whole stream: a stream that produced an error chunk (or nothing at all) is a
190- // failure; a stream that ended cleanly is a success that closes/keeps-closed the breaker.
191- bool ok = sawAnyChunk && ! sawTerminalFailure ;
192- RecordResult ( ok , ok ? LlmErrorCode . None : terminalCode ) ;
209+ // WHY: Classify in the finally so the breaker state also updates when the consumer
210+ // abandons the await-foreach early (user stop): a stream that already produced a
211+ // terminal error chunk is a failure even then; a completed stream is classified as
212+ // before (an error chunk or nothing at all = failure, a clean end = success); an
213+ // abandoned/cancelled healthy stream carries no verdict at all — it only releases
214+ // the half-open probe slot and is never misclassified as a backend failure.
215+ if ( ! classified )
216+ {
217+ if ( streamEnded || sawTerminalFailure )
218+ {
219+ bool ok = streamEnded && sawAnyChunk && ! sawTerminalFailure ;
220+ RecordResult ( ok , ok ? LlmErrorCode . None : terminalCode ) ;
221+ }
222+ else
223+ {
224+ ReleaseHalfOpenProbe ( ) ;
225+ }
226+ }
227+ }
193228 }
194229
195230 /// <summary>Current state name for diagnostics/tests: "Closed", "Open", or "HalfOpen".</summary>
@@ -217,6 +252,7 @@ private bool TryEnter(out string rejectReason)
217252 if ( _nowMs ( ) - _openedAtMs >= _openDurationMs )
218253 {
219254 _state = State . HalfOpen ;
255+ _halfOpenProbeInFlight = true ;
220256 _log ? . Invoke ( "[CircuitBreaker] half-open: admitting one probe request." ) ;
221257 rejectReason = null ;
222258 return true ;
@@ -228,12 +264,42 @@ private bool TryEnter(out string rejectReason)
228264 return false ;
229265 }
230266
231- // WHY: Closed or HalfOpen: allow through (HalfOpen admits the single probe already in flight).
267+ if ( _state == State . HalfOpen )
268+ {
269+ // WHY: Exactly ONE probe may be in flight while half-open. Admitting every concurrent
270+ // caller here used to burst the whole backlog onto a backend that is still likely down.
271+ if ( _halfOpenProbeInFlight )
272+ {
273+ rejectReason =
274+ "Circuit breaker half-open: a single probe request is already in flight; " +
275+ "short-circuited until the probe result is known." ;
276+ return false ;
277+ }
278+
279+ _halfOpenProbeInFlight = true ;
280+ _log ? . Invoke ( "[CircuitBreaker] half-open: admitting one probe request." ) ;
281+ rejectReason = null ;
282+ return true ;
283+ }
284+
232285 rejectReason = null ;
233286 return true ;
234287 }
235288 }
236289
290+ /// <summary>
291+ /// Releases the half-open probe slot when a call ends without any success/failure verdict
292+ /// (consumer cancelled the call or abandoned the stream). The breaker stays half-open so the
293+ /// next call becomes the new probe; abandonment is never counted as a backend failure.
294+ /// </summary>
295+ private void ReleaseHalfOpenProbe ( )
296+ {
297+ lock ( _gate )
298+ {
299+ _halfOpenProbeInFlight = false ;
300+ }
301+ }
302+
237303 private void RecordResult ( bool ok , LlmErrorCode code )
238304 {
239305 if ( ok )
@@ -260,6 +326,7 @@ private void RecordSuccess()
260326 lock ( _gate )
261327 {
262328 _consecutiveFailures = 0 ;
329+ _halfOpenProbeInFlight = false ;
263330 if ( _state != State . Closed )
264331 {
265332 _state = State . Closed ;
@@ -272,6 +339,7 @@ private void RecordFailure()
272339 {
273340 lock ( _gate )
274341 {
342+ _halfOpenProbeInFlight = false ;
275343 if ( _state == State . HalfOpen )
276344 {
277345 // WHY: The probe failed — re-open for another cooldown.
0 commit comments