Skip to content

Commit d54150e

Browse files
CopilotThavarshan
andcommitted
Address code review feedback: status codes consistency, race condition, null handling, and tests
Co-authored-by: Thavarshan <10804999+Thavarshan@users.noreply.github.com>
1 parent 6f29968 commit d54150e

File tree

4 files changed

+55
-17
lines changed

4 files changed

+55
-17
lines changed

src/Fetch/Cache/CacheControl.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,10 @@ public function shouldCache(ResponseInterface $response, bool $isSharedCache = f
9999
return false;
100100
}
101101

102-
// Check response status code
102+
// Check response status code - using RFC 7234 recommended cacheable status codes
103+
// This list matches the default cache_status_codes in ManagesCache trait
103104
$status = $response->getStatusCode();
104-
$cacheableStatuses = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501];
105+
$cacheableStatuses = [200, 203, 204, 206, 300, 301, 404, 410];
105106

106107
if (! in_array($status, $cacheableStatuses, true)) {
107108
return false;

src/Fetch/Cache/FileCache.php

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,9 @@ public function get(string $key): ?CachedResponse
5353
{
5454
$path = $this->getPath($key);
5555

56-
if (! file_exists($path)) {
57-
return null;
58-
}
59-
60-
$contents = file_get_contents($path);
56+
// Directly attempt to read the file - file_get_contents returns false if file doesn't exist
57+
// This avoids a race condition where file could be deleted between exists check and read
58+
$contents = @file_get_contents($path);
6159
if ($contents === false) {
6260
return null;
6361
}
@@ -108,29 +106,35 @@ public function set(string $key, CachedResponse $response, ?int $ttl = null): vo
108106
if ($ttl !== null) {
109107
if ($ttl === 0) {
110108
// No expiration
111-
$response = CachedResponse::fromArray(
109+
$updatedResponse = CachedResponse::fromArray(
112110
array_merge($response->toArray(), [
113111
'expires_at' => null,
114112
])
115113
);
114+
if ($updatedResponse !== null) {
115+
$response = $updatedResponse;
116+
}
116117
} else {
117-
$response = CachedResponse::fromArray(
118+
$updatedResponse = CachedResponse::fromArray(
118119
array_merge($response->toArray(), [
119120
'expires_at' => time() + $ttl,
120121
])
121122
);
123+
if ($updatedResponse !== null) {
124+
$response = $updatedResponse;
125+
}
122126
}
123127
} elseif ($response->getExpiresAt() === null && $this->defaultTtl > 0) {
124-
$response = CachedResponse::fromArray(
128+
$updatedResponse = CachedResponse::fromArray(
125129
array_merge($response->toArray(), [
126130
'expires_at' => time() + $this->defaultTtl,
127131
])
128132
);
133+
if ($updatedResponse !== null) {
134+
$response = $updatedResponse;
135+
}
129136
}
130137

131-
// PHPStan cannot infer that $response is non-null here after the conditionals above
132-
// but we know $response is always a valid CachedResponse at this point
133-
// @phpstan-ignore-next-line
134138
$encoded = json_encode($response->toArray());
135139
if ($encoded === false) {
136140
throw new RuntimeException('Failed to encode cache data as JSON');

src/Fetch/Concerns/ManagesCache.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
/**
1616
* Trait for managing HTTP caching.
17+
*
18+
* Note: Caching is only supported for synchronous requests. Asynchronous requests
19+
* bypass the cache entirely and do not check or store cached responses.
1720
*/
1821
trait ManagesCache
1922
{

tests/Unit/ClientHandlerCacheTest.php

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public function test_caches_get_requests(): void
8181
$this->assertEquals('HIT', $response2->getHeaderLine('X-Cache-Status'));
8282
}
8383

84-
public function test_cache_miss_adds_x_cache_status_header(): void
84+
public function test_cache_miss_returns_successful_response(): void
8585
{
8686
$responses = [
8787
new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"test"}'),
@@ -93,8 +93,9 @@ public function test_cache_miss_adds_x_cache_status_header(): void
9393

9494
$response = $handler->get('/users');
9595

96-
// After request is cached, the response won't have the header until fetched from cache
96+
// First request is a cache miss, response should be successful
9797
$this->assertEquals(200, $response->getStatusCode());
98+
$this->assertEquals('{"data":"test"}', $response->body());
9899
}
99100

100101
public function test_does_not_cache_post_requests_by_default(): void
@@ -286,7 +287,7 @@ public function test_cache_respects_max_age_from_response(): void
286287
$this->assertEquals('{"data":"cached"}', $response->body());
287288
}
288289

289-
public function test_cache_disabled_when_respect_headers_false_and_no_store(): void
290+
public function test_cache_respects_no_store_when_respect_headers_enabled(): void
290291
{
291292
$responses = [
292293
new GuzzleResponse(200, [
@@ -298,7 +299,7 @@ public function test_cache_disabled_when_respect_headers_false_and_no_store(): v
298299

299300
$handler = $this->create_handler_with_mock_responses($responses);
300301
$handler->baseUri('https://api.example.com');
301-
// Even with respect_cache_headers=false, no-store should be respected
302+
// With respect_cache_headers=true (default), no-store should be respected
302303
$handler->withCache(null, ['respect_cache_headers' => true]);
303304

304305
$response1 = $handler->get('/users');
@@ -308,4 +309,33 @@ public function test_cache_disabled_when_respect_headers_false_and_no_store(): v
308309
$response2 = $handler->get('/users');
309310
$this->assertEquals('{"data":"second"}', $response2->body());
310311
}
312+
313+
public function test_cache_requires_revalidation_with_no_cache_directive(): void
314+
{
315+
// no-cache means the response may be cached but must be revalidated before use
316+
// This is different from no-store which forbids caching entirely
317+
$responses = [
318+
new GuzzleResponse(200, [
319+
'Content-Type' => 'application/json',
320+
'Cache-Control' => 'no-cache',
321+
'ETag' => '"abc123"',
322+
], '{"data":"first"}'),
323+
// Second request should include conditional headers and may get 304
324+
new GuzzleResponse(304, [], ''),
325+
];
326+
327+
$handler = $this->create_handler_with_mock_responses($responses);
328+
$handler->baseUri('https://api.example.com');
329+
$handler->withCache(null, ['respect_cache_headers' => true]);
330+
331+
// First request - gets response with no-cache
332+
$response1 = $handler->get('/users');
333+
$this->assertEquals('{"data":"first"}', $response1->body());
334+
$this->assertEquals(200, $response1->getStatusCode());
335+
336+
// Second request - should revalidate and use cached response if 304
337+
$response2 = $handler->get('/users');
338+
// Response should still be the cached one after revalidation
339+
$this->assertEquals(200, $response2->getStatusCode());
340+
}
311341
}

0 commit comments

Comments
 (0)