Skip to content

Commit 51a67ef

Browse files
authored
fix: handle drained response streams in api decoding (#121)
* test: cover drained response bodies without retrying * fix(api-call): read full response bodies before decoding - switch api responses to string-cast body reads so drained streams from middleware still decode correctly - update retry tests to mock string-castable response streams
1 parent 04ef507 commit 51a67ef

2 files changed

Lines changed: 56 additions & 32 deletions

File tree

‎src/ApiCall.php‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,7 @@ private function makeRequest(string $method, string $endPoint, bool $asJson, arr
256256
$this->setNodeHealthCheck($node, true);
257257
}
258258

259-
$responseContents = $response->getBody()
260-
->getContents();
259+
$responseContents = (string) $response->getBody();
261260

262261
if (!(200 <= $statusCode && $statusCode < 300)) {
263262
try {

‎tests/Feature/ApiCallRetryTest.php‎

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@
1818

1919
class ApiCallRetryTest extends TestCase
2020
{
21+
private function createJsonResponseMock(string $json, int $statusCode = 200): ResponseInterface
22+
{
23+
$stream = $this->createMock(StreamInterface::class);
24+
$stream->method('__toString')->willReturn($json);
25+
$stream->method('getContents')->willReturn($json);
26+
27+
$response = $this->createMock(ResponseInterface::class);
28+
$response->method('getStatusCode')->willReturn($statusCode);
29+
$response->method('getBody')->willReturn($stream);
30+
31+
return $response;
32+
}
33+
2134
public function testRetriesOnHttpExceptionWithNon408Status(): void
2235
{
2336
$callCount = 0;
@@ -33,12 +46,7 @@ public function testRetriesOnHttpExceptionWithNon408Status(): void
3346
$response->method('getStatusCode')->willReturn(500);
3447
throw new HttpException('Server error', $this->createMock(RequestInterface::class), $response);
3548
} else {
36-
$response = $this->createMock(ResponseInterface::class);
37-
$response->method('getStatusCode')->willReturn(200);
38-
$stream = $this->createMock(StreamInterface::class);
39-
$stream->method('getContents')->willReturn('{"success": true}');
40-
$response->method('getBody')->willReturn($stream);
41-
return $response;
49+
return $this->createJsonResponseMock('{"success": true}');
4250
}
4351
});
4452

@@ -109,12 +117,7 @@ public function testRetriesOnTypesenseClientError(): void
109117
if ($callCount < $expectedCalls) {
110118
throw new RequestMalformed('Bad request');
111119
} else {
112-
$response = $this->createMock(ResponseInterface::class);
113-
$response->method('getStatusCode')->willReturn(200);
114-
$stream = $this->createMock(StreamInterface::class);
115-
$stream->method('getContents')->willReturn('{"success": true}');
116-
$response->method('getBody')->willReturn($stream);
117-
return $response;
120+
return $this->createJsonResponseMock('{"success": true}');
118121
}
119122
});
120123

@@ -152,12 +155,7 @@ public function testRetriesOnHttpClientException(): void
152155
if ($callCount < $expectedCalls) {
153156
throw new TransferException('Connection error');
154157
} else {
155-
$response = $this->createMock(ResponseInterface::class);
156-
$response->method('getStatusCode')->willReturn(200);
157-
$stream = $this->createMock(StreamInterface::class);
158-
$stream->method('getContents')->willReturn('{"success": true}');
159-
$response->method('getBody')->willReturn($stream);
160-
return $response;
158+
return $this->createJsonResponseMock('{"success": true}');
161159
}
162160
});
163161

@@ -198,12 +196,7 @@ public function testSkips408TimeoutErrorsAndContinuesRetrying(): void
198196
$response->method('getStatusCode')->willReturn(500);
199197
throw new HttpException('Server error', $this->createMock(RequestInterface::class), $response);
200198
} else {
201-
$response = $this->createMock(ResponseInterface::class);
202-
$response->method('getStatusCode')->willReturn(200);
203-
$stream = $this->createMock(StreamInterface::class);
204-
$stream->method('getContents')->willReturn('{"success": true}');
205-
$response->method('getBody')->willReturn($stream);
206-
return $response;
199+
return $this->createJsonResponseMock('{"success": true}');
207200
}
208201
});
209202

@@ -384,12 +377,7 @@ public function test408ErrorsAreSkippedAndRetryingContinues(): void
384377
$response->method('getStatusCode')->willReturn(408);
385378
throw new HttpException('Request timeout', $this->createMock(RequestInterface::class), $response);
386379
} else {
387-
$response = $this->createMock(ResponseInterface::class);
388-
$response->method('getStatusCode')->willReturn(200);
389-
$stream = $this->createMock(StreamInterface::class);
390-
$stream->method('getContents')->willReturn('{"success": true}');
391-
$response->method('getBody')->willReturn($stream);
392-
return $response;
380+
return $this->createJsonResponseMock('{"success": true}');
393381
}
394382
});
395383

@@ -475,6 +463,7 @@ public function testThrowsTypesenseClientErrorWhenSuccessResponseContainsInvalid
475463
$response = $this->createMock(ResponseInterface::class);
476464
$response->method('getStatusCode')->willReturn(200);
477465
$stream = $this->createMock(StreamInterface::class);
466+
$stream->method('__toString')->willReturn('{invalid json');
478467
$stream->method('getContents')->willReturn('{invalid json');
479468
$response->method('getBody')->willReturn($stream);
480469
return $response;
@@ -500,4 +489,40 @@ public function testThrowsTypesenseClientErrorWhenSuccessResponseContainsInvalid
500489
$this->assertInstanceOf(JsonException::class, $exception->getPrevious());
501490
}
502491
}
492+
493+
public function testDrainedResponseBodyStillDecodesWithoutRetrying(): void
494+
{
495+
$callCount = 0;
496+
497+
$stream = $this->createMock(StreamInterface::class);
498+
$stream->method('__toString')->willReturn('{"ok": true}');
499+
$stream->method('getContents')->willReturn('');
500+
501+
$response = $this->createMock(ResponseInterface::class);
502+
$response->method('getStatusCode')->willReturn(200);
503+
$response->method('getBody')->willReturn($stream);
504+
505+
$httpClient = $this->createMock(ClientInterface::class);
506+
$httpClient->method('sendRequest')
507+
->willReturnCallback(function() use (&$callCount, $response) {
508+
$callCount++;
509+
510+
return $response;
511+
});
512+
513+
$config = new Configuration([
514+
'api_key' => 'test-key',
515+
'nodes' => [
516+
['host' => 'node1', 'port' => 8108, 'protocol' => 'http']
517+
],
518+
'client' => $httpClient
519+
]);
520+
521+
$apiCall = new ApiCall($config);
522+
523+
$result = $apiCall->get('/health', []);
524+
525+
$this->assertSame(['ok' => true], $result);
526+
$this->assertSame(1, $callCount);
527+
}
503528
}

0 commit comments

Comments
 (0)