Repository: App-vNext/Polly

Issue:
[Question]: Circuit Breaker: Using BreakDurationGenerator to implement exponential backoff of open to half-open state
### What are you wanting to achieve?

I'd like to be able to have exponential back-off of break duration via the `BreakDurationGenerator`. For example, when the circuit goes from Closed to Open, I'd like to set a smaller break duration before it goes to Half-Open. As the circuit continues to move from Half-Open, back to Open, I'd like to keep track of the number of times this has happened, and increase the break duration up to a maximum time. 

### What code or approach do you have so far?

The following works, but it feels incorrect to have to maintain a state variable outside of the Builder 
```
            var halfOpenAttempts = 0; // <-- holding state outside.
            return builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions<int>
            {
                ShouldHandle = args =>
                {
                    return args.Outcome.Exception switch
                    {
                        not null => PredicateResult.True(), 
                        _ => PredicateResult.False()
                    };
                },
                MinimumThroughput = 2,
                BreakDurationGenerator = args =>
                {
                    var delay = Math.Pow(2, halfOpenAttempts);
                    var fromSeconds = TimeSpan.FromSeconds(Math.Min(delay, 60));
                    _logger.Debug(
                            "Generating break duration of {timeSpan} after {failureCount} failures.",
                            fromSeconds, args.FailureCount);
                    return new ValueTask<TimeSpan>(fromSeconds);
                },
                OnOpened = args =>
                {
                    _logger.Warning("opened with break duration {breakDuration}", args.BreakDuration);
                    return ValueTask.CompletedTask;
                },
                OnHalfOpened = _ =>
                {
                    // increment each time we "probe"
                    halfOpenAttempts++;
                    return ValueTask.CompletedTask;
                },
                OnClosed = _ =>
                {
                    // reset back to zero when circuit closes.
                    halfOpenAttempts = 0;
                    return ValueTask.CompletedTask;
                }
            });
```
Would it be possible to add the "half-open" attempts to the `BreakDurationGeneratorArguments`?

### Additional context

I saw that there was a similar [PR/request ](https://github.com/App-vNext/Polly/issues/653) for the very same thing for v7.x of Polly. 


Hints:
> Would it be possible to add the "half-open" attempts to the BreakDurationGeneratorArguments?

Yes, the new design allows us to do these kind of changes quite easily. :)

Patch:
diff --git a/src/Polly.Core/CircuitBreaker/BreakDurationGeneratorArguments.cs b/src/Polly.Core/CircuitBreaker/BreakDurationGeneratorArguments.cs
index 6b10a0e8d9d..8883f6dd774 100644
--- a/src/Polly.Core/CircuitBreaker/BreakDurationGeneratorArguments.cs
+++ b/src/Polly.Core/CircuitBreaker/BreakDurationGeneratorArguments.cs
@@ -1,3 +1,5 @@
+using System.ComponentModel;
+
 namespace Polly.CircuitBreaker;
 
 #pragma warning disable CA1815 // Override equals and operator equals on value types
@@ -16,6 +18,7 @@ public readonly struct BreakDurationGeneratorArguments
     /// This count is used to determine if the failure threshold has been reached.</param>
     /// <param name="context">The resilience context providing additional information
     /// about the execution state and failures.</param>
+    [EditorBrowsable(EditorBrowsableState.Never)]
     public BreakDurationGeneratorArguments(
         double failureRate,
         int failureCount,
@@ -24,6 +27,29 @@ public BreakDurationGeneratorArguments(
         FailureRate = failureRate;
         FailureCount = failureCount;
         Context = context;
+        HalfOpenAttempts = 0;
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BreakDurationGeneratorArguments"/> struct.
+    /// </summary>
+    /// <param name="failureRate">The failure rate at which the circuit breaker should trip.
+    /// It represents the ratio of failed actions to the total executed actions.</param>
+    /// <param name="failureCount">The number of failures that have occurred.
+    /// This count is used to determine if the failure threshold has been reached.</param>
+    /// <param name="context">The resilience context providing additional information
+    /// about the execution state and failures.</param>
+    /// <param name="halfOpenAttempts">The number of half-open attempts.</param>
+    public BreakDurationGeneratorArguments(
+        double failureRate,
+        int failureCount,
+        ResilienceContext context,
+        int halfOpenAttempts)
+    {
+        FailureRate = failureRate;
+        FailureCount = failureCount;
+        Context = context;
+        HalfOpenAttempts = halfOpenAttempts;
     }
 
     /// <summary>
@@ -40,4 +66,9 @@ public BreakDurationGeneratorArguments(
     /// Gets the context that provides additional information about the resilience operation.
     /// </summary>
     public ResilienceContext Context { get; }
+
+    /// <summary>
+    /// Gets the number of half-open attempts.
+    /// </summary>
+    public int HalfOpenAttempts { get; }
 }
diff --git a/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs b/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs
index e2333370884..12902f25c85 100644
--- a/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs
+++ b/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs
@@ -22,6 +22,7 @@ internal sealed class CircuitStateController<T> : IDisposable
     private Outcome<T>? _lastOutcome;
     private Exception? _breakingException;
     private bool _disposed;
+    private int _halfOpenAttempts;
 
 #pragma warning disable S107
     public CircuitStateController(
@@ -132,6 +133,7 @@ public ValueTask CloseCircuitAsync(ResilienceContext context)
             // check if circuit can be half-opened
             if (_circuitState == CircuitState.Open && PermitHalfOpenCircuitTest_NeedsLock())
             {
+                _halfOpenAttempts++;
                 _circuitState = CircuitState.HalfOpen;
                 _telemetry.Report(new(ResilienceEventSeverity.Warning, CircuitBreakerConstants.OnHalfOpenEvent), context, new OnCircuitHalfOpenedArguments(context));
                 isHalfOpen = true;
@@ -270,6 +272,7 @@ private void CloseCircuit_NeedsLock(Outcome<T> outcome, bool manual, ResilienceC
 
         _blockedUntil = DateTimeOffset.MinValue;
         _lastOutcome = null;
+        _halfOpenAttempts = 0;
 
         CircuitState priorState = _circuitState;
         _circuitState = CircuitState.Closed;
@@ -325,7 +328,7 @@ private void OpenCircuitFor_NeedsLock(Outcome<T> outcome, TimeSpan breakDuration
         {
 #pragma warning disable CA2012
 #pragma warning disable S1226
-            breakDuration = _breakDurationGenerator(new(_behavior.FailureRate, _behavior.FailureCount, context)).GetAwaiter().GetResult();
+            breakDuration = _breakDurationGenerator(new(_behavior.FailureRate, _behavior.FailureCount, context, _halfOpenAttempts)).GetAwaiter().GetResult();
 #pragma warning restore S1226
 #pragma warning restore CA2012
         }
diff --git a/src/Polly.Core/PublicAPI.Unshipped.txt b/src/Polly.Core/PublicAPI.Unshipped.txt
index 6a64f8a1f95..14a878b70e0 100644
--- a/src/Polly.Core/PublicAPI.Unshipped.txt
+++ b/src/Polly.Core/PublicAPI.Unshipped.txt
@@ -1,4 +1,6 @@
 ﻿#nullable enable
+Polly.CircuitBreaker.BreakDurationGeneratorArguments.BreakDurationGeneratorArguments(double failureRate, int failureCount, Polly.ResilienceContext! context, int halfOpenAttempts) -> void
+Polly.CircuitBreaker.BreakDurationGeneratorArguments.HalfOpenAttempts.get -> int
 Polly.Simmy.Behavior.BehaviorActionArguments
 Polly.Simmy.Behavior.BehaviorActionArguments.BehaviorActionArguments() -> void
 Polly.Simmy.Behavior.BehaviorActionArguments.BehaviorActionArguments(Polly.ResilienceContext! context) -> void


Test patch:
diff --git a/test/Polly.Core.Tests/CircuitBreaker/BreakDurationGeneratorArgumentsTests.cs b/test/Polly.Core.Tests/CircuitBreaker/BreakDurationGeneratorArgumentsTests.cs
index 5afa53fe6ae..7b0e5378559 100644
--- a/test/Polly.Core.Tests/CircuitBreaker/BreakDurationGeneratorArgumentsTests.cs
+++ b/test/Polly.Core.Tests/CircuitBreaker/BreakDurationGeneratorArgumentsTests.cs
@@ -6,7 +6,7 @@ namespace Polly.Core.Tests.CircuitBreaker;
 public class BreakDurationGeneratorArgumentsTests
 {
     [Fact]
-    public void Constructor_ShouldSetFailureRate()
+    public void Constructor_Old_Ok()
     {
         double expectedFailureRate = 0.5;
         int failureCount = 10;
@@ -15,29 +15,22 @@ public void Constructor_ShouldSetFailureRate()
         var args = new BreakDurationGeneratorArguments(expectedFailureRate, failureCount, context);
 
         args.FailureRate.Should().Be(expectedFailureRate);
+        args.FailureCount.Should().Be(failureCount);
+        args.Context.Should().Be(context);
     }
 
     [Fact]
-    public void Constructor_ShouldSetFailureCount()
+    public void Constructor_Ok()
     {
-        double failureRate = 0.5;
-        int expectedFailureCount = 10;
-        var context = new ResilienceContext();
-
-        var args = new BreakDurationGeneratorArguments(failureRate, expectedFailureCount, context);
-
-        args.FailureCount.Should().Be(expectedFailureCount);
-    }
-
-    [Fact]
-    public void Constructor_ShouldSetContext()
-    {
-        double failureRate = 0.5;
+        double expectedFailureRate = 0.5;
         int failureCount = 10;
-        var expectedContext = new ResilienceContext();
+        var context = new ResilienceContext();
 
-        var args = new BreakDurationGeneratorArguments(failureRate, failureCount, expectedContext);
+        var args = new BreakDurationGeneratorArguments(expectedFailureRate, failureCount, context, 99);
 
-        args.Context.Should().Be(expectedContext);
+        args.FailureRate.Should().Be(expectedFailureRate);
+        args.FailureCount.Should().Be(failureCount);
+        args.Context.Should().Be(context);
+        args.HalfOpenAttempts.Should().Be(99);
     }
 }
diff --git a/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs b/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs
index c7b086b0780..6ce574759e8 100644
--- a/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs
+++ b/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs
@@ -374,6 +374,41 @@ public async Task OnActionFailureAsync_EnsureBreakDurationGeneration()
         blockedTill.Should().Be(utcNow + TimeSpan.FromMinutes(42));
     }
 
+    [Fact]
+    public async Task BreakDurationGenerator_EnsureHalfOpenAttempts()
+    {
+        // arrange
+        var halfOpenAttempts = new List<int>();
+
+        _options.BreakDurationGenerator = args =>
+        {
+            halfOpenAttempts.Add(args.HalfOpenAttempts);
+            return new ValueTask<TimeSpan>(TimeSpan.Zero);
+        };
+
+        using var controller = CreateController();
+
+        // act
+        await TransitionToState(controller, CircuitState.Closed);
+
+        for (int i = 0; i < 5; i++)
+        {
+            await TransitionToState(controller, CircuitState.Open);
+            await TransitionToState(controller, CircuitState.HalfOpen);
+        }
+
+        await TransitionToState(controller, CircuitState.Closed);
+
+        for (int i = 0; i < 3; i++)
+        {
+            await TransitionToState(controller, CircuitState.Open);
+            await TransitionToState(controller, CircuitState.HalfOpen);
+        }
+
+        // assert
+        halfOpenAttempts.Should().BeEquivalentTo([0, 1, 2, 3, 4, 0, 1, 2]);
+    }
+
     [InlineData(true)]
     [InlineData(false)]
     [Theory]
@@ -502,6 +537,7 @@ private async Task TransitionToState(CircuitStateController<int> controller, Cir
         switch (state)
         {
             case CircuitState.Closed:
+                await controller.CloseCircuitAsync(ResilienceContextPool.Shared.Get());
                 break;
             case CircuitState.Open:
                 await OpenCircuit(controller);