2026-09-22 16:43:47 +08:00
<? php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient ;
use Symfony\Component\HttpClient\Caching\Freshness ;
use Symfony\Component\HttpClient\Chunk\ErrorChunk ;
use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException ;
use Symfony\Component\HttpClient\Internal\OutgoingRequest ;
use Symfony\Component\HttpClient\Response\AsyncContext ;
use Symfony\Component\HttpClient\Response\AsyncResponse ;
use Symfony\Component\HttpClient\Response\MockResponse ;
use Symfony\Component\HttpClient\Response\ResponseStream ;
2026-10-16 18:13:09 +08:00
use Symfony\Component\HttpKernel\HttpCache\HttpCache ;
use Symfony\Component\HttpKernel\HttpCache\StoreInterface ;
use Symfony\Component\HttpKernel\HttpClientKernel ;
2026-09-22 16:43:47 +08:00
use Symfony\Contracts\Cache\ItemInterface ;
use Symfony\Contracts\Cache\TagAwareCacheInterface ;
use Symfony\Contracts\HttpClient\ChunkInterface ;
use Symfony\Contracts\HttpClient\HttpClientInterface ;
use Symfony\Contracts\HttpClient\ResponseInterface ;
use Symfony\Contracts\HttpClient\ResponseStreamInterface ;
use Symfony\Contracts\Service\ResetInterface ;
/**
* Adds caching on top of an HTTP client (per RFC 9111).
*
* Known omissions / partially supported features per RFC 9111:
* 1. Range requests:
* - All range requests ("partial content") are passed through and never cached.
* 2. stale-while-revalidate:
* - There's no actual "background revalidation" for stale responses, they will
* always be revalidated.
* 3. min-fresh, max-stale:
* - These request directives are not parsed; the client ignores them.
*
* @see https://www.rfc-editor.org/rfc/rfc9111
*/
2026-10-16 18:13:09 +08:00
class CachingHttpClient implements HttpClientInterface , ResetInterface
2026-09-22 16:43:47 +08:00
{
use AsyncDecoratorTrait {
stream as asyncStream ;
AsyncDecoratorTrait :: withOptions insteadof HttpClientTrait ;
}
use HttpClientTrait ;
/**
* The status codes that are cacheable by default.
*/
private const DEFAULT_CACHEABLE_STATUS_CODES = [ 200 , 203 , 204 , 300 , 301 , 308 , 404 , 405 , 410 , 414 , 501 ];
/**
* The HTTP methods that are always cacheable.
*/
private const CACHEABLE_METHODS = [ 'GET' , 'HEAD' ];
/**
* The HTTP methods that are considered safe per RFC 9110.
*/
private const SAFE_METHODS = [ 'GET' , 'HEAD' , 'OPTIONS' , 'TRACE' ];
/**
* Headers that influence the response and may affect caching behavior.
*/
private const RESPONSE_INFLUENCING_HEADERS = [
'accept' => true ,
'accept-charset' => true ,
'accept-encoding' => true ,
'accept-language' => true ,
'authorization' => true ,
'cookie' => true ,
'expect' => true ,
'host' => true ,
'user-agent' => true ,
];
/**
* Headers that MUST NOT be stored as per RFC 9111 Section 3.1.
*/
private const EXCLUDED_HEADERS = [
'connection' => true ,
'keep-alive' => true ,
'proxy-authenticate' => true ,
'proxy-authentication-info' => true ,
'proxy-authorization' => true ,
'te' => true ,
'trailer' => true ,
'transfer-encoding' => true ,
'upgrade' => true ,
];
/**
* Maximum heuristic freshness lifetime in seconds (24 hours).
*/
private const MAX_HEURISTIC_FRESHNESS_TTL = 86400 ;
/**
* RFC 9111 recommends a heuristic freshness lifetime of 10% of the time since Last-Modified.
*/
private const HEURISTIC_FRESHNESS_FRACTION = 0.1 ;
2026-10-16 18:13:09 +08:00
private TagAwareCacheInterface | HttpCache $cache ;
2026-09-22 16:43:47 +08:00
private array $defaultOptions = self :: OPTIONS_DEFAULTS ;
private bool $isInnerRequest = false ;
/**
2026-10-16 18:13:09 +08:00
* @param bool $sharedCache Indicates whether this cache is shared or private. When true, responses
* may be skipped from caching in presence of certain headers
* (e.g. Authorization) unless explicitly marked as public.
* @param int|null $maxTtl The maximum time-to-live (in seconds) for cached responses.
* If a server-provided TTL exceeds this value, it will be capped
* to this maximum.
2026-09-22 16:43:47 +08:00
*/
public function __construct (
private HttpClientInterface $client ,
2026-10-16 18:13:09 +08:00
TagAwareCacheInterface | StoreInterface $cache ,
2026-09-22 16:43:47 +08:00
array $defaultOptions = [],
private readonly bool $sharedCache = true ,
2026-10-16 18:13:09 +08:00
private readonly ? int $maxTtl = null ,
2026-09-22 16:43:47 +08:00
) {
2026-10-16 18:13:09 +08:00
if ( $cache instanceof StoreInterface ) {
trigger_deprecation ( 'symfony/http-client' , '7.4' , 'Passing a "%s" as constructor\'s 2nd argument of "%s" is deprecated, "%s" expected.' , StoreInterface :: class , __CLASS__ , TagAwareCacheInterface :: class );
if ( ! class_exists ( HttpClientKernel :: class )) {
throw new \LogicException ( \sprintf ( 'Using "%s" requires the HttpKernel component, try running "composer require symfony/http-kernel".' , __CLASS__ ));
}
$kernel = new HttpClientKernel ( $client );
$this -> cache = new HttpCache ( $kernel , $cache , null , $defaultOptions );
unset ( $defaultOptions [ 'debug' ]);
unset ( $defaultOptions [ 'default_ttl' ]);
unset ( $defaultOptions [ 'private_headers' ]);
unset ( $defaultOptions [ 'skip_response_headers' ]);
unset ( $defaultOptions [ 'allow_reload' ]);
unset ( $defaultOptions [ 'allow_revalidate' ]);
unset ( $defaultOptions [ 'stale_while_revalidate' ]);
unset ( $defaultOptions [ 'stale_if_error' ]);
unset ( $defaultOptions [ 'trace_level' ]);
unset ( $defaultOptions [ 'trace_header' ]);
} else {
$this -> cache = $cache ;
2026-09-22 16:43:47 +08:00
}
if ( $defaultOptions ) {
[, $this -> defaultOptions ] = self :: prepareRequest ( null , null , $defaultOptions , $this -> defaultOptions );
}
}
public function request ( string $method , string $url , array $options = []) : ResponseInterface
{
if ( $this -> isInnerRequest ) {
return $this -> client -> request ( $method , $url , $options );
}
2026-10-16 18:13:09 +08:00
if ( $this -> cache instanceof HttpCache ) {
return $this -> legacyRequest ( $method , $url , $options );
}
2026-09-22 16:43:47 +08:00
[ $fullUrl , $options ] = self :: prepareRequest ( $method , $url , $options , $this -> defaultOptions );
$fullUrl = implode ( '' , $fullUrl );
$fullUrlTag = self :: hash ( $fullUrl );
$requestCacheControl = self :: parseRequestCacheControlHeader ( $options [ 'normalized_headers' ][ 'cache-control' ] ?? []);
if ( '' !== $options [ 'body' ] || ( $options [ 'extra' ][ 'no_cache' ] ?? false ) || isset ( $options [ 'normalized_headers' ][ 'range' ]) || ! \in_array ( $method , self :: CACHEABLE_METHODS , true )) {
if ( isset ( $requestCacheControl [ 'only-if-cached' ])) {
return self :: createGatewayTimeoutResponse ( $method , $url , $options );
}
$passthru = function ( ChunkInterface $chunk , AsyncContext $context ) use ( $method , $fullUrlTag ) : \Generator {
if ( null !== $chunk -> getError () || $chunk -> isTimeout () || ! $chunk -> isFirst ()) {
yield $chunk ;
return ;
}
$statusCode = $context -> getStatusCode ();
if ( $statusCode >= 200 && $statusCode < 400 && ! \in_array ( $method , self :: SAFE_METHODS , true )) {
$this -> cache -> invalidateTags ([ $fullUrlTag ]);
}
$context -> passthru ();
yield $chunk ;
};
$this -> isInnerRequest = true ;
try {
return new AsyncResponse ( $this , $method , $url , $options , $passthru );
} finally {
$this -> isInnerRequest = false ;
}
}
if ( isset ( $requestCacheControl [ 'no-store' ])) {
return new AsyncResponse ( $this -> client , $method , $url , $options );
}
$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 fn ( $item , & $save ) : array => ( $save = false ) ?: [], 0 );
$metadataKey = self :: getMetadataKey ( $requestHash , $options [ 'normalized_headers' ], $varyFields );
$cachedData = $this -> cache -> get ( $metadataKey , static fn ( $item , & $save ) : array => ( $save = false ) ?: [], 0 );
$hasClientConditionalValidator = isset ( $options [ 'normalized_headers' ][ 'if-none-match' ]) || isset ( $options [ 'normalized_headers' ][ 'if-modified-since' ]);
$sentCacheValidator = false ;
$allowStaleFallback = true ;
$freshness = null ;
if ( $cachedData ) {
$freshness = $this -> evaluateCacheFreshness ( $cachedData );
$cachedResponseAcceptable = $this -> isCachedResponseAcceptable ( $cachedData , $requestCacheControl , $freshness );
$allowStaleFallback = $cachedResponseAcceptable || ! $this -> requestCacheControlRequiresRevalidation ( $cachedData , $requestCacheControl );
if ( $cachedResponseAcceptable ) {
if ( $hasClientConditionalValidator && self :: clientValidatorMatchesCachedResponse ( $options [ 'normalized_headers' ], $cachedData )) {
return self :: createNotModifiedResponse ( $method , $url , $options , $cachedData [ 'headers' ]);
}
return $this -> createResponseFromCache ( $cachedData , $method , $url , $options , $metadataKey );
}
if ( isset ( $requestCacheControl [ 'only-if-cached' ])) {
return self :: createGatewayTimeoutResponse ( $method , $url , $options );
}
if ( ! $hasClientConditionalValidator ) {
if ( isset ( $cachedData [ 'headers' ][ 'etag' ])) {
$options [ 'headers' ][ 'If-None-Match' ] = implode ( ', ' , $cachedData [ 'headers' ][ 'etag' ]);
$sentCacheValidator = true ;
}
if ( isset ( $cachedData [ 'headers' ][ 'last-modified' ][ 0 ])) {
$options [ 'headers' ][ 'If-Modified-Since' ] = $cachedData [ 'headers' ][ 'last-modified' ][ 0 ];
$sentCacheValidator = true ;
}
}
}
if ( isset ( $requestCacheControl [ 'only-if-cached' ])) {
return self :: createGatewayTimeoutResponse ( $method , $url , $options );
}
2026-10-16 18:13:09 +08:00
$requestTime = time ();
2026-09-22 16:43:47 +08:00
// consistent expiration time for all items
2026-10-16 18:13:09 +08:00
$expiresAt = null === $this -> maxTtl ? null : \DateTimeImmutable :: createFromFormat ( 'U' , time () + $this -> maxTtl );
2026-09-22 16:43:47 +08:00
$passthru = function ( ChunkInterface $chunk , AsyncContext $context ) use (
$expiresAt ,
$fullUrlTag ,
$requestHash ,
$varyKey ,
$varyFields ,
& $metadataKey ,
$cachedData ,
$freshness ,
$url ,
$method ,
$options ,
$hasClientConditionalValidator ,
$sentCacheValidator ,
$allowStaleFallback ,
2026-10-16 18:13:09 +08:00
$requestTime ,
2026-09-22 16:43:47 +08:00
) : \Generator {
static $attemptTag = null ;
static $firstChunkKey = null ;
static $chunkKey = null ;
if ( null !== $chunk -> getError () || $chunk -> isTimeout ()) {
null !== $attemptTag && $this -> cache -> invalidateTags ([ $attemptTag ]);
if ( $allowStaleFallback && Freshness :: StaleButUsable === $freshness ) {
// avoid throwing exception in ErrorChunk#__destruct()
$chunk instanceof ErrorChunk && $chunk -> didThrow ( true );
$context -> passthru ();
$context -> replaceResponse ( $this -> createResponseFromCache ( $cachedData , $method , $url , $options , $metadataKey ));
return ;
}
if ( Freshness :: MustRevalidate === $freshness ) {
// avoid throwing exception in ErrorChunk#__destruct()
$chunk instanceof ErrorChunk && $chunk -> didThrow ( true );
$context -> passthru ();
$context -> replaceResponse ( self :: createGatewayTimeoutResponse ( $method , $url , $options ));
return ;
}
yield $chunk ;
return ;
}
$headers = $context -> getHeaders ();
if ( $chunk -> isFirst ()) {
$statusCode = $context -> getStatusCode ();
$attemptTag = self :: generateChunkKey ();
if ( 304 === $statusCode && null !== $freshness ) {
$shouldUpdateCachedMetadata = ( $sentCacheValidator || $hasClientConditionalValidator ) && self :: notModifiedResponseMatchesCachedResponse ( $headers , $cachedData );
if ( ! $shouldUpdateCachedMetadata ) {
$context -> passthru ();
yield $chunk ;
return ;
}
$responseTime = time ();
$correctedInitialAge = self :: getCorrectedInitialAge ( $headers , $requestTime , $responseTime );
$updatedHeaders = self :: filterStorableHeaders ( array_merge ( $cachedData [ 'headers' ], $headers ), self :: getExcludedHeaders ( $headers ));
$updatedCachedData = array_replace ( $cachedData , [
'stored_at' => $responseTime ,
'initial_age' => $correctedInitialAge ,
'headers' => $updatedHeaders ,
]);
$updatedCacheControl = self :: parseCacheControlHeader ( $updatedCachedData [ 'headers' ][ 'cache-control' ] ?? []);
$updatedCachedData [ 'expires_at' ] = self :: calculateExpiresAt ( $this -> determineMaxAge ( $updatedCachedData [ 'headers' ], $updatedCacheControl , $correctedInitialAge , $requestTime , $responseTime ));
$newVaryFields = $this -> parseVaryFields ( $updatedCachedData [ 'headers' ][ 'vary' ] ?? []);
$updatedMetadataIsCacheable = ! \in_array ( '*' , $newVaryFields , true )
&& $varyFields === $newVaryFields
&& $this -> isServerResponseCacheable ( $cachedData [ 'status_code' ], $options [ 'normalized_headers' ], $updatedCachedData [ 'headers' ], $updatedCacheControl );
if ( ! $updatedMetadataIsCacheable ) {
$this -> cache -> delete ( $metadataKey );
$context -> passthru ();
if ( ! $hasClientConditionalValidator ) {
$context -> replaceResponse ( $this -> createResponseFromCache ( $updatedCachedData , $method , $url , $options , $metadataKey , $expiresAt ));
}
return ;
}
$updatedCachedData = $this -> cache -> get ( $metadataKey , static function ( ItemInterface $item ) use ( $updatedCachedData , $expiresAt , $fullUrlTag , $metadataKey ) : array {
$item -> expiresAt ( $expiresAt ) -> tag ([ $fullUrlTag , $metadataKey ]);
return $updatedCachedData ;
}, \INF );
$context -> passthru ();
if ( ! $hasClientConditionalValidator ) {
$context -> replaceResponse ( $this -> createResponseFromCache ( $updatedCachedData , $method , $url , $options , $metadataKey , $expiresAt ));
}
return ;
}
if ( $statusCode >= 500 && $statusCode < 600 ) {
if ( $allowStaleFallback && Freshness :: StaleButUsable === $freshness ) {
$context -> passthru ();
$context -> replaceResponse ( $this -> createResponseFromCache ( $cachedData , $method , $url , $options , $metadataKey ));
return ;
}
if ( Freshness :: MustRevalidate === $freshness ) {
$context -> passthru ();
$context -> replaceResponse ( self :: createGatewayTimeoutResponse ( $method , $url , $options ));
return ;
}
}
$cacheControl = self :: parseCacheControlHeader ( $headers [ 'cache-control' ] ?? []);
if ( ! $this -> isServerResponseCacheable ( $statusCode , $options [ 'normalized_headers' ], $headers , $cacheControl )) {
$context -> passthru ();
yield $chunk ;
return ;
}
// recomputing vary fields in case it changed or for first request
$newVaryFields = $this -> parseVaryFields ( $headers [ 'vary' ] ?? []);
if ( \in_array ( '*' , $newVaryFields , true )) {
$context -> passthru ();
yield $chunk ;
return ;
}
sort ( $newVaryFields );
if ( $varyFields !== $newVaryFields ) {
$this -> cache -> invalidateTags ([ $fullUrlTag ]);
$metadataKey = self :: getMetadataKey ( $requestHash , $options [ 'normalized_headers' ], $newVaryFields );
}
$this -> cache -> get ( $varyKey , static function ( ItemInterface $item ) use ( $newVaryFields , $expiresAt , $fullUrlTag ) : array {
$item -> tag ([ $fullUrlTag ]) -> expiresAt ( $expiresAt );
return $newVaryFields ;
}, \INF );
$firstChunkKey = $chunkKey = self :: generateChunkKey ();
yield $chunk ;
return ;
}
if ( null === $chunkKey ) {
// informational chunks
yield $chunk ;
return ;
}
if ( $chunk -> isLast ()) {
$this -> cache -> get ( $chunkKey , static function ( ItemInterface $item ) use ( $expiresAt , $fullUrlTag , $metadataKey , $chunk , $attemptTag ) : array {
$item -> tag ([ $fullUrlTag , $metadataKey , $attemptTag ]) -> expiresAt ( $expiresAt );
return [
'content' => $chunk -> getContent (),
'next_chunk' => null ,
];
}, \INF );
$responseTime = time ();
$correctedInitialAge = self :: getCorrectedInitialAge ( $headers , $requestTime , $responseTime );
$maxAge = $this -> determineMaxAge ( $headers , self :: parseCacheControlHeader ( $headers [ 'cache-control' ] ?? []), $correctedInitialAge , $requestTime , $responseTime );
$this -> cache -> get ( $metadataKey , static function ( ItemInterface $item ) use ( $context , $headers , $maxAge , $expiresAt , $fullUrlTag , $metadataKey , $attemptTag , $firstChunkKey , $responseTime , $correctedInitialAge ) : array {
$item -> tag ([ $fullUrlTag , $metadataKey , $attemptTag ]) -> expiresAt ( $expiresAt );
return [
'status_code' => $context -> getStatusCode (),
'headers' => self :: filterStorableHeaders ( $headers ),
'initial_age' => $correctedInitialAge ,
'stored_at' => $responseTime ,
'expires_at' => self :: calculateExpiresAt ( $maxAge ),
'next_chunk' => $firstChunkKey ,
];
}, \INF );
yield $chunk ;
return ;
}
$nextChunkKey = self :: generateChunkKey ();
$this -> cache -> get ( $chunkKey , static function ( ItemInterface $item ) use ( $expiresAt , $fullUrlTag , $metadataKey , $attemptTag , $chunk , $nextChunkKey ) : array {
$item -> tag ([ $fullUrlTag , $metadataKey , $attemptTag ]) -> expiresAt ( $expiresAt );
return [
'content' => $chunk -> getContent (),
'next_chunk' => $nextChunkKey ,
];
}, \INF );
$chunkKey = $nextChunkKey ;
yield $chunk ;
};
$this -> isInnerRequest = true ;
try {
return new AsyncResponse ( $this , $method , $url , $options , $passthru );
} finally {
$this -> isInnerRequest = false ;
}
}
public function stream ( ResponseInterface | iterable $responses , ? float $timeout = null ) : ResponseStreamInterface
{
if ( $responses instanceof ResponseInterface ) {
$responses = [ $responses ];
}
$mockResponses = [];
$asyncResponses = [];
$clientResponses = [];
foreach ( $responses as $response ) {
if ( $response instanceof MockResponse ) {
$mockResponses [] = $response ;
} elseif ( $response instanceof AsyncResponse ) {
$asyncResponses [] = $response ;
} else {
$clientResponses [] = $response ;
}
}
if ( ! $mockResponses && ! $clientResponses ) {
return $this -> asyncStream ( $asyncResponses , $timeout );
}
if ( ! $asyncResponses && ! $clientResponses ) {
return new ResponseStream ( MockResponse :: stream ( $mockResponses , $timeout ));
}
if ( ! $mockResponses && ! $asyncResponses ) {
return $this -> client -> stream ( $clientResponses , $timeout );
}
return new ResponseStream (( function () use ( $mockResponses , $asyncResponses , $clientResponses , $timeout ) {
if ( $mockResponses ) {
yield from MockResponse :: stream ( $mockResponses , $timeout );
}
if ( $clientResponses ) {
yield from $this -> client -> stream ( $clientResponses , $timeout );
}
if ( $asyncResponses ) {
yield from $this -> asyncStream ( $asyncResponses , $timeout );
}
})());
}
2026-10-16 18:13:09 +08:00
private function legacyRequest ( string $method , string $url , array $options = []) : ResponseInterface
{
[ $url , $options ] = self :: prepareRequest ( $method , $url , $options , $this -> defaultOptions , true );
$url = implode ( '' , $url );
if ( ! empty ( $options [ 'body' ]) || ! empty ( $options [ 'extra' ][ 'no_cache' ]) || ! \in_array ( $method , [ 'GET' , 'HEAD' , 'OPTIONS' ], true )) {
return new AsyncResponse ( $this -> client , $method , $url , $options );
}
$request = OutgoingRequest :: create ( $url , $method );
$request -> attributes -> set ( 'http_client_options' , $options );
foreach ( $options [ 'normalized_headers' ] as $name => $values ) {
if ( 'cookie' !== $name ) {
$headerValues = [];
foreach ( $values as $value ) {
$headerValues [] = substr ( $value , 2 + \strlen ( $name ));
}
$request -> headers -> set ( $name , $headerValues );
continue ;
}
foreach ( $values as $cookies ) {
foreach ( explode ( '; ' , substr ( $cookies , \strlen ( 'Cookie: ' ))) as $cookie ) {
if ( '' !== $cookie ) {
$cookie = explode ( '=' , $cookie , 2 );
$request -> cookies -> set ( $cookie [ 0 ], $cookie [ 1 ] ?? '' );
}
}
}
}
$response = $this -> cache -> handle ( $request );
$response = new MockResponse ( $response -> getContent (), [
'http_code' => $response -> getStatusCode (),
'response_headers' => $response -> headers -> allPreserveCase (),
]);
return MockResponse :: fromRequest ( $method , $url , $options , $response );
}
2026-09-22 16:43:47 +08:00
private static function hash ( string $toHash ) : string
{
return str_replace ( '/' , '_' , base64_encode ( hash ( 'sha256' , $toHash , true )));
}
private static function generateChunkKey () : string
{
return str_replace ( '/' , '_' , base64_encode ( random_bytes ( 6 )));
}
/**
* 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
*
* @return string The metadata key composed of the request hash and variant key
*/
private static function getMetadataKey ( string $requestHash , array $normalizedHeaders , array $varyFields ) : string
{
$variantKey = self :: hash ( self :: buildVariantKey ( $normalizedHeaders , $varyFields ));
return "metadata_ { $requestHash } _ { $variantKey } " ;
}
/**
* Build a variant key for caching, given an array of normalized headers and the vary fields.
*
* The key is an ampersand-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
*/
private static function buildVariantKey ( array $normalizedHeaders , array $varyFields ) : string
{
$parts = [];
foreach ( $varyFields as $field ) {
$lower = strtolower ( $field );
if ( ! isset ( $normalizedHeaders [ $lower ])) {
$parts [ $lower ] = $lower . '=' ;
} else {
$parts [ $lower ] = $lower . '=' . implode ( ',' , array_map ( rawurlencode ( ... ), ( array ) $normalizedHeaders [ $lower ]));
}
}
ksort ( $parts );
return implode ( '&' , $parts );
}
/**
* 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
*
* @return array<string, string|true> The parsed Cache-Control directives
*/
private static function parseCacheControlHeader ( array $header ) : array
{
$parsed = [];
foreach ( $header as $line ) {
foreach ( explode ( ',' , $line ) as $directive ) {
if ( str_contains ( $directive , '=' )) {
[ $name , $value ] = explode ( '=' , $directive , 2 );
$normalizedName = strtolower ( trim ( $name ));
$normalizedValue = self :: unquoteCacheControlValue ( trim ( $value ));
} else {
$normalizedName = strtolower ( trim ( $directive ));
$normalizedValue = true ;
}
if ( '' === $normalizedName ) {
continue ;
}
if ( \array_key_exists ( $normalizedName , $parsed )) {
// Duplicate directive values are ambiguous; make value-based checks fail closed.
$parsed [ $normalizedName ] = '' ;
continue ;
}
$parsed [ $normalizedName ] = $normalizedValue ;
}
}
return $parsed ;
}
private static function unquoteCacheControlValue ( string $value ) : string
{
if ( 2 <= \strlen ( $value ) && '"' === $value [ 0 ] && '"' === $value [ - 1 ]) {
return substr ( $value , 1 , - 1 );
}
return $value ;
}
private static function parseDeltaSeconds ( string | true | null $value ) : ? int
{
if ( ! \is_string ( $value ) || '' === $value || ! ctype_digit ( $value )) {
return null ;
}
return ( int ) $value ;
}
/**
* @param array<string, string[]> $headers
*
* @return array<string, string[]>
*/
private static function filterStorableHeaders ( array $headers , ? array $excludedHeaders = null ) : array
{
$excludedHeaders ??= self :: getExcludedHeaders ( $headers );
return array_diff_key ( $headers , $excludedHeaders );
}
/**
* @param array<string, string[]> $headers
*
* @return array<string, bool>
*/
private static function getExcludedHeaders ( array $headers ) : array
{
$excludedHeaders = self :: EXCLUDED_HEADERS ;
foreach ( $headers [ 'connection' ] ?? [] as $connectionHeader ) {
foreach ( explode ( ',' , $connectionHeader ) as $headerName ) {
$headerName = strtolower ( trim ( $headerName ));
if ( '' !== $headerName ) {
$excludedHeaders [ $headerName ] = true ;
}
}
}
return $excludedHeaders ;
}
/**
* @param array<string, string[]> $headers
* @param array{headers: array<string, string[]>} $cachedData
*/
private static function notModifiedResponseMatchesCachedResponse ( array $headers , array $cachedData ) : bool
{
if ( isset ( $headers [ 'etag' ])) {
$cachedEtags = $cachedData [ 'headers' ][ 'etag' ] ?? [];
foreach ( $headers [ 'etag' ] as $etag ) {
if ( ! self :: isWeakEtag ( $etag )) {
foreach ( $cachedEtags as $cachedEtag ) {
if ( ! self :: isWeakEtag ( $cachedEtag ) && $etag === $cachedEtag ) {
return true ;
}
}
return false ;
}
}
foreach ( $headers [ 'etag' ] as $etag ) {
foreach ( $cachedEtags as $cachedEtag ) {
if ( self :: stripWeakPrefix ( $etag ) === self :: stripWeakPrefix ( $cachedEtag )) {
return true ;
}
}
}
return false ;
}
foreach ( $headers [ 'last-modified' ] ?? [] as $lastModified ) {
return \in_array ( $lastModified , $cachedData [ 'headers' ][ 'last-modified' ] ?? [], true );
}
return ! isset ( $cachedData [ 'headers' ][ 'etag' ]) && ! isset ( $cachedData [ 'headers' ][ 'last-modified' ]);
}
/**
* @param array<string, string[]> $requestHeaders
* @param array{headers: array<string, string[]>} $cachedData
*/
private static function clientValidatorMatchesCachedResponse ( array $requestHeaders , array $cachedData ) : bool
{
if ( isset ( $requestHeaders [ 'if-none-match' ])) {
$cachedEtags = $cachedData [ 'headers' ][ 'etag' ] ?? [];
foreach ( $requestHeaders [ 'if-none-match' ] as $ifNoneMatch ) {
$ifNoneMatch = substr ( $ifNoneMatch , 15 );
foreach ( explode ( ',' , $ifNoneMatch ) as $etag ) {
$etag = trim ( $etag );
if ( '*' === $etag && [] !== $cachedEtags ) {
return true ;
}
foreach ( $cachedEtags as $cachedEtag ) {
if ( self :: stripWeakPrefix ( $etag ) === self :: stripWeakPrefix ( $cachedEtag )) {
return true ;
}
}
}
}
return false ;
}
if ( ! isset ( $requestHeaders [ 'if-modified-since' ], $cachedData [ 'headers' ][ 'last-modified' ][ 0 ])) {
return false ;
}
$lastModified = strtotime ( $cachedData [ 'headers' ][ 'last-modified' ][ 0 ]);
if ( false === $lastModified ) {
return false ;
}
foreach ( $requestHeaders [ 'if-modified-since' ] as $ifModifiedSince ) {
$ifModifiedSince = substr ( $ifModifiedSince , 19 );
if ( false !== $modifiedSince = strtotime ( $ifModifiedSince )) {
return $lastModified <= $modifiedSince ;
}
}
return false ;
}
private static function stripWeakPrefix ( string $etag ) : string
{
return str_starts_with ( $etag , 'W/' ) ? substr ( $etag , 2 ) : $etag ;
}
private static function isWeakEtag ( string $etag ) : bool
{
return str_starts_with ( $etag , 'W/' );
}
/**
* @param string[] $header
*
* @return array<string, string|true>
*/
private static function parseRequestCacheControlHeader ( array $header ) : array
{
$cacheControlHeader = [];
foreach ( $header as $line ) {
// Strip the "Cache-Control: " prefix added by normalizeHeaders(); response headers contain values only.
$cacheControlHeader [] = substr ( $line , 15 );
}
return self :: parseCacheControlHeader ( $cacheControlHeader );
}
/**
* Evaluates the freshness of a cached response based on its headers and expiration time.
*
* 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
*/
private function evaluateCacheFreshness ( array $data ) : Freshness
{
$cacheControl = self :: parseCacheControlHeader ( $data [ 'headers' ][ 'cache-control' ] ?? []);
if ( isset ( $cacheControl [ 'no-cache' ])) {
return Freshness :: Stale ;
}
$now = time ();
$expires = $data [ 'expires_at' ];
if ( null !== $expires && $now < $expires ) {
return Freshness :: Fresh ;
}
if (
isset ( $cacheControl [ 'must-revalidate' ])
|| (
$this -> sharedCache
&& (
isset ( $cacheControl [ 'proxy-revalidate' ])
|| self :: hasValidSharedMaxAge ( $cacheControl )
)
)
) {
return Freshness :: MustRevalidate ;
}
if (
null !== ( $staleIfError = self :: parseDeltaSeconds ( $cacheControl [ 'stale-if-error' ] ?? null ))
&& ( $now - $expires ) <= $staleIfError
) {
return Freshness :: StaleButUsable ;
}
return Freshness :: Stale ;
}
/**
* Determine the maximum age of the response.
*
* This method first checks for the presence of the s-maxage directive, and if
* present, returns its value minus the current age. If s-maxage is not present,
* it checks for the presence of the max-age directive, and if present, returns
* its value minus the current age. If neither directive is present, it checks
* 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.
*
* @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
*/
private function determineMaxAge ( array $headers , array $cacheControl , int $correctedInitialAge , int $requestTime , int $responseTime ) : ? int
{
$age = $correctedInitialAge ;
if ( $this -> sharedCache && isset ( $cacheControl [ 's-maxage' ])) {
if ( null === $sharedMaxAge = self :: parseDeltaSeconds ( $cacheControl [ 's-maxage' ])) {
return null ;
}
return max ( 0 , $sharedMaxAge - $age );
}
if ( isset ( $cacheControl [ 'max-age' ])) {
if ( null === $maxAge = self :: parseDeltaSeconds ( $cacheControl [ 'max-age' ])) {
return null ;
}
return max ( 0 , $maxAge - $age );
}
foreach ( $headers [ 'expires' ] ?? [] as $expire ) {
if ( false !== $expirationTimestamp = strtotime ( $expire )) {
$dateTimestamp = self :: getDateHeaderTimestamp ( $headers ) ?? $responseTime ;
$timeUntilExpiration = $expirationTimestamp - $dateTimestamp - $age ;
return max ( $timeUntilExpiration , 0 );
}
}
if ( null !== $heuristicFreshnessLifetime = $this -> determineHeuristicFreshnessLifetime ( $headers , $cacheControl , $requestTime )) {
return max ( 0 , $heuristicFreshnessLifetime - $age );
}
return null ;
}
/**
* @param array<string, string|string[]> $headers
* @param array<string, string|true> $cacheControl
*/
private function determineHeuristicFreshnessLifetime ( array $headers , array $cacheControl , int $requestTime ) : ? int
{
if ( ! $this -> allowsHeuristicFreshness ( $headers , $cacheControl )) {
return null ;
}
foreach ( $headers [ 'last-modified' ] as $lastModified ) {
if ( false === $lastModifiedTimestamp = strtotime ( $lastModified )) {
continue ;
}
$secondsSinceLastModified = $requestTime - $lastModifiedTimestamp ;
if ( 0 >= $secondsSinceLastModified ) {
continue ;
}
$heuristicFreshnessLifetime = ( int ) ( $secondsSinceLastModified * self :: HEURISTIC_FRESHNESS_FRACTION );
return min ( $heuristicFreshnessLifetime , self :: MAX_HEURISTIC_FRESHNESS_TTL );
}
return null ;
}
/**
* Retrieves the current age of the response from the 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
*/
private static function getCurrentAge ( array $headers ) : int
{
return ( int ) ( $headers [ 'age' ][ 0 ] ?? 0 );
}
/**
* @param array<string, string|string[]> $headers
*/
private static function getDateHeaderTimestamp ( array $headers ) : ? int
{
foreach ( $headers [ 'date' ] ?? [] as $date ) {
if ( false !== $timestamp = strtotime ( $date )) {
return $timestamp ;
}
}
return null ;
}
/**
* @param array<string, string|string[]> $headers
*/
private static function getCorrectedInitialAge ( array $headers , int $requestTime , int $responseTime ) : int
{
$ageValue = self :: getCurrentAge ( $headers );
$dateValue = self :: getDateHeaderTimestamp ( $headers );
$apparentAge = null === $dateValue ? 0 : max ( 0 , $responseTime - $dateValue );
$responseDelay = max ( 0 , $responseTime - $requestTime );
$correctedAgeValue = $ageValue + $responseDelay ;
return max ( $apparentAge , $correctedAgeValue );
}
/**
* @param array{initial_age: int, stored_at: int} $cachedData
* @param array<string, string|true> $requestCacheControl
*/
private function isCachedResponseAcceptable ( array $cachedData , array $requestCacheControl , Freshness $freshness ) : bool
{
if ( Freshness :: Fresh !== $freshness || isset ( $requestCacheControl [ 'no-cache' ])) {
return false ;
}
if ( ! isset ( $requestCacheControl [ 'max-age' ])) {
return true ;
}
if ( null === $maxAge = self :: parseDeltaSeconds ( $requestCacheControl [ 'max-age' ])) {
return true ;
}
if ( 0 === $maxAge ) {
return false ;
}
return $this -> getCachedResponseAge ( $cachedData ) <= $maxAge ;
}
/**
* @param array{initial_age: int, stored_at: int} $cachedData
* @param array<string, string|true> $requestCacheControl
*/
private function requestCacheControlRequiresRevalidation ( array $cachedData , array $requestCacheControl ) : bool
{
if ( isset ( $requestCacheControl [ 'no-cache' ])) {
return true ;
}
if ( null === $maxAge = self :: parseDeltaSeconds ( $requestCacheControl [ 'max-age' ] ?? null )) {
return false ;
}
return $this -> getCachedResponseAge ( $cachedData ) > $maxAge ;
}
/**
* @param array{initial_age: int, stored_at: int} $cachedData
*/
private function getCachedResponseAge ( array $cachedData ) : int
{
return $cachedData [ 'initial_age' ] + ( time () - $cachedData [ 'stored_at' ]);
}
/**
* 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
*
* @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
{
if ( null === $maxAge ) {
return null ;
}
return time () + $maxAge ;
}
/**
* @param string[] $varyHeaders
*
* @return string[]
*/
private function parseVaryFields ( array $varyHeaders ) : array
{
$varyFields = [];
foreach ( $varyHeaders as $vary ) {
foreach ( explode ( ',' , $vary ) as $field ) {
$field = strtolower ( trim ( $field ));
if ( 'cookie' === $field ? $this -> sharedCache : ! preg_match ( '/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D' , $field )) {
$field = '*' ;
}
$varyFields [] = $field ;
}
}
sort ( $varyFields );
return $varyFields ;
}
/**
* Checks if the server response is cacheable according to the HTTP 1.1
* specification (RFC 9111).
*
* This function will return true if the server response can be cached,
* false otherwise.
*
* @param array<string, string|string[]> $requestHeaders
* @param array<string, string|string[]> $responseHeaders
* @param array<string, string|true> $cacheControl
*/
private function isServerResponseCacheable ( int $statusCode , array $requestHeaders , array $responseHeaders , array $cacheControl ) : bool
{
// Only final status codes are cacheable by RFC 9111 Section 3.
if ( $statusCode < 200 || $statusCode > 599 || self :: isStatusCodeExcludedFromStorage ( $statusCode )) {
return false ;
}
// no-store => skip caching
if ( isset ( $cacheControl [ 'no-store' ])) {
return false ;
}
if ( $this -> sharedCache ) {
if (
! isset ( $cacheControl [ 'public' ]) && ! self :: hasValidSharedMaxAge ( $cacheControl ) && ! isset ( $cacheControl [ 'must-revalidate' ])
&& isset ( $requestHeaders [ 'authorization' ])
) {
return false ;
}
if ( isset ( $cacheControl [ 'private' ])) {
return false ;
}
if ( isset ( $responseHeaders [ 'authentication-info' ]) || isset ( $responseHeaders [ 'set-cookie' ]) || isset ( $responseHeaders [ 'www-authenticate' ])) {
return false ;
}
}
if ( \in_array ( $statusCode , self :: DEFAULT_CACHEABLE_STATUS_CODES , true )) {
return true ;
}
if ( $this -> hasExplicitFreshness ( $responseHeaders , $cacheControl )) {
return true ;
}
return $this -> allowsHeuristicFreshnessForNonDefaultStatus ( $responseHeaders , $cacheControl );
}
private static function isStatusCodeExcludedFromStorage ( int $statusCode ) : bool
{
// 206 is only cacheable for range requests, which this client does not store.
return 206 === $statusCode || 304 === $statusCode ;
}
/**
* Checks if the response has explicit freshness.
*
* This function will return true if the response has explicit freshness
* specified in the headers or in the Cache-Control directives,
* false otherwise.
*
* @param array<string, string|string[]> $headers
* @param array<string, string|true> $cacheControl
*/
private function hasExplicitFreshness ( array $headers , array $cacheControl ) : bool
{
if (( $this -> sharedCache && self :: hasValidSharedMaxAge ( $cacheControl )) || null !== self :: parseDeltaSeconds ( $cacheControl [ 'max-age' ] ?? null )) {
return true ;
}
foreach ( $headers [ 'expires' ] ?? [] as $expires ) {
if ( false !== strtotime ( $expires )) {
return true ;
}
}
return false ;
}
/**
* @param array<string, string|true> $cacheControl
*/
private static function hasValidSharedMaxAge ( array $cacheControl ) : bool
{
return null !== self :: parseDeltaSeconds ( $cacheControl [ 's-maxage' ] ?? null );
}
/**
* @param array<string, string|string[]> $headers
* @param array<string, string|true> $cacheControl
*/
private function allowsHeuristicFreshnessForNonDefaultStatus ( array $headers , array $cacheControl ) : bool
{
return isset ( $cacheControl [ 'public' ]) && $this -> allowsHeuristicFreshness ( $headers , $cacheControl );
}
/**
* @param array<string, string|string[]> $headers
* @param array<string, string|true> $cacheControl
*/
private function allowsHeuristicFreshness ( array $headers , array $cacheControl ) : bool
{
return ! isset ( $cacheControl [ 'no-cache' ])
&& ! isset ( $cacheControl [ 'no-store' ])
&& isset ( $headers [ 'last-modified' ]);
}
/**
* Creates a MockResponse object from cached data.
*
* This function constructs a MockResponse from the cached data, including
* the original request method, URL, and options, as well as the cached
* response headers and content. The constructed MockResponse is then
* returned.
*
* @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 $metadataKey , \DateTimeImmutable | false | null $newExpiresAt = false ) : MockResponse
{
$cache = $this -> cache ;
$beta = 0 ;
$callback = static function ( ItemInterface $item ) use ( $cache , $metadataKey ) : never {
$cache -> invalidateTags ([ $metadataKey ]);
throw new ChunkCacheItemNotFoundException ( \sprintf ( 'Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.' , $item -> getKey ()));
};
if ( false !== $newExpiresAt ) {
$beta = \INF ;
$callback = static function ( ItemInterface $item ) use ( $callback , $newExpiresAt ) : array {
if ( ! $item -> isHit ()) {
$callback ( $item );
}
$item -> expiresAt ( $newExpiresAt );
return $item -> get ();
};
}
$body = static function () use ( $cache , $cachedData , $callback , $beta ) : \Generator {
while ( null !== $cachedData [ 'next_chunk' ]) {
$cachedData = $cache -> get ( $cachedData [ 'next_chunk' ], $callback , $beta );
if ( '' !== $cachedData [ 'content' ]) {
yield $cachedData [ 'content' ];
}
}
};
return MockResponse :: fromRequest ( $method , $url , $options , new MockResponse ( $body (), [
'http_code' => $cachedData [ 'status_code' ],
'response_headers' => [
'age' => $this -> getCachedResponseAge ( $cachedData ),
] + $cachedData [ 'headers' ],
]));
}
private static function createGatewayTimeoutResponse ( string $method , string $url , array $options ) : MockResponse
{
return MockResponse :: fromRequest ( $method , $url , $options , new MockResponse ( '' , [ 'http_code' => 504 ]));
}
/**
* @param array<string, string|string[]> $headers
*/
private static function createNotModifiedResponse ( string $method , string $url , array $options , array $headers ) : MockResponse
{
return MockResponse :: fromRequest ( $method , $url , $options , new MockResponse ( '' , [
'http_code' => 304 ,
'response_headers' => array_diff_key ( $headers , self :: EXCLUDED_HEADERS ),
]));
}
}