📦 EqualifyEverything / equalify-reflow

📄 test_circuit_breaker.py · 426 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426"""Unit tests for circuit breaker implementation."""

import time

import pytest
from src.utils.circuit_breaker import CircuitBreaker, CircuitBreakerConfig, CircuitBreakerOpenError, CircuitState


@pytest.mark.unit
class TestCircuitBreakerStates:
    """Test circuit breaker state transitions."""

    def test_initial_state_is_closed(self):
        """Circuit breaker should start in CLOSED state."""
        breaker = CircuitBreaker("test")
        assert breaker.state == CircuitState.CLOSED
        assert breaker.is_closed
        assert not breaker.is_open
        assert not breaker.is_half_open

    def test_closed_to_open_on_failures(self):
        """Circuit should open after reaching failure threshold."""
        breaker = CircuitBreaker("test", failure_threshold=3)

        # Record failures
        breaker.record_failure()
        assert breaker.is_closed  # Still closed (1/3)

        breaker.record_failure()
        assert breaker.is_closed  # Still closed (2/3)

        breaker.record_failure()
        assert breaker.is_open    # Now open (3/3)

    def test_success_resets_failure_count_in_closed(self):
        """Success in CLOSED state should reset failure count."""
        breaker = CircuitBreaker("test", failure_threshold=3)

        breaker.record_failure()
        breaker.record_failure()
        assert breaker.is_closed

        # Success resets counter
        breaker.record_success()

        # Should take 3 more failures to open
        breaker.record_failure()
        breaker.record_failure()
        assert breaker.is_closed  # Still closed

        breaker.record_failure()
        assert breaker.is_open    # Now opens

    def test_open_to_half_open_after_timeout(self):
        """Circuit should transition to HALF_OPEN after timeout."""
        breaker = CircuitBreaker("test", failure_threshold=2, timeout=0.1)

        # Trigger open state
        breaker.record_failure()
        breaker.record_failure()
        assert breaker.is_open

        # Wait for timeout
        time.sleep(0.15)

        # Check state (triggers timeout check)
        assert breaker.is_half_open

    def test_half_open_to_closed_on_successes(self):
        """Circuit should close after enough successes in HALF_OPEN."""
        breaker = CircuitBreaker(
            "test",
            failure_threshold=2,
            success_threshold=2,
            timeout=0.1
        )

        # Open circuit
        breaker.record_failure()
        breaker.record_failure()
        assert breaker.is_open

        # Wait for timeout
        time.sleep(0.15)
        assert breaker.is_half_open

        # First success
        breaker.check_state()  # Allow call
        breaker.record_success()
        assert breaker.is_half_open  # Still half-open (1/2)

        # Second success -> closes
        breaker.check_state()
        breaker.record_success()
        assert breaker.is_closed

    def test_half_open_to_open_on_failure(self):
        """Failure in HALF_OPEN should immediately reopen circuit."""
        breaker = CircuitBreaker("test", failure_threshold=2, timeout=0.1)

        # Open circuit
        breaker.record_failure()
        breaker.record_failure()
        assert breaker.is_open

        # Transition to half-open
        time.sleep(0.15)
        assert breaker.is_half_open

        # Failure immediately reopens
        breaker.check_state()
        breaker.record_failure()
        assert breaker.is_open


@pytest.mark.unit
class TestCircuitBreakerBlocking:
    """Test circuit breaker request blocking behavior."""

    def test_check_state_passes_when_closed(self):
        """check_state() should not raise when circuit is closed."""
        breaker = CircuitBreaker("test")
        breaker.check_state()  # Should not raise

    def test_check_state_raises_when_open(self):
        """check_state() should raise CircuitBreakerOpenError when open."""
        breaker = CircuitBreaker("test", failure_threshold=1)

        breaker.record_failure()
        assert breaker.is_open

        with pytest.raises(CircuitBreakerOpenError) as exc_info:
            breaker.check_state()

        assert "test" in str(exc_info.value)
        assert "is open" in str(exc_info.value)

    def test_half_open_allows_limited_concurrent_calls(self):
        """HALF_OPEN should allow only half_open_max_calls concurrent calls."""
        breaker = CircuitBreaker(
            "test",
            failure_threshold=1,
            timeout=0.1,
            config=CircuitBreakerConfig(half_open_max_calls=2)
        )

        # Open circuit
        breaker.record_failure()
        assert breaker.is_open

        # Transition to half-open
        time.sleep(0.15)
        assert breaker.is_half_open

        # First call allowed
        breaker.check_state()

        # Second call allowed
        breaker.check_state()

        # Third call blocked (max is 2)
        with pytest.raises(CircuitBreakerOpenError) as exc_info:
            breaker.check_state()

        assert "half-open" in str(exc_info.value).lower()
        assert "max concurrent calls" in str(exc_info.value).lower()

    def test_half_open_calls_decrement_on_completion(self):
        """Half-open call count should decrement after success/failure."""
        breaker = CircuitBreaker(
            "test",
            failure_threshold=1,
            timeout=0.1,
            config=CircuitBreakerConfig(half_open_max_calls=1)
        )

        # Open and transition to half-open
        breaker.record_failure()
        time.sleep(0.15)
        assert breaker.is_half_open

        # Start call
        breaker.check_state()

        # Complete with success
        breaker.record_success()

        # Should allow another call now
        breaker.check_state()  # Should not raise


@pytest.mark.unit
class TestCircuitBreakerConfiguration:
    """Test circuit breaker configuration options."""

    def test_custom_failure_threshold(self):
        """Test custom failure threshold configuration."""
        breaker = CircuitBreaker("test", failure_threshold=10)

        for i in range(9):
            breaker.record_failure()
            assert breaker.is_closed

        breaker.record_failure()  # 10th failure
        assert breaker.is_open

    def test_custom_success_threshold(self):
        """Test custom success threshold in half-open state."""
        breaker = CircuitBreaker(
            "test",
            failure_threshold=1,
            success_threshold=3,
            timeout=0.1
        )

        # Open circuit
        breaker.record_failure()
        time.sleep(0.15)
        assert breaker.is_half_open

        # Need 3 successes to close
        breaker.check_state()
        breaker.record_success()
        assert breaker.is_half_open

        breaker.check_state()
        breaker.record_success()
        assert breaker.is_half_open

        breaker.check_state()
        breaker.record_success()
        assert breaker.is_closed

    def test_custom_timeout(self):
        """Test custom timeout configuration."""
        breaker = CircuitBreaker("test", failure_threshold=1, timeout=0.5)

        breaker.record_failure()
        assert breaker.is_open

        # Too early
        time.sleep(0.2)
        assert breaker.is_open

        # After timeout
        time.sleep(0.4)
        assert breaker.is_half_open

    def test_config_object(self):
        """Test using CircuitBreakerConfig object."""
        config = CircuitBreakerConfig(
            failure_threshold=7,
            success_threshold=3,
            timeout=120.0,
            half_open_max_calls=5
        )

        breaker = CircuitBreaker("test", config=config)

        stats = breaker.get_stats()
        assert stats['config']['failure_threshold'] == 7
        assert stats['config']['success_threshold'] == 3
        assert stats['config']['timeout'] == 120.0
        assert stats['config']['half_open_max_calls'] == 5


@pytest.mark.unit
class TestCircuitBreakerReset:
    """Test manual reset functionality."""

    def test_manual_reset_from_open(self):
        """Manual reset should close circuit from OPEN state."""
        breaker = CircuitBreaker("test", failure_threshold=1)

        breaker.record_failure()
        assert breaker.is_open

        breaker.reset()
        assert breaker.is_closed

    def test_manual_reset_clears_counters(self):
        """Reset should clear failure and success counters."""
        breaker = CircuitBreaker("test", failure_threshold=5)

        breaker.record_failure()
        breaker.record_failure()
        breaker.record_failure()

        breaker.reset()

        stats = breaker.get_stats()
        assert stats['failure_count'] == 0
        assert stats['success_count'] == 0
        assert stats['last_failure_time'] is None


@pytest.mark.unit
class TestCircuitBreakerStats:
    """Test statistics and monitoring functionality."""

    def test_get_stats_returns_current_state(self):
        """get_stats() should return current circuit state."""
        breaker = CircuitBreaker("test-circuit")

        stats = breaker.get_stats()

        assert stats['name'] == "test-circuit"
        assert stats['state'] == CircuitState.CLOSED.value
        assert stats['failure_count'] == 0
        assert stats['success_count'] == 0
        assert stats['last_failure_time'] is None

    def test_get_stats_includes_failure_count(self):
        """Stats should include current failure count."""
        breaker = CircuitBreaker("test", failure_threshold=5)

        breaker.record_failure()
        breaker.record_failure()

        stats = breaker.get_stats()
        assert stats['failure_count'] == 2
        assert stats['last_failure_time'] is not None

    def test_get_stats_includes_config(self):
        """Stats should include configuration values."""
        breaker = CircuitBreaker(
            "test",
            failure_threshold=10,
            success_threshold=3,
            timeout=90.0
        )

        stats = breaker.get_stats()

        assert stats['config']['failure_threshold'] == 10
        assert stats['config']['success_threshold'] == 3
        assert stats['config']['timeout'] == 90.0


@pytest.mark.unit
class TestCircuitBreakerThreadSafety:
    """Test thread safety of circuit breaker operations.

    Note: These are basic tests. Full thread-safety testing would require
    concurrent operations from multiple threads.
    """

    def test_concurrent_check_state_safe(self):
        """Multiple check_state() calls should be safe."""
        breaker = CircuitBreaker("test")

        # Should not raise due to race conditions
        for _ in range(100):
            breaker.check_state()

    def test_concurrent_record_operations_safe(self):
        """Concurrent record_success/record_failure should be safe."""
        breaker = CircuitBreaker("test", failure_threshold=50)

        # Simulate mixed success/failure operations
        for i in range(100):
            if i % 3 == 0:
                breaker.record_success()
            else:
                breaker.record_failure()

        # Should have consistent state
        stats = breaker.get_stats()
        assert isinstance(stats['failure_count'], int)
        assert stats['state'] in [s.value for s in CircuitState]


@pytest.mark.unit
class TestCircuitBreakerEdgeCases:
    """Test edge cases and error conditions."""

    def test_zero_failure_threshold_not_recommended(self):
        """Circuit with zero threshold opens on first failure."""
        breaker = CircuitBreaker("test", failure_threshold=0)
        assert breaker.is_closed  # Starts closed

        # First failure opens immediately (0 threshold)
        breaker.record_failure()
        assert breaker.is_open

    def test_very_short_timeout(self):
        """Circuit should handle very short timeouts."""
        breaker = CircuitBreaker("test", failure_threshold=1, timeout=0.01)

        breaker.record_failure()
        assert breaker.is_open

        time.sleep(0.02)
        assert breaker.is_half_open

    def test_state_property_triggers_update(self):
        """Accessing .state property should trigger timeout check."""
        breaker = CircuitBreaker("test", failure_threshold=1, timeout=0.1)

        breaker.record_failure()
        assert breaker.state == CircuitState.OPEN

        time.sleep(0.15)

        # Accessing state should trigger transition
        assert breaker.state == CircuitState.HALF_OPEN

    def test_multiple_resets_safe(self):
        """Multiple resets should be safe."""
        breaker = CircuitBreaker("test")

        for _ in range(10):
            breaker.reset()
            assert breaker.is_closed

    def test_success_in_closed_with_no_failures(self):
        """Recording success when no failures is safe."""
        breaker = CircuitBreaker("test")

        breaker.record_success()
        breaker.record_success()

        assert breaker.is_closed
        stats = breaker.get_stats()
        assert stats['failure_count'] == 0