@@ -12,6 +12,12 @@ namespace GVFS.UnitTests.Common
1212 [ TestFixture ]
1313 public class RetryWrapperTests
1414 {
15+ [ SetUp ]
16+ public void SetUp ( )
17+ {
18+ RetryCircuitBreaker . Reset ( ) ;
19+ }
20+
1521 [ TestCase ]
1622 [ Category ( CategoryConstants . ExceptionExpected ) ]
1723 public void WillRetryOnIOException ( )
@@ -233,5 +239,118 @@ public void WillRetryWhenRequested()
233239 actualTries . ShouldEqual ( ExpectedTries ) ;
234240 actualFailures . ShouldEqual ( ExpectedFailures ) ;
235241 }
242+
243+ [ TestCase ]
244+ [ Category ( CategoryConstants . ExceptionExpected ) ]
245+ public void CircuitBreakerOpensAfterConsecutiveFailures ( )
246+ {
247+ const int Threshold = 5 ;
248+ const int CooldownMs = 5000 ;
249+ RetryCircuitBreaker . Configure ( Threshold , CooldownMs ) ;
250+
251+ // Generate enough failures to trip the circuit breaker
252+ for ( int i = 0 ; i < Threshold ; i ++ )
253+ {
254+ RetryWrapper < bool > wrapper = new RetryWrapper < bool > ( 1 , CancellationToken . None , exponentialBackoffBase : 0 ) ;
255+ wrapper . Invoke ( tryCount => throw new IOException ( "simulated failure" ) ) ;
256+ }
257+
258+ RetryCircuitBreaker . IsOpen . ShouldBeTrue ( "Circuit breaker should be open after threshold failures" ) ;
259+
260+ // Next invocation should fail fast without calling the callback
261+ int callbackInvocations = 0 ;
262+ RetryWrapper < bool > dut = new RetryWrapper < bool > ( 5 , CancellationToken . None , exponentialBackoffBase : 0 ) ;
263+ RetryWrapper < bool > . InvocationResult result = dut . Invoke (
264+ tryCount =>
265+ {
266+ callbackInvocations ++ ;
267+ return new RetryWrapper < bool > . CallbackResult ( true ) ;
268+ } ) ;
269+
270+ result . Succeeded . ShouldEqual ( false ) ;
271+ callbackInvocations . ShouldEqual ( 0 ) ;
272+ }
273+
274+ [ TestCase ]
275+ public void CircuitBreakerResetsOnSuccess ( )
276+ {
277+ const int Threshold = 3 ;
278+ RetryCircuitBreaker . Configure ( Threshold , 30_000 ) ;
279+
280+ // Record failures just below threshold
281+ for ( int i = 0 ; i < Threshold - 1 ; i ++ )
282+ {
283+ RetryCircuitBreaker . RecordFailure ( ) ;
284+ }
285+
286+ RetryCircuitBreaker . IsOpen . ShouldBeFalse ( "Circuit should still be closed below threshold" ) ;
287+
288+ // A successful invocation resets the counter
289+ RetryWrapper < bool > dut = new RetryWrapper < bool > ( 1 , CancellationToken . None , exponentialBackoffBase : 0 ) ;
290+ dut . Invoke ( tryCount => new RetryWrapper < bool > . CallbackResult ( true ) ) ;
291+
292+ RetryCircuitBreaker . ConsecutiveFailures . ShouldEqual ( 0 ) ;
293+
294+ // Now threshold more failures are needed to trip it again
295+ for ( int i = 0 ; i < Threshold - 1 ; i ++ )
296+ {
297+ RetryCircuitBreaker . RecordFailure ( ) ;
298+ }
299+
300+ RetryCircuitBreaker . IsOpen . ShouldBeFalse ( "Circuit should still be closed after reset" ) ;
301+ }
302+
303+ [ TestCase ]
304+ public void CircuitBreakerIgnoresNonRetryableErrors ( )
305+ {
306+ const int Threshold = 3 ;
307+ RetryCircuitBreaker . Configure ( Threshold , 30_000 ) ;
308+
309+ // Generate non-retryable failures (e.g., 404/400) — these should NOT count
310+ for ( int i = 0 ; i < Threshold + 5 ; i ++ )
311+ {
312+ RetryWrapper < bool > wrapper = new RetryWrapper < bool > ( 1 , CancellationToken . None , exponentialBackoffBase : 0 ) ;
313+ wrapper . Invoke ( tryCount => new RetryWrapper < bool > . CallbackResult ( new Exception ( "404 Not Found" ) , shouldRetry : false ) ) ;
314+ }
315+
316+ RetryCircuitBreaker . IsOpen . ShouldBeFalse ( "Non-retryable errors should not trip the circuit breaker" ) ;
317+ RetryCircuitBreaker . ConsecutiveFailures . ShouldEqual ( 0 ) ;
318+ }
319+
320+ [ TestCase ]
321+ [ Category ( CategoryConstants . ExceptionExpected ) ]
322+ public void CircuitBreakerClosesAfterCooldown ( )
323+ {
324+ const int Threshold = 3 ;
325+ const int CooldownMs = 100 ; // Very short cooldown for testing
326+ RetryCircuitBreaker . Configure ( Threshold , CooldownMs ) ;
327+
328+ // Trip the circuit breaker
329+ for ( int i = 0 ; i < Threshold ; i ++ )
330+ {
331+ RetryWrapper < bool > wrapper = new RetryWrapper < bool > ( 1 , CancellationToken . None , exponentialBackoffBase : 0 ) ;
332+ wrapper . Invoke ( tryCount => throw new IOException ( "simulated failure" ) ) ;
333+ }
334+
335+ RetryCircuitBreaker . IsOpen . ShouldBeTrue ( "Circuit should be open" ) ;
336+
337+ // Wait for cooldown to expire
338+ Thread . Sleep ( CooldownMs + 50 ) ;
339+
340+ RetryCircuitBreaker . IsOpen . ShouldBeFalse ( "Circuit should be closed after cooldown" ) ;
341+
342+ // Should be able to invoke successfully now
343+ int callbackInvocations = 0 ;
344+ RetryWrapper < bool > dut = new RetryWrapper < bool > ( 1 , CancellationToken . None , exponentialBackoffBase : 0 ) ;
345+ RetryWrapper < bool > . InvocationResult result = dut . Invoke (
346+ tryCount =>
347+ {
348+ callbackInvocations ++ ;
349+ return new RetryWrapper < bool > . CallbackResult ( true ) ;
350+ } ) ;
351+
352+ result . Succeeded . ShouldEqual ( true ) ;
353+ callbackInvocations . ShouldEqual ( 1 ) ;
354+ }
236355 }
237356}
0 commit comments