-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[HttpClient] Make CachingHttpClient compatible with RFC 9111
#59576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
This comment has been minimized.
This comment has been minimized.
|
Can we bring this to CachingHttpClient (with a BC layer) instead of introducing a new class that is the good implementation of caching but with a name that is much less clear ? |
src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd
Outdated
Show resolved
Hide resolved
src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php
Outdated
Show resolved
Hide resolved
62db76a to
5b52dd2
Compare
|
UPDATE:
Otherwise, PR is ready on my side. Ready for another reviews. |
src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
Outdated
Show resolved
Hide resolved
b7a0bb4 to
12bf66f
Compare
e93f3b2 to
d0be468
Compare
CachingHttpClient compatible with RFC 9111
b9df14d to
0405dc5
Compare
src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
Show resolved
Hide resolved
7c66ffd to
17d0fdc
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me, but I'd like a review from @nicolas-grekas as he is the one knowing symfony/http-client best.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't get into all details but still LGTM thanks!
|
Can you please maybe expand in the PR description? Think about a blog post we could derive from it :) |
|
I'm currently on holiday. I'll get back to it next week. Including my thoughts on possible race conditions discussed earlier.
May you be more specific ? You want me to explain the code ? |
|
What we could put in the doc about it yes (thus not internal details of course) |
0668a9f to
c81927d
Compare
|
@nicolas-grekas Done. Hope it's what you had in mind. About concurrent requests, due to the async nature of the implementation - and unless I'm missing something -, wrapping everything in a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm proposing to be a bit stricter about vary computation, see comments.
About concurrent requests, I understand that the cached response will become available only after the last chunk has been received. This means the concurrency model is: duplicate requests until one completes. That's totally fine to me.
There's just one issue I think: during this warmup phase, two concurrent requests can corrupt the chunks of the other one.
We could fix this by adding a random value in the chunk cache, stored also in the final item, so that we can be sure we're dealing with the correct chain of chunks.
Does this make sense?
Here are some CS improvements as a patch.
diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php
index 87fae82c95a..8127e4ac205 100644
--- a/src/Symfony/Component/HttpClient/CachingHttpClient.php
+++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php
@@ -164,21 +164,13 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
$requestHash = self::hash($method.$fullUrl.serialize(array_intersect_key($options['normalized_headers'], self::RESPONSE_INFLUENCING_HEADERS)));
$varyKey = "vary_{$requestHash}";
- $varyFields = $this->cache->get($varyKey, static function (ItemInterface $item, bool &$save): array {
- $save = false;
-
- return [];
- }, 0);
+ $varyFields = $this->cache->get($varyKey, static fn ($item, &$save): array => ($save = false) ?: [], 0);
$metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields);
- $cachedData = $this->cache->get($metadataKey, static function (ItemInterface $item, bool &$save): null {
- $save = false;
-
- return null;
- });
+ $cachedData = $this->cache->get($metadataKey, static fn ($item, &$save): array => ($save = false) ?: [], 0);
$freshness = null;
- if (\is_array($cachedData)) {
+ if ($cachedData) {
$freshness = $this->evaluateCacheFreshness($cachedData);
if (Freshness::Fresh === $freshness) {
@@ -433,11 +429,11 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
/**
* Generates a unique metadata key based on the request hash and varying headers.
*
- * @param string $requestHash a hash representing the request details
- * @param array<string, string|string[]> $normalizedHeaders normalized headers of the request
- * @param string[] $varyFields headers to consider for building the variant key
+ * @param string $requestHash A hash representing the request details
+ * @param array<string, string|string[]> $normalizedHeaders Normalized headers of the request
+ * @param string[] $varyFields Headers to consider for building the variant key
*
- * @return string the metadata key composed of the request hash and variant key
+ * @return string The metadata key composed of the request hash and variant key
*/
private static function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string
{
@@ -449,7 +445,8 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
/**
* Build a variant key for caching, given an array of normalized headers and the vary fields.
*
- * The key is a pipe-separated string of "header=value" pairs, with the special case of "header=" for headers that are not present.
+ * The key is a pipe-separated string of "header=value" pairs, with
+ * the special case of "header=" for headers that are not present.
*
* @param array<string, string|string[]> $normalizedHeaders
* @param string[] $varyFields
@@ -460,25 +457,23 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
/**
* Parse the Cache-Control header and return an array of directive names as keys
* and their values as values, or true if the directive has no value.
*
- * @param array<string, string|string[]> $header the Cache-Control header as an array of strings
+ * @param array<string, string|string[]> $header The Cache-Control header as an array of strings
*
- * @return array<string, string|true> the parsed Cache-Control directives
+ * @return array<string, string|true> The parsed Cache-Control directives
*/
private static function parseCacheControlHeader(array $header): array
{
@@ -503,7 +498,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
* This method determines the state of the cached response by analyzing the Cache-Control
* directives and the expiration timestamp.
*
- * @param array{headers: array<string, string[]>, expires_at: int|null} $data the cached response data, including headers and expiration time
+ * @param array{headers: array<string, string[]>, expires_at: int|null} $data The cached response data, including headers and expiration time
*/
private function evaluateCacheFreshness(array $data): Freshness
{
@@ -547,14 +542,12 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
* the Expires header for a valid timestamp, and if present, returns the
* difference between the timestamp and the current time minus the current age.
*
- * If none of the above directives or headers are present, the method returns
- * null.
+ * If none of the above directives or headers are present, the method returns null.
*
- * @param array<string, string|string[]> $headers an array of HTTP headers
- * @param array<string, string|true> $cacheControl an array of parsed Cache-Control directives
+ * @param array<string, string|string[]> $headers An array of HTTP headers
+ * @param array<string, string|true> $cacheControl An array of parsed Cache-Control directives
*
- * @return int|null the maximum age of the response, or null if it cannot be
- * determined
+ * @return int|null The maximum age of the response, or null if it cannot be determined
*/
private function determineMaxAge(array $headers, array $cacheControl): ?int
{
@@ -608,9 +601,9 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
/**
* Retrieves the current age of the response from the headers.
*
- * @param array<string, string|string[]> $headers an array of HTTP headers
+ * @param array<string, string|string[]> $headers An array of HTTP headers
*
- * @return int The age of the response in seconds. Defaults to 0 if not present.
+ * @return int The age of the response in seconds, defaults to 0 if not present
*/
private static function getCurrentAge(array $headers): int
{
@@ -620,9 +613,9 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
/**
* Calculates the expiration time of the response given the maximum age.
*
- * @param int|null $maxAge the maximum age of the response in seconds, or null if it cannot be determined
+ * @param int|null $maxAge The maximum age of the response in seconds, or null if it cannot be determined
*
- * @return int|null the expiration time of the response as a Unix timestamp, or null if the maximum age is null
+ * @return int|null The expiration time of the response as a Unix timestamp, or null if the maximum age is null
*/
private static function calculateExpiresAt(?int $maxAge): ?int
{
@@ -646,7 +639,6 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
*/
private function isServerResponseCacheable(int $statusCode, array $requestHeaders, array $responseHeaders, array $cacheControl): bool
{
- // no-store => skip caching
if (isset($cacheControl['no-store'])) {
return false;
}
@@ -700,32 +692,28 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
*/
private function createResponseFromCache(string $key, array $cachedData, string $method, string $url, array $options, string $fullUrlTag): MockResponse
{
- return MockResponse::fromRequest(
- $method,
- $url,
- $options,
- new MockResponse(
- (function () use ($key, $cachedData, $fullUrlTag): \Generator {
- for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) {
- yield $this->cache->get("{$key}_chunk_{$i}", function (ItemInterface $item) use ($fullUrlTag): never {
- $this->cache->invalidateTags([$fullUrlTag]);
-
- throw new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $item->getKey()));
- }, 0);
- }
- })(),
- ['http_code' => $cachedData['status_code'], 'response_headers' => ['age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at'])] + $cachedData['headers']]
- )
- );
+ $cache = $this->cache;
+ $callback = static function (ItemInterface $item) use ($cache, $fullUrlTag): never {
+ $cache->invalidateTags([$fullUrlTag]);
+
+ th
5D2A
row new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $item->getKey()));
+ };
+ $body = static function () use ($cache, $key, $cachedData, $callback): \Generator {
+ for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) {
+ yield $cache->get("{$key}_chunk_{$i}", $callback, 0);
+ }
+ };
+
+ return MockResponse::fromRequest($method, $url, $options, new MockResponse($body(), [
+ 'http_code' => $cachedData['status_code'],
+ 'response_headers' => [
+ 'age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at']),
+ ] + $cachedData['headers'],
+ ]));
}
private static function createGatewayTimeoutResponse(string $method, string $url, array $options): MockResponse
{
- return MockResponse::fromRequest(
- $method,
- $url,
- $options,
- new MockResponse('', ['http_code' => 504])
- );
+ return MockResponse::fromRequest($method, $url, $options, new MockResponse('', ['http_code' => 504]));
}
}
I don't think the probability of having different responses for doing the 2 same requests in a very short time span is very high but who knows ? That being said, the only reason the cached response is made available at the last chunk is because of the chunks count. If we change the logic of the reconstitution of cached responses' content by turning the chunks' cache entry as a linked list (ie. |
|
Linked list works for me too. I'd just not use offsets as it's always possible for two concurrent requests to yield different chunks. |
db8a067 to
0eb8d4d
Compare
|
@nicolas-grekas done. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can reduce the storage requirements a bit this way.
diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php
index 7e021c1b3cd..714ee889cb9 100644
--- a/src/Symfony/Component/HttpClient/CachingHttpClient.php
+++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php
@@ -174,7 +174,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
$freshness = $this->evaluateCacheFreshness($cachedData);
if (Freshness::Fresh === $freshness) {
- return $this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag);
+ return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag);
}
if (isset($cachedData['headers']['etag'])) {
@@ -213,7 +213,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
// avoid throwing exception in ErrorChunk#__destruct()
$chunk instanceof ErrorChunk && $chunk->didThrow(true);
$context->passthru();
- $context->replaceResponse($this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag));
+ $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
return;
}
@@ -253,7 +253,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
}, \INF);
$context->passthru();
- $context->replaceResponse($this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag));
+ $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
return;
}
@@ -261,7 +261,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
if ($statusCode >= 500 && $statusCode < 600) {
if (Freshness::StaleButUsable === $freshness) {
$context->passthru();
- $context->replaceResponse($this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag));
+ $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
return;
}
@@ -310,7 +310,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
$metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields);
$maxAge = $this->determineMaxAge($headers, $cacheControl);
- $chunkKey = "{$metadataKey}_chunk_".bin2hex(random_bytes(8));
+ $chunkKey = str_replace('/', '_', base64_encode(random_bytes(6)));
$this->cache->get($metadataKey, static function (ItemInterface $item) use ($context, $headers, $maxAge, $expiresAt, $fullUrlTag, $chunkKey): array {
$item->tag($fullUrlTag)->expiresAt($expiresAt);
@@ -321,7 +321,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
'initial_age' => (int) ($headers['age'][0] ?? 0),
'stored_at' => time(),
'expires_at' => self::calculateExpiresAt($maxAge),
- 'first_chunk' => $chunkKey,
+ 'next_chunk' => $chunkKey,
];
}, \INF);
@@ -330,8 +330,15 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
return;
}
+ if (null === $chunkKey) {
+ // informational chunks
+ yield $chunk;
+
+ return;
+ }
+
if ($chunk->isLast()) {
- $this->cache->get($chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk): array {
+ $this->cache->get($metadataKey.'_'.$chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk): array {
$item->tag($fullUrlTag)->expiresAt($expiresAt);
return [
@@ -345,8 +352,8 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
return;
}
- $nextChunkKey = "{$metadataKey}_chunk_".bin2hex(random_bytes(8));
- $this->cache->get($chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk, $nextChunkKey): array {
+ $nextChunkKey = str_replace('/', '_', base64_encode(random_bytes(6)));
+ $this->cache->get($metadataKey.'_'.$chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk, $nextChunkKey): array {
$item->tag($fullUrlTag)->expiresAt($expiresAt);
return [
@@ -700,9 +707,9 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
* response headers and content. The constructed MockResponse is then
* returned.
*
- * @param array{first_chunk: string, status_code: int, initial_age: int, headers: array<string, string|string[]>, stored_at: int} $cachedData
+ * @param array{next_chunk: string, status_code: int, initial_age: int, headers: array<string, string|string[]>, stored_at: int} $cachedData
*/
- private function createResponseFromCache(array $cachedData, string $method, string $url, array $options, string $fullUrlTag): MockResponse
+ private function createResponseFromCache(string $metadataKey, array $cachedData, string $method, string $url, array $options, string $fullUrlTag): MockResponse
{
$cache = $this->cache;
$callback = static function (ItemInterface $item) use ($cache, $fullUrlTag): never {
@@ -710,13 +717,13 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
throw new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $item->getKey()));
};
- $body = static function () use ($cache, $cachedData, $callback): \Generator {
- $chunk = $cache->get($cachedData['first_chunk'], $callback, 0);
+ $body = static function () use ($cache, $cachedData, $metadataKey, $callback): \Generator {
+ $chunk = $cache->get($metadataKey.'_'.$cachedData['next_chunk'], $callback, 0);
yield $chunk['content'];
while (null !== $chunk['next_chunk']) {
- $chunk = $cache->get($chunk['next_chunk'], $callback, 0);
+ $chunk = $cache->get($metadataKey.'_'.$chunk['next_chunk'], $callback, 0);
if ('' !== $chunk['content']) {
yield $chunk['content'];BUT, the latest iteration changed the concurrency model: now, 2nd concurrent requests will hit the cache, and will inevitably fail, because consuming from the cache should be much faster than populating it from the 1st request.
I was more comfortable with the previous model that committed to the cache only when the last chunk was reached. Also because that plays better with canceled requests.
BTW, this makes me realize that if an error happens in the middle of populating the chunks, we should invalidate them all I suppose.
11b5c42 to
6962aba
Compare
|
@nicolas-grekas I reverted to commit the cache on last chunk. I also tweaked the cases when we want to clear the cache. |
6962aba to
a62b547
Compare
|
Thank you @Lctrs. |
|
Months of work, congrats for the achievement! |
This PR brings RFC9111 support to
CachingHttpClientby leveragingsymfony/cache. It provides caching forGETandHEADrequests, and cache invalidation forPOST,PUT,DELETE,PATCH.The implementation is asynchronous, so the response must be consumed (e.g., via getContent() or streaming) for caching to occur.
Basic usage
Constructor parameters
$client: The underlying HTTP client.$cache: A cache backend implementingTagAwareCacheInterface.$defaultOptions: An optional array of default request options.$sharedCache:trueto indicate the cache is shared,falsefor private. Defaults to true.$maxTtl: Optional maximum time-to-live (in seconds) for cached responses. If set, server-provided TTLs are capped to this value.Omissions from RFC9111
stale-while-revalidate,min-fresh,max-stale, andonly-if-cached. In the case ofstale-while-revalidate, a stale response will always be revalidated.### Integration with FrameworkBundle
It is also highly recommended to configure a retry strategy to automatically retry the request in case of cache inconsistency.