anderson-ufrj commited on
Commit
6891803
·
1 Parent(s): 66488e3

test(infrastructure): add comprehensive tests for circuit breaker system

Browse files

- Test all circuit breaker states (CLOSED, OPEN, HALF_OPEN)
- Verify state transitions based on failure/success thresholds
- Test automatic recovery mechanism with configurable timeout
- Add tests for concurrent call handling
- Test both sync and async function protection
- Verify timeout handling and custom exceptions
- Test CircuitBreakerManager for multi-service management
- Add decorator tests for convenient usage
- Test health status monitoring across services
- Cover edge cases like race conditions and rapid state changes
- Verify statistics collection and reset functionality

tests/unit/infrastructure/__init__.py ADDED
File without changes
tests/unit/infrastructure/test_circuit_breaker.py ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for circuit breaker implementation.
3
+ Tests failure detection, state transitions, and recovery mechanisms.
4
+ """
5
+
6
+ import pytest
7
+ import asyncio
8
+ import time
9
+ from unittest.mock import Mock, AsyncMock, patch
10
+ from datetime import datetime, timedelta
11
+
12
+ from src.infrastructure.resilience.circuit_breaker import (
13
+ CircuitBreaker,
14
+ CircuitBreakerConfig,
15
+ CircuitBreakerManager,
16
+ CircuitState,
17
+ CircuitBreakerOpenException,
18
+ CircuitBreakerTimeoutException,
19
+ circuit_breaker,
20
+ circuit_breaker_manager
21
+ )
22
+
23
+
24
+ class MockException(Exception):
25
+ """Mock exception for testing."""
26
+ pass
27
+
28
+
29
+ async def async_success_function(value: int = 42) -> int:
30
+ """Async function that always succeeds."""
31
+ await asyncio.sleep(0.01)
32
+ return value
33
+
34
+
35
+ async def async_failure_function():
36
+ """Async function that always fails."""
37
+ await asyncio.sleep(0.01)
38
+ raise MockException("Intentional failure")
39
+
40
+
41
+ def sync_success_function(value: int = 42) -> int:
42
+ """Sync function that always succeeds."""
43
+ return value
44
+
45
+
46
+ def sync_failure_function():
47
+ """Sync function that always fails."""
48
+ raise MockException("Intentional failure")
49
+
50
+
51
+ async def async_slow_function(delay: float = 2.0) -> str:
52
+ """Async function that is slow."""
53
+ await asyncio.sleep(delay)
54
+ return "completed"
55
+
56
+
57
+ @pytest.fixture
58
+ def circuit_config():
59
+ """Create test circuit breaker config."""
60
+ return CircuitBreakerConfig(
61
+ failure_threshold=3,
62
+ recovery_timeout=1.0, # Short for tests
63
+ success_threshold=2,
64
+ timeout=0.5, # Short timeout for tests
65
+ expected_exception=MockException
66
+ )
67
+
68
+
69
+ @pytest.fixture
70
+ def circuit(circuit_config):
71
+ """Create test circuit breaker."""
72
+ return CircuitBreaker("test_service", circuit_config)
73
+
74
+
75
+ class TestCircuitBreakerConfig:
76
+ """Test CircuitBreakerConfig class."""
77
+
78
+ @pytest.mark.unit
79
+ def test_default_config(self):
80
+ """Test default configuration values."""
81
+ config = CircuitBreakerConfig()
82
+
83
+ assert config.failure_threshold == 5
84
+ assert config.recovery_timeout == 60.0
85
+ assert config.success_threshold == 3
86
+ assert config.timeout == 30.0
87
+ assert config.expected_exception == Exception
88
+
89
+ @pytest.mark.unit
90
+ def test_custom_config(self):
91
+ """Test custom configuration values."""
92
+ config = CircuitBreakerConfig(
93
+ failure_threshold=10,
94
+ recovery_timeout=120.0,
95
+ success_threshold=5,
96
+ timeout=60.0,
97
+ expected_exception=ValueError
98
+ )
99
+
100
+ assert config.failure_threshold == 10
101
+ assert config.recovery_timeout == 120.0
102
+ assert config.success_threshold == 5
103
+ assert config.timeout == 60.0
104
+ assert config.expected_exception == ValueError
105
+
106
+
107
+ class TestCircuitBreaker:
108
+ """Test CircuitBreaker class."""
109
+
110
+ @pytest.mark.unit
111
+ def test_initialization(self, circuit_config):
112
+ """Test circuit breaker initialization."""
113
+ breaker = CircuitBreaker("test_service", circuit_config)
114
+
115
+ assert breaker.name == "test_service"
116
+ assert breaker.config == circuit_config
117
+ assert breaker.state == CircuitState.CLOSED
118
+ assert breaker.stats.total_requests == 0
119
+ assert breaker.stats.successful_requests == 0
120
+ assert breaker.stats.failed_requests == 0
121
+
122
+ @pytest.mark.unit
123
+ async def test_successful_async_call(self, circuit):
124
+ """Test successful async function call."""
125
+ result = await circuit.call(async_success_function, 100)
126
+
127
+ assert result == 100
128
+ assert circuit.state == CircuitState.CLOSED
129
+ assert circuit.stats.total_requests == 1
130
+ assert circuit.stats.successful_requests == 1
131
+ assert circuit.stats.failed_requests == 0
132
+
133
+ @pytest.mark.unit
134
+ async def test_successful_sync_call(self, circuit):
135
+ """Test successful sync function call."""
136
+ result = await circuit.call(sync_success_function, 100)
137
+
138
+ assert result == 100
139
+ assert circuit.state == CircuitState.CLOSED
140
+ assert circuit.stats.total_requests == 1
141
+ assert circuit.stats.successful_requests == 1
142
+ assert circuit.stats.failed_requests == 0
143
+
144
+ @pytest.mark.unit
145
+ async def test_failed_call(self, circuit):
146
+ """Test failed function call."""
147
+ with pytest.raises(MockException):
148
+ await circuit.call(async_failure_function)
149
+
150
+ assert circuit.state == CircuitState.CLOSED # Not open yet
151
+ assert circuit.stats.total_requests == 1
152
+ assert circuit.stats.successful_requests == 0
153
+ assert circuit.stats.failed_requests == 1
154
+ assert circuit.stats.current_consecutive_failures == 1
155
+
156
+ @pytest.mark.unit
157
+ async def test_circuit_opens_after_threshold(self, circuit):
158
+ """Test circuit opens after failure threshold."""
159
+ # Fail 3 times to reach threshold
160
+ for _ in range(3):
161
+ with pytest.raises(MockException):
162
+ await circuit.call(async_failure_function)
163
+
164
+ assert circuit.state == CircuitState.OPEN
165
+ assert circuit.stats.failed_requests == 3
166
+ assert circuit.stats.current_consecutive_failures == 3
167
+ assert circuit.stats.state_changes == 1
168
+
169
+ @pytest.mark.unit
170
+ async def test_open_circuit_rejects_calls(self, circuit):
171
+ """Test open circuit rejects calls."""
172
+ # Open the circuit
173
+ for _ in range(3):
174
+ with pytest.raises(MockException):
175
+ await circuit.call(async_failure_function)
176
+
177
+ assert circuit.state == CircuitState.OPEN
178
+
179
+ # Try another call - should be rejected
180
+ with pytest.raises(CircuitBreakerOpenException) as exc_info:
181
+ await circuit.call(async_success_function)
182
+
183
+ assert "Circuit breaker 'test_service' is open" in str(exc_info.value)
184
+ assert circuit.stats.rejected_requests == 1
185
+ assert circuit.stats.total_requests == 4 # 3 failures + 1 rejected
186
+
187
+ @pytest.mark.unit
188
+ async def test_half_open_transition(self, circuit):
189
+ """Test transition to half-open state."""
190
+ # Open the circuit
191
+ for _ in range(3):
192
+ with pytest.raises(MockException):
193
+ await circuit.call(async_failure_function)
194
+
195
+ assert circuit.state == CircuitState.OPEN
196
+
197
+ # Wait for recovery timeout
198
+ await asyncio.sleep(1.1) # Recovery timeout is 1.0
199
+
200
+ # Next call should go through (half-open)
201
+ result = await circuit.call(async_success_function)
202
+
203
+ assert result == 42
204
+ assert circuit.state == CircuitState.HALF_OPEN
205
+ assert circuit.stats.current_consecutive_successes == 1
206
+
207
+ @pytest.mark.unit
208
+ async def test_half_open_to_closed(self, circuit):
209
+ """Test successful recovery from half-open to closed."""
210
+ # Open the circuit
211
+ for _ in range(3):
212
+ with pytest.raises(MockException):
213
+ await circuit.call(async_failure_function)
214
+
215
+ # Wait for recovery
216
+ await asyncio.sleep(1.1)
217
+
218
+ # Two successful calls to close circuit (success_threshold=2)
219
+ await circuit.call(async_success_function)
220
+ assert circuit.state == CircuitState.HALF_OPEN
221
+
222
+ await circuit.call(async_success_function)
223
+ assert circuit.state == CircuitState.CLOSED
224
+ assert circuit.stats.state_changes == 2 # CLOSED->OPEN->CLOSED
225
+
226
+ @pytest.mark.unit
227
+ async def test_half_open_to_open(self, circuit):
228
+ """Test failure in half-open state reopens circuit."""
229
+ # Open the circuit
230
+ for _ in range(3):
231
+ with pytest.raises(MockException):
232
+ await circuit.call(async_failure_function)
233
+
234
+ # Wait for recovery
235
+ await asyncio.sleep(1.1)
236
+
237
+ # Successful call puts in half-open
238
+ await circuit.call(async_success_function)
239
+ assert circuit.state == CircuitState.HALF_OPEN
240
+
241
+ # Failed call reopens circuit
242
+ with pytest.raises(MockException):
243
+ await circuit.call(async_failure_function)
244
+
245
+ assert circuit.state == CircuitState.OPEN
246
+
247
+ @pytest.mark.unit
248
+ async def test_timeout_handling(self, circuit):
249
+ """Test request timeout handling."""
250
+ with pytest.raises(CircuitBreakerTimeoutException) as exc_info:
251
+ await circuit.call(async_slow_function, 2.0) # 2s delay, 0.5s timeout
252
+
253
+ assert "timed out after 0.5s" in str(exc_info.value)
254
+ assert circuit.stats.failed_requests == 1
255
+ assert circuit.state == CircuitState.CLOSED # One failure, not at threshold
256
+
257
+ @pytest.mark.unit
258
+ async def test_unexpected_exception_passthrough(self, circuit):
259
+ """Test unexpected exceptions pass through."""
260
+ async def unexpected_error():
261
+ raise ValueError("Unexpected error")
262
+
263
+ # ValueError is not the expected exception type
264
+ with pytest.raises(ValueError):
265
+ await circuit.call(unexpected_error)
266
+
267
+ # Should not count as circuit breaker failure
268
+ assert circuit.stats.failed_requests == 0
269
+ assert circuit.stats.total_requests == 1
270
+
271
+ @pytest.mark.unit
272
+ async def test_reset_circuit(self, circuit):
273
+ """Test manual circuit reset."""
274
+ # Open the circuit
275
+ for _ in range(3):
276
+ with pytest.raises(MockException):
277
+ await circuit.call(async_failure_function)
278
+
279
+ assert circuit.state == CircuitState.OPEN
280
+
281
+ # Reset circuit
282
+ await circuit.reset()
283
+
284
+ assert circuit.state == CircuitState.CLOSED
285
+ assert circuit.stats.current_consecutive_failures == 0
286
+ assert circuit.stats.current_consecutive_successes == 0
287
+
288
+ @pytest.mark.unit
289
+ async def test_force_open(self, circuit):
290
+ """Test forcing circuit to open state."""
291
+ await circuit.force_open()
292
+
293
+ assert circuit.state == CircuitState.OPEN
294
+
295
+ # Should reject calls
296
+ with pytest.raises(CircuitBreakerOpenException):
297
+ await circuit.call(async_success_function)
298
+
299
+ @pytest.mark.unit
300
+ def test_get_stats(self, circuit):
301
+ """Test getting circuit breaker statistics."""
302
+ stats = circuit.get_stats()
303
+
304
+ assert stats["name"] == "test_service"
305
+ assert stats["state"] == "closed"
306
+ assert stats["config"]["failure_threshold"] == 3
307
+ assert stats["stats"]["total_requests"] == 0
308
+ assert stats["stats"]["success_rate"] == 0
309
+
310
+ @pytest.mark.unit
311
+ async def test_concurrent_calls(self, circuit):
312
+ """Test concurrent calls through circuit breaker."""
313
+ # Run multiple concurrent successful calls
314
+ results = await asyncio.gather(
315
+ circuit.call(async_success_function, 1),
316
+ circuit.call(async_success_function, 2),
317
+ circuit.call(async_success_function, 3)
318
+ )
319
+
320
+ assert results == [1, 2, 3]
321
+ assert circuit.stats.total_requests == 3
322
+ assert circuit.stats.successful_requests == 3
323
+ assert circuit.state == CircuitState.CLOSED
324
+
325
+
326
+ class TestCircuitBreakerManager:
327
+ """Test CircuitBreakerManager class."""
328
+
329
+ @pytest.mark.unit
330
+ def test_manager_initialization(self):
331
+ """Test circuit breaker manager initialization."""
332
+ manager = CircuitBreakerManager()
333
+
334
+ assert len(manager._breakers) == 0
335
+ assert len(manager._default_configs) == 0
336
+
337
+ @pytest.mark.unit
338
+ def test_register_default_config(self):
339
+ """Test registering default configuration."""
340
+ manager = CircuitBreakerManager()
341
+ config = CircuitBreakerConfig(failure_threshold=10)
342
+
343
+ manager.register_default_config("test_service", config)
344
+
345
+ assert "test_service" in manager._default_configs
346
+ assert manager._default_configs["test_service"] == config
347
+
348
+ @pytest.mark.unit
349
+ def test_get_circuit_breaker(self):
350
+ """Test getting circuit breaker."""
351
+ manager = CircuitBreakerManager()
352
+
353
+ breaker1 = manager.get_circuit_breaker("service1")
354
+ breaker2 = manager.get_circuit_breaker("service1")
355
+ breaker3 = manager.get_circuit_breaker("service2")
356
+
357
+ assert breaker1 is breaker2 # Same instance
358
+ assert breaker1 is not breaker3 # Different services
359
+ assert len(manager._breakers) == 2
360
+
361
+ @pytest.mark.unit
362
+ def test_get_circuit_breaker_with_default_config(self):
363
+ """Test getting circuit breaker with default config."""
364
+ manager = CircuitBreakerManager()
365
+ config = CircuitBreakerConfig(failure_threshold=10)
366
+
367
+ manager.register_default_config("test_service", config)
368
+ breaker = manager.get_circuit_breaker("test_service")
369
+
370
+ assert breaker.config.failure_threshold == 10
371
+
372
+ @pytest.mark.unit
373
+ async def test_call_service(self):
374
+ """Test calling service through manager."""
375
+ manager = CircuitBreakerManager()
376
+
377
+ result = await manager.call_service(
378
+ "test_service",
379
+ async_success_function,
380
+ 100
381
+ )
382
+
383
+ assert result == 100
384
+ assert "test_service" in manager._breakers
385
+
386
+ @pytest.mark.unit
387
+ def test_get_all_stats(self):
388
+ """Test getting all circuit breaker stats."""
389
+ manager = CircuitBreakerManager()
390
+
391
+ manager.get_circuit_breaker("service1")
392
+ manager.get_circuit_breaker("service2")
393
+
394
+ stats = manager.get_all_stats()
395
+
396
+ assert len(stats) == 2
397
+ assert "service1" in stats
398
+ assert "service2" in stats
399
+ assert stats["service1"]["name"] == "service1"
400
+
401
+ @pytest.mark.unit
402
+ async def test_reset_all(self):
403
+ """Test resetting all circuit breakers."""
404
+ manager = CircuitBreakerManager()
405
+
406
+ # Create and open multiple breakers
407
+ for service in ["service1", "service2"]:
408
+ breaker = manager.get_circuit_breaker(service)
409
+ await breaker.force_open()
410
+
411
+ # Reset all
412
+ await manager.reset_all()
413
+
414
+ # Check all are closed
415
+ for breaker in manager._breakers.values():
416
+ assert breaker.state == CircuitState.CLOSED
417
+
418
+ @pytest.mark.unit
419
+ async def test_get_health_status(self):
420
+ """Test getting health status of all services."""
421
+ manager = CircuitBreakerManager()
422
+
423
+ # Create breakers in different states
424
+ breaker1 = manager.get_circuit_breaker("healthy_service")
425
+
426
+ breaker2 = manager.get_circuit_breaker("degraded_service")
427
+ breaker2.state = CircuitState.HALF_OPEN
428
+
429
+ breaker3 = manager.get_circuit_breaker("failed_service")
430
+ await breaker3.force_open()
431
+
432
+ health = manager.get_health_status()
433
+
434
+ assert health["overall_health"] == "degraded"
435
+ assert health["total_services"] == 3
436
+ assert "healthy_service" in health["healthy_services"]
437
+ assert "degraded_service" in health["degraded_services"]
438
+ assert "failed_service" in health["failed_services"]
439
+ assert health["health_score"] == pytest.approx(1/3)
440
+
441
+
442
+ class TestCircuitBreakerDecorator:
443
+ """Test circuit breaker decorator."""
444
+
445
+ @pytest.mark.unit
446
+ async def test_decorator_basic(self):
447
+ """Test basic decorator usage."""
448
+ @circuit_breaker("decorated_service")
449
+ async def decorated_function(value: int) -> int:
450
+ return value * 2
451
+
452
+ result = await decorated_function(5)
453
+
454
+ assert result == 10
455
+
456
+ # Check breaker was created
457
+ stats = circuit_breaker_manager.get_all_stats()
458
+ assert "decorated_service" in stats
459
+
460
+ @pytest.mark.unit
461
+ async def test_decorator_with_config(self):
462
+ """Test decorator with custom config."""
463
+ config = CircuitBreakerConfig(failure_threshold=2)
464
+
465
+ @circuit_breaker("custom_service", config)
466
+ async def failing_function():
467
+ raise MockException("Fail")
468
+
469
+ # Fail twice to open circuit
470
+ for _ in range(2):
471
+ with pytest.raises(MockException):
472
+ await failing_function()
473
+
474
+ # Check circuit is open
475
+ breaker = circuit_breaker_manager.get_circuit_breaker("custom_service")
476
+ assert breaker.state == CircuitState.OPEN
477
+
478
+
479
+ class TestGlobalCircuitBreakerManager:
480
+ """Test global circuit breaker manager instance."""
481
+
482
+ @pytest.mark.unit
483
+ def test_default_configurations(self):
484
+ """Test default configurations are registered."""
485
+ # Check some default services are configured
486
+ assert "transparency_api" in circuit_breaker_manager._default_configs
487
+ assert "llm_service" in circuit_breaker_manager._default_configs
488
+ assert "database" in circuit_breaker_manager._default_configs
489
+ assert "redis" in circuit_breaker_manager._default_configs
490
+
491
+ # Check configuration values
492
+ transparency_config = circuit_breaker_manager._default_configs["transparency_api"]
493
+ assert transparency_config.failure_threshold == 3
494
+ assert transparency_config.timeout == 15.0
495
+
496
+
497
+ class TestCircuitBreakerEdgeCases:
498
+ """Test edge cases and error scenarios."""
499
+
500
+ @pytest.mark.unit
501
+ async def test_multiple_concurrent_failures(self, circuit):
502
+ """Test handling multiple concurrent failures."""
503
+ # Create concurrent failing calls
504
+ tasks = []
505
+ for _ in range(5):
506
+ tasks.append(circuit.call(async_failure_function))
507
+
508
+ # All should fail
509
+ results = await asyncio.gather(*tasks, return_exceptions=True)
510
+
511
+ assert all(isinstance(r, MockException) for r in results)
512
+ assert circuit.state == CircuitState.OPEN
513
+ assert circuit.stats.failed_requests >= 3 # At least threshold
514
+
515
+ @pytest.mark.unit
516
+ async def test_race_condition_state_change(self, circuit):
517
+ """Test race condition during state change."""
518
+ # Bring circuit to edge of opening (2 failures, threshold is 3)
519
+ for _ in range(2):
520
+ with pytest.raises(MockException):
521
+ await circuit.call(async_failure_function)
522
+
523
+ # Concurrent calls - one fails, one succeeds
524
+ results = await asyncio.gather(
525
+ circuit.call(async_failure_function),
526
+ circuit.call(async_success_function),
527
+ return_exceptions=True
528
+ )
529
+
530
+ # Circuit state should be consistent
531
+ assert circuit.state in [CircuitState.CLOSED, CircuitState.OPEN]
532
+ assert circuit.stats.total_requests == 4
533
+
534
+ @pytest.mark.unit
535
+ async def test_very_short_recovery_timeout(self):
536
+ """Test very short recovery timeout."""
537
+ config = CircuitBreakerConfig(
538
+ failure_threshold=1,
539
+ recovery_timeout=0.01, # Very short
540
+ success_threshold=1
541
+ )
542
+ breaker = CircuitBreaker("fast_recovery", config)
543
+
544
+ # Open circuit
545
+ with pytest.raises(MockException):
546
+ await breaker.call(async_failure_function)
547
+
548
+ assert breaker.state == CircuitState.OPEN
549
+
550
+ # Wait for recovery
551
+ await asyncio.sleep(0.02)
552
+
553
+ # Should be able to try again
554
+ result = await breaker.call(async_success_function)
555
+ assert result == 42