Skip to content

Commit de6eb20

Browse files
Fix CircuitBreaker handling of Half-Open (#1991)
* Rename methods to clarify intention * Ensure that an unhandled exception in half open state closes the circuit instead of blocking any further transition from half open state * Fix mutation survived in StrategyHelper; add explicit test to replace no longer existing implicit test in CircuitBreakerResilienceStrategyTests
1 parent 5f726d3 commit de6eb20

File tree

5 files changed

+45
-24
lines changed

5 files changed

+45
-24
lines changed

src/Polly.Core/CircuitBreaker/CircuitBreakerResilienceStrategy.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ protected internal override async ValueTask<Outcome<T>> ExecuteCore<TState>(Func
3939
var args = new CircuitBreakerPredicateArguments<T>(context, outcome);
4040
if (await _handler(args).ConfigureAwait(context.ContinueOnCapturedContext))
4141
{
42-
await _controller.OnActionFailureAsync(outcome, context).ConfigureAwait(context.ContinueOnCapturedContext);
42+
await _controller.OnHandledOutcomeAsync(outcome, context).ConfigureAwait(context.ContinueOnCapturedContext);
4343
}
44-
else if (outcome.Exception is null)
44+
else
4545
{
46-
await _controller.OnActionSuccessAsync(outcome, context).ConfigureAwait(context.ContinueOnCapturedContext);
46+
await _controller.OnUnhandledOutcomeAsync(outcome, context).ConfigureAwait(context.ContinueOnCapturedContext);
4747
}
4848

4949
return outcome;

src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ public ValueTask CloseCircuitAsync(ResilienceContext context)
163163
return null;
164164
}
165165

166-
public ValueTask OnActionSuccessAsync(Outcome<T> outcome, ResilienceContext context)
166+
public ValueTask OnUnhandledOutcomeAsync(Outcome<T> outcome, ResilienceContext context)
167167
{
168168
EnsureNotDisposed();
169169

@@ -189,7 +189,7 @@ public ValueTask OnActionSuccessAsync(Outcome<T> outcome, ResilienceContext cont
189189
return ExecuteScheduledTaskAsync(task, context);
190190
}
191191

192-
public ValueTask OnActionFailureAsync(Outcome<T> outcome, ResilienceContext context)
192+
public ValueTask OnHandledOutcomeAsync(Outcome<T> outcome, ResilienceContext context)
193193
{
194194
EnsureNotDisposed();
195195

test/Polly.Core.Tests/CircuitBreaker/CircuitBreakerResilienceStrategyTests.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,14 @@ public void Execute_HandledException_OnFailureCalled()
105105
}
106106

107107
[Fact]
108-
public void Execute_UnhandledException_NoCalls()
108+
public void Execute_UnhandledException_OnActionSuccess()
109109
{
110110
_options.ShouldHandle = args => new ValueTask<bool>(args.Outcome.Exception is InvalidOperationException);
111111
var strategy = Create();
112112

113113
strategy.Invoking(s => s.Execute<int>(_ => throw new ArgumentException())).Should().Throw<ArgumentException>();
114114

115-
_behavior.DidNotReceiveWithAnyArgs().OnActionFailure(default, out Arg.Any<bool>());
116-
_behavior.DidNotReceiveWithAnyArgs().OnActionSuccess(default);
117-
_behavior.DidNotReceiveWithAnyArgs().OnCircuitClosed();
115+
_behavior.Received(1).OnActionSuccess(CircuitState.Closed);
118116
}
119117

120118
public void Dispose() => _controller.Dispose();

test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ public async Task Disposed_EnsureThrows()
108108
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await controller.CloseCircuitAsync(ResilienceContextPool.Shared.Get()));
109109
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await controller.IsolateCircuitAsync(ResilienceContextPool.Shared.Get()));
110110
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get()));
111-
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await controller.OnActionSuccessAsync(Outcome.FromResult(10), ResilienceContextPool.Shared.Get()));
112-
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await controller.OnActionFailureAsync(Outcome.FromResult(10), ResilienceContextPool.Shared.Get()));
111+
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await controller.OnUnhandledOutcomeAsync(Outcome.FromResult(10), ResilienceContextPool.Shared.Get()));
112+
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await controller.OnHandledOutcomeAsync(Outcome.FromResult(10), ResilienceContextPool.Shared.Get()));
113113
}
114114

115115
[Fact]
@@ -182,15 +182,15 @@ public async Task HalfOpen_EnsureCorrectStateTransitionAfterExecution(bool succe
182182

183183
if (success)
184184
{
185-
await controller.OnActionSuccessAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
185+
await controller.OnUnhandledOutcomeAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
186186
controller.CircuitState.Should().Be(CircuitState.Closed);
187187

188188
_circuitBehavior.Received().OnActionSuccess(CircuitState.HalfOpen);
189189
_circuitBehavior.Received().OnCircuitClosed();
190190
}
191191
else
192192
{
193-
await controller.OnActionFailureAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
193+
await controller.OnHandledOutcomeAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
194194
controller.CircuitState.Should().Be(CircuitState.Open);
195195

196196
_circuitBehavior.DidNotReceiveWithAnyArgs().OnActionSuccess(default);
@@ -226,9 +226,9 @@ public async Task OnActionFailure_EnsureLock()
226226
using var controller = CreateController();
227227

228228
// act
229-
var executeAction = Task.Run(() => controller.OnActionFailureAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get()));
229+
var executeAction = Task.Run(() => controller.OnHandledOutcomeAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get()));
230230
executing.WaitOne();
231-
var executeAction2 = Task.Run(() => controller.OnActionFailureAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get()));
231+
var executeAction2 = Task.Run(() => controller.OnHandledOutcomeAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get()));
232232

233233
// assert
234234
#pragma warning disable xUnit1031 // Do not use blocking task operations in test method
@@ -285,7 +285,7 @@ public async Task OnActionSuccess_EnsureCorrectBehavior(CircuitState state, Circ
285285
await TransitionToState(controller, state);
286286

287287
// act
288-
await controller.OnActionSuccessAsync(Outcome.FromResult(10), ResilienceContextPool.Shared.Get());
288+
await controller.OnUnhandledOutcomeAsync(Outcome.FromResult(10), ResilienceContextPool.Shared.Get());
289289

290290
// assert
291291
controller.CircuitState.Should().Be(expectedState);
@@ -330,7 +330,7 @@ public async Task OnActionFailureAsync_EnsureCorrectBehavior(CircuitState state,
330330
.Do(x => x[1] = shouldBreak);
331331

332332
// act
333-
await controller.OnActionFailureAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
333+
await controller.OnHandledOutcomeAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
334334

335335
// assert
336336
controller.LastHandledOutcome!.Value.Result.Should().Be(99);
@@ -367,7 +367,7 @@ public async Task OnActionFailureAsync_EnsureBreakDurationGeneration()
367367
.Do(x => x[1] = true);
368368

369369
// act
370-
await controller.OnActionFailureAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
370+
await controller.OnHandledOutcomeAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
371371

372372
// assert
373373
var blockedTill = GetBlockedTill(controller);
@@ -430,7 +430,7 @@ public async Task OnActionFailureAsync_EnsureBreakDurationNotOverflow(bool overf
430430
.Do(x => x[1] = shouldBreak);
431431

432432
// act
433-
await controller.OnActionFailureAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
433+
await controller.OnHandledOutcomeAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
434434

435435
// assert
436436
var blockedTill = GetBlockedTill(controller);
@@ -457,7 +457,7 @@ public async Task OnActionFailureAsync_VoidResult_EnsureBreakingExceptionNotSet(
457457
.Do(x => x[1] = shouldBreak);
458458

459459
// act
460-
await controller.OnActionFailureAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
460+
await controller.OnHandledOutcomeAsync(Outcome.FromResult(99), ResilienceContextPool.Shared.Get());
461461

462462
// assert
463463
controller.LastException.Should().BeNull();
@@ -472,7 +472,7 @@ public async Task Flow_Closed_HalfOpen_Closed()
472472

473473
await TransitionToState(controller, CircuitState.HalfOpen);
474474

475-
await controller.OnActionSuccessAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
475+
await controller.OnUnhandledOutcomeAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
476476
controller.CircuitState.Should().Be(CircuitState.Closed);
477477

478478
_circuitBehavior.Received().OnActionSuccess(CircuitState.HalfOpen);
@@ -491,7 +491,7 @@ public async Task Flow_Closed_HalfOpen_Open_HalfOpen_Closed()
491491
_circuitBehavior.When(v => v.OnActionFailure(CircuitState.HalfOpen, out Arg.Any<bool>()))
492492
.Do(x => x[1] = shouldBreak);
493493

494-
await controller.OnActionFailureAsync(Outcome.FromResult(0), context);
494+
await controller.OnHandledOutcomeAsync(Outcome.FromResult(0), context);
495495
controller.CircuitState.Should().Be(CircuitState.Open);
496496

497497
// execution rejected
@@ -505,7 +505,7 @@ public async Task Flow_Closed_HalfOpen_Open_HalfOpen_Closed()
505505
controller.CircuitState.Should().Be(CircuitState.HalfOpen);
506506

507507
// close circuit
508-
await controller.OnActionSuccessAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
508+
await controller.OnUnhandledOutcomeAsync(Outcome.FromResult(0), ResilienceContextPool.Shared.Get());
509509
controller.CircuitState.Should().Be(CircuitState.Closed);
510510

511511
_circuitBehavior.Received().OnActionSuccess(CircuitState.HalfOpen);
@@ -562,7 +562,7 @@ private async Task OpenCircuit(CircuitStateController<int> controller, Outcome<i
562562
_circuitBehavior.When(v => v.OnActionFailure(CircuitState.Closed, out Arg.Any<bool>()))
563563
.Do(x => x[1] = breakCircuit);
564564

565-
await controller.OnActionFailureAsync(outcome ?? Outcome.FromResult(10), ResilienceContextPool.Shared.Get().Initialize<int>(true));
565+
await controller.OnHandledOutcomeAsync(outcome ?? Outcome.FromResult(10), ResilienceContextPool.Shared.Get().Initialize<int>(true));
566566
}
567567

568568
private void AdvanceTime(TimeSpan timespan) => _timeProvider.Advance(timespan);

test/Polly.Core.Tests/Utils/StrategyHelperTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,27 @@ await TestUtilities.AssertWithTimeoutAsync(async () =>
3939

4040
outcome.Exception.Should().BeOfType<InvalidOperationException>();
4141
});
42+
43+
[InlineData(true)]
44+
[InlineData(false)]
45+
[Theory]
46+
public async Task ExecuteCallbackSafeAsync_AsyncCallback_CompletedOk(bool isAsync) =>
47+
await TestUtilities.AssertWithTimeoutAsync(async () =>
48+
{
49+
var outcomeTask = StrategyHelper.ExecuteCallbackSafeAsync<string, string>(
50+
async (_, _) =>
51+
{
52+
if (isAsync)
53+
{
54+
await Task.Delay(15);
55+
}
56+
57+
return Outcome.FromResult("success");
58+
},
59+
ResilienceContextPool.Shared.Get(),
60+
"dummy");
61+
62+
outcomeTask.IsCompleted.Should().Be(!isAsync);
63+
(await outcomeTask).Result.Should().Be("success");
64+
});
4265
}

0 commit comments

Comments
 (0)