44
55namespace Bizkit \CircuitBreakerBundle \Tests \HttpClient ;
66
7+ use Bizkit \CircuitBreakerBundle \FailureChecker \DefaultFailureChecker ;
8+ use Bizkit \CircuitBreakerBundle \FailureChecker \FailureCheckerInterface ;
79use Bizkit \CircuitBreakerBundle \HttpClient \CircuitBreakerHttpClient ;
810use GabrielAnhaia \PhpCircuitBreaker \CircuitBreaker ;
911use GabrielAnhaia \PhpCircuitBreaker \CircuitBreakerConfig ;
1719use Symfony \Component \HttpClient \Exception \ServerException ;
1820use Symfony \Component \HttpClient \Exception \TransportException ;
1921use Symfony \Component \HttpClient \MockHttpClient ;
22+ use Symfony \Component \HttpClient \Response \AsyncContext ;
2023use Symfony \Component \HttpClient \Response \MockResponse ;
24+ use Symfony \Contracts \HttpClient \ChunkInterface ;
2125
2226#[CoversClass(CircuitBreakerHttpClient::class)]
2327final class CircuitBreakerHttpClientTest extends TestCase
@@ -35,6 +39,7 @@ public function testRecordsSuccessForSuccessfulResponseAfterBodyCompletes(array
3539 $ client = new CircuitBreakerHttpClient (
3640 new MockHttpClient (new MockResponse ('ok ' , ['http_code ' => 200 ])),
3741 $ circuitBreaker ,
42+ new DefaultFailureChecker (),
3843 'api ' ,
3944 );
4045
@@ -54,6 +59,7 @@ public function testRecordsSuccessForClientErrorResponseAfterBodyCompletes(): vo
5459 $ client = new CircuitBreakerHttpClient (
5560 new MockHttpClient (new MockResponse ('not found ' , ['http_code ' => 404 ])),
5661 $ circuitBreaker ,
62+ new DefaultFailureChecker (),
5763 'api ' ,
5864 );
5965
@@ -77,20 +83,98 @@ public function testRecordsFailureForServerErrorResponse(array $options, string
7783 $ client = new CircuitBreakerHttpClient (
7884 new MockHttpClient (new MockResponse ('' , ['http_code ' => 500 ])),
7985 $ circuitBreaker ,
86+ new DefaultFailureChecker (),
8087 'api ' ,
8188 );
8289
8390 self ::assertSame (500 , $ client ->request ('GET ' , 'https://example.com ' , $ options )->getStatusCode ());
8491 self ::assertSame (CircuitState::OPEN , $ circuitBreaker ->getState ($ serviceName ));
8592 }
8693
94+ public function testInjectedFailureCheckerCanRecordClientErrorAsFailure (): void
95+ {
96+ $ storage = new InMemoryStorage ();
97+ $ circuitBreaker = new CircuitBreaker ($ storage , new CircuitBreakerConfig (failureThreshold: 1 ));
98+ $ failureChecker = new class implements FailureCheckerInterface {
99+ public function __invoke (ChunkInterface $ chunk , AsyncContext $ context , string $ serviceName ): bool
100+ {
101+ return $ chunk ->isFirst () && $ context ->getStatusCode () >= 400 ;
102+ }
103+ };
104+ $ client = new CircuitBreakerHttpClient (
105+ new MockHttpClient (new MockResponse ('' , ['http_code ' => 404 ])),
106+ $ circuitBreaker ,
107+ $ failureChecker ,
108+ 'api ' ,
109+ );
110+
111+ self ::assertSame (404 , $ client ->request ('GET ' , 'https://example.com ' )->getStatusCode ());
112+ self ::assertSame (CircuitState::OPEN , $ circuitBreaker ->getState ('api ' ));
113+ }
114+
115+ public function testInjectedFailureCheckerCanIgnoreServerError (): void
116+ {
117+ $ storage = new InMemoryStorage ();
118+ $ storage ->setHalfOpen ('api ' , 30 );
119+ $ circuitBreaker = new CircuitBreaker ($ storage , new CircuitBreakerConfig (failureThreshold: 1 ));
120+ $ failureChecker = new class implements FailureCheckerInterface {
121+ public function __invoke (ChunkInterface $ chunk , AsyncContext $ context , string $ serviceName ): bool
122+ {
123+ return false ;
124+ }
125+ };
126+ $ client = new CircuitBreakerHttpClient (
127+ new MockHttpClient (new MockResponse ('server error ' , ['http_code ' => 500 ])),
128+ $ circuitBreaker ,
129+ $ failureChecker ,
130+ 'api ' ,
131+ );
132+
133+ $ response = $ client ->request ('GET ' , 'https://example.com ' );
134+
135+ self ::assertSame (500 , $ response ->getStatusCode ());
136+ self ::assertSame (CircuitState::HALF_OPEN , $ circuitBreaker ->getState ('api ' ));
137+ self ::assertSame ('server error ' , $ response ->getContent (false ));
138+ self ::assertSame (CircuitState::CLOSED , $ circuitBreaker ->getState ('api ' ));
139+ }
140+
141+ public function testRequestFailureCheckerOverridesInjectedFailureChecker (): void
142+ {
143+ $ storage = new InMemoryStorage ();
144+ $ circuitBreaker = new CircuitBreaker ($ storage , new CircuitBreakerConfig (failureThreshold: 1 ));
145+ $ failureChecker = new class implements FailureCheckerInterface {
146+ public function __invoke (ChunkInterface $ chunk , AsyncContext $ context , string $ serviceName ): bool
147+ {
148+ return false ;
149+ }
150+ };
151+ $ client = new CircuitBreakerHttpClient (
152+ new MockHttpClient (new MockResponse ('' , ['http_code ' => 404 ])),
153+ $ circuitBreaker ,
154+ $ failureChecker ,
155+ 'api ' ,
156+ );
157+
158+ $ response = $ client ->request ('GET ' , 'https://example.com ' , [
159+ 'extra ' => [
160+ 'circuit_breaker ' => [
161+ 'failure_checker ' => static fn (ChunkInterface $ chunk , AsyncContext $ context , string $ serviceName ): bool => $ chunk ->isFirst () && 404 === $ context ->getStatusCode (),
162+ ],
163+ ],
164+ ]);
165+
166+ self ::assertSame (404 , $ response ->getStatusCode ());
167+ self ::assertSame (CircuitState::OPEN , $ circuitBreaker ->getState ('api ' ));
168+ }
169+
87170 public function testRecordsFailureForServerErrorResponseWhenStreaming (): void
88171 {
89172 $ storage = new InMemoryStorage ();
90173 $ circuitBreaker = new CircuitBreaker ($ storage , new CircuitBreakerConfig (failureThreshold: 1 ));
91174 $ client = new CircuitBreakerHttpClient (
92175 new MockHttpClient (new MockResponse ('error ' , ['http_code ' => 500 ])),
93176 $ circuitBreaker ,
177+ new DefaultFailureChecker (),
94178 'api ' ,
95179 );
96180 $ response = $ client ->request ('GET ' , 'https://example.com ' );
@@ -115,6 +199,7 @@ public function testRecordsFailureForTransportException(MockResponse $response):
115199 $ client = new CircuitBreakerHttpClient (
116200 new MockHttpClient ($ response ),
117201 $ circuitBreaker ,
202+ new DefaultFailureChecker (),
118203 'api ' ,
119204 );
120205
@@ -134,6 +219,7 @@ public function testRecordsFailureOnlyOnceWhenServerErrorResponseAlsoFailsWhileS
134219 $ client = new CircuitBreakerHttpClient (
135220 new MockHttpClient (new MockResponse ([new TransportException ('Network failure ' )], ['http_code ' => 500 ])),
136221 $ circuitBreaker ,
222+ new DefaultFailureChecker (),
137223 'api ' ,
138224 );
139225
@@ -158,7 +244,7 @@ public function testReturnsSyntheticServiceUnavailableResponseWithoutCallingDeco
158244 $ circuitBreaker ->forceState ($ serviceName , CircuitState::OPEN );
159245 $ innerClient = new MockHttpClient ();
160246
161- $ client = new CircuitBreakerHttpClient ($ innerClient , $ circuitBreaker , 'api ' );
247+ $ client = new CircuitBreakerHttpClient ($ innerClient , $ circuitBreaker , new DefaultFailureChecker (), 'api ' );
162248 $ response = $ client ->request ('GET ' , 'https://example.com ' , $ options );
163249
164250 self ::assertSame (503 , $ response ->getStatusCode ());
@@ -174,7 +260,7 @@ public function testThrowsWhenCircuitIsOpenAndExceptionsAreEnabled(): void
174260 $ circuitBreaker = new CircuitBreaker ($ storage , new CircuitBreakerConfig (exceptionsEnabled: true ));
175261 $ circuitBreaker ->forceState ('api ' , CircuitState::OPEN );
176262
177- $ client = new CircuitBreakerHttpClient (new MockHttpClient (), $ circuitBreaker , 'api ' );
263+ $ client = new CircuitBreakerHttpClient (new MockHttpClient (), $ circuitBreaker , new DefaultFailureChecker (), 'api ' );
178264
179265 $ this ->expectException (OpenCircuitException::class);
180266
@@ -187,7 +273,7 @@ public function testResetResetsDecoratedClientWithoutClearingCircuitStorage(): v
187273 $ circuitBreaker = new CircuitBreaker ($ storage , new CircuitBreakerConfig (failureThreshold: 1 ));
188274 $ innerClient = new MockHttpClient (new MockResponse ());
189275
190- $ client = new CircuitBreakerHttpClient ($ innerClient , $ circuitBreaker , 'api ' );
276+ $ client = new CircuitBreakerHttpClient ($ innerClient , $ circuitBreaker , new DefaultFailureChecker (), 'api ' );
191277 $ client ->request ('GET ' , 'https://example.com ' )->getStatusCode ();
192278 self ::assertSame (1 , $ innerClient ->getRequestsCount ());
193279
@@ -198,6 +284,31 @@ public function testResetResetsDecoratedClientWithoutClearingCircuitStorage(): v
198284 self ::assertSame (0 , $ innerClient ->getRequestsCount ());
199285 }
200286
287+ public function testRejectsInvalidRequestFailureChecker (): void
288+ {
289+ $ storage = new InMemoryStorage ();
290+ $ client = new CircuitBreakerHttpClient (
291+ new MockHttpClient (new MockResponse ('ok ' )),
292+ new CircuitBreaker ($ storage ),
293+ new DefaultFailureChecker (),
294+ 'api ' ,
295+ );
296+
297+ /** @var array<string, mixed> $options */
298+ $ options = [
299+ 'extra ' => [
300+ 'circuit_breaker ' => [
301+ 'failure_checker ' => 'not_a_function ' ,
302+ ],
303+ ],
304+ ];
305+
306+ $ this ->expectException (InvalidArgumentException::class);
307+ $ this ->expectExceptionMessage ('Option "extra.circuit_breaker.failure_checker" must be callable. ' );
308+
309+ $ client ->request ('GET ' , 'https://example.com ' , $ options );
310+ }
311+
201312 /**
202313 * @param array<string, mixed> $options
203314 */
@@ -209,6 +320,7 @@ public function testRejectsInvalidOverriddenServiceName(array $options): void
209320 $ client = new CircuitBreakerHttpClient (
210321 new MockHttpClient (new MockResponse ('ok ' )),
211322 new CircuitBreaker ($ storage ),
323+ new DefaultFailureChecker (),
212324 'api ' ,
213325 );
214326
0 commit comments