mirror of
https://github.com/symfony/http-client.git
synced 2026-03-24 00:12:11 +01:00
753 lines
29 KiB
PHP
753 lines
29 KiB
PHP
<?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\Response\AsyncContext;
|
|
use Symfony\Component\HttpClient\Response\AsyncResponse;
|
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
|
use Symfony\Component\HttpClient\Response\ResponseStream;
|
|
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, only-if-cached:
|
|
* - Request directives are not parsed; the client ignores them.
|
|
*
|
|
* @see https://www.rfc-editor.org/rfc/rfc9111
|
|
*/
|
|
class CachingHttpClient implements HttpClientInterface, ResetInterface
|
|
{
|
|
use AsyncDecoratorTrait {
|
|
stream as asyncStream;
|
|
AsyncDecoratorTrait::withOptions insteadof HttpClientTrait;
|
|
}
|
|
use HttpClientTrait;
|
|
|
|
/**
|
|
* The status codes that are always cacheable.
|
|
*/
|
|
private const CACHEABLE_STATUS_CODES = [200, 203, 204, 300, 301, 404, 410];
|
|
/**
|
|
* The status codes that are cacheable if the response carries explicit cache directives.
|
|
*/
|
|
private const CONDITIONALLY_CACHEABLE_STATUS_CODES = [302, 303, 307, 308];
|
|
/**
|
|
* The HTTP methods that are always cacheable.
|
|
*/
|
|
private const CACHEABLE_METHODS = ['GET', 'HEAD'];
|
|
/**
|
|
* The HTTP methods that will trigger a cache invalidation.
|
|
*/
|
|
private const UNSAFE_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
|
|
/**
|
|
* 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,
|
|
'proxy-authenticate' => true,
|
|
'proxy-authentication-info' => true,
|
|
'proxy-authorization' => true,
|
|
];
|
|
/**
|
|
* Maximum heuristic freshness lifetime in seconds (24 hours).
|
|
*/
|
|
private const MAX_HEURISTIC_FRESHNESS_TTL = 86400;
|
|
|
|
private array $defaultOptions = self::OPTIONS_DEFAULTS;
|
|
private bool $isInnerRequest = false;
|
|
|
|
/**
|
|
* @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 positive-int $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.
|
|
*/
|
|
public function __construct(
|
|
private HttpClientInterface $client,
|
|
private readonly TagAwareCacheInterface $cache,
|
|
array $defaultOptions = [],
|
|
private readonly bool $sharedCache = true,
|
|
private readonly ?int $maxTtl = 86400,
|
|
) {
|
|
if (null === $maxTtl) {
|
|
trigger_deprecation('symfony/http-client', '8.1', 'Passing null as "$maxTtl" to "%s()" is deprecated, pass a positive integer instead.', __METHOD__);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
[$fullUrl, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
|
|
|
|
$fullUrl = implode('', $fullUrl);
|
|
$fullUrlTag = self::hash($fullUrl);
|
|
|
|
if ('' !== $options['body'] || ($options['extra']['no_cache'] ?? false) || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) {
|
|
$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 >= 100 && $statusCode < 400 && \in_array($method, self::UNSAFE_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;
|
|
}
|
|
}
|
|
|
|
$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);
|
|
|
|
$freshness = null;
|
|
if ($cachedData) {
|
|
$freshness = $this->evaluateCacheFreshness($cachedData);
|
|
|
|
if (Freshness::Fresh === $freshness) {
|
|
return $this->createResponseFromCache($cachedData, $method, $url, $options, $metadataKey);
|
|
}
|
|
|
|
if (isset($cachedData['headers']['etag'])) {
|
|
$options['headers']['If-None-Match'] = implode(', ', $cachedData['headers']['etag']);
|
|
}
|
|
|
|
if (isset($cachedData['headers']['last-modified'][0])) {
|
|
$options['headers']['If-Modified-Since'] = $cachedData['headers']['last-modified'][0];
|
|
}
|
|
}
|
|
|
|
// consistent expiration time for all items
|
|
$expiresAt = \DateTimeImmutable::createFromFormat('U', time() + ($this->maxTtl ?? 86400));
|
|
|
|
$passthru = function (ChunkInterface $chunk, AsyncContext $context) use (
|
|
$expiresAt,
|
|
$fullUrlTag,
|
|
$requestHash,
|
|
$varyKey,
|
|
$varyFields,
|
|
&$metadataKey,
|
|
$cachedData,
|
|
$freshness,
|
|
$url,
|
|
$method,
|
|
$options,
|
|
): \Generator {
|
|
static $attemptTag = null;
|
|
static $firstChunkKey = null;
|
|
static $chunkKey = null;
|
|
|
|
if (null !== $chunk->getError() || $chunk->isTimeout()) {
|
|
null !== $attemptTag && $this->cache->invalidateTags([$attemptTag]);
|
|
|
|
if (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) {
|
|
$headers = array_merge($cachedData['headers'], array_diff_key($headers, self::EXCLUDED_HEADERS));
|
|
|
|
$cacheControl = self::parseCacheControlHeader($headers['cache-control'] ?? []);
|
|
$maxAge = $this->determineMaxAge($headers, $cacheControl);
|
|
|
|
$this->cache->get($metadataKey, static function (ItemInterface $item) use ($headers, $maxAge, $cachedData, $expiresAt, $fullUrlTag, $metadataKey): array {
|
|
$item->expiresAt($expiresAt)->tag([$fullUrlTag, $metadataKey]);
|
|
|
|
$cachedData['expires_at'] = self::calculateExpiresAt($maxAge);
|
|
$cachedData['stored_at'] = time();
|
|
$cachedData['initial_age'] = self::getCurrentAge($headers);
|
|
$cachedData['headers'] = $headers;
|
|
|
|
return $cachedData;
|
|
}, \INF);
|
|
|
|
$context->passthru();
|
|
$context->replaceResponse($this->createResponseFromCache($cachedData, $method, $url, $options, $metadataKey, $expiresAt));
|
|
|
|
return;
|
|
}
|
|
|
|
if ($statusCode >= 500 && $statusCode < 600) {
|
|
if (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 = [];
|
|
foreach ($headers['vary'] ?? [] 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 = '*';
|
|
}
|
|
$newVaryFields[] = $field;
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
$maxAge = $this->determineMaxAge($headers, self::parseCacheControlHeader($headers['cache-control'] ?? []));
|
|
$this->cache->get($metadataKey, static function (ItemInterface $item) use ($context, $headers, $maxAge, $expiresAt, $fullUrlTag, $metadataKey, $attemptTag, $firstChunkKey): array {
|
|
$item->tag([$fullUrlTag, $metadataKey, $attemptTag])->expiresAt($expiresAt);
|
|
|
|
return [
|
|
'status_code' => $context->getStatusCode(),
|
|
'headers' => array_diff_key($headers, self::EXCLUDED_HEADERS),
|
|
'initial_age' => self::getCurrentAge($headers),
|
|
'stored_at' => time(),
|
|
'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);
|
|
}
|
|
})());
|
|
}
|
|
|
|
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);
|
|
$parsed[trim($name)] = trim($value);
|
|
} else {
|
|
$parsed[trim($directive)] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$parseCacheControlHeader = self::parseCacheControlHeader($data['headers']['cache-control'] ?? []);
|
|
|
|
if (isset($parseCacheControlHeader['no-cache'])) {
|
|
return Freshness::Stale;
|
|
}
|
|
|
|
$now = time();
|
|
$expires = $data['expires_at'];
|
|
|
|
if (null !== $expires && $now < $expires) {
|
|
return Freshness::Fresh;
|
|
}
|
|
|
|
if (
|
|
isset($parseCacheControlHeader['must-revalidate'])
|
|
|| ($this->sharedCache && isset($parseCacheControlHeader['proxy-revalidate']))
|
|
) {
|
|
return Freshness::MustRevalidate;
|
|
}
|
|
|
|
if (isset($parseCacheControlHeader['stale-if-error']) && ($now - $expires) <= (int) $parseCacheControlHeader['stale-if-error']) {
|
|
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
|
|
{
|
|
$age = self::getCurrentAge($headers);
|
|
|
|
if ($this->sharedCache && isset($cacheControl['s-maxage'])) {
|
|
$sharedMaxAge = (int) $cacheControl['s-maxage'];
|
|
|
|
return max(0, $sharedMaxAge - $age);
|
|
}
|
|
|
|
if (isset($cacheControl['max-age'])) {
|
|
$maxAge = (int) $cacheControl['max-age'];
|
|
|
|
return max(0, $maxAge - $age);
|
|
}
|
|
|
|
foreach ($headers['expires'] ?? [] as $expire) {
|
|
if (false !== $expirationTimestamp = strtotime($expire)) {
|
|
$timeUntilExpiration = $expirationTimestamp - time() - $age;
|
|
|
|
return max($timeUntilExpiration, 0);
|
|
}
|
|
}
|
|
|
|
// Heuristic freshness fallback when no explicit directives are present
|
|
if (
|
|
!isset($cacheControl['no-cache'])
|
|
&& !isset($cacheControl['no-store'])
|
|
&& isset($headers['last-modified'])
|
|
) {
|
|
foreach ($headers['last-modified'] as $lastModified) {
|
|
if (false === $lastModifiedTimestamp = strtotime($lastModified)) {
|
|
continue;
|
|
}
|
|
if (0 < $secondsSinceLastModified = time() - $lastModifiedTimestamp) {
|
|
// Heuristic: 10% of time since last modified, capped at max heuristic freshness
|
|
$heuristicFreshnessSeconds = (int) ($secondsSinceLastModified * 0.1);
|
|
$cappedHeuristicFreshness = min($heuristicFreshnessSeconds, self::MAX_HEURISTIC_FRESHNESS_TTL);
|
|
|
|
return max(0, $cappedHeuristicFreshness - $age);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
// no-store => skip caching
|
|
if (isset($cacheControl['no-store'])) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->sharedCache) {
|
|
if (
|
|
!isset($cacheControl['public']) && !isset($cacheControl['s-maxage']) && !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;
|
|
}
|
|
}
|
|
|
|
// Conditionals require an explicit expiration
|
|
if (\in_array($statusCode, self::CONDITIONALLY_CACHEABLE_STATUS_CODES, true)) {
|
|
return $this->hasExplicitExpiration($responseHeaders, $cacheControl);
|
|
}
|
|
|
|
return \in_array($statusCode, self::CACHEABLE_STATUS_CODES, true);
|
|
}
|
|
|
|
/**
|
|
* Checks if the response has an explicit expiration.
|
|
*
|
|
* This function will return true if the response has an explicit expiration
|
|
* time 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 hasExplicitExpiration(array $headers, array $cacheControl): bool
|
|
{
|
|
return isset($headers['expires'])
|
|
|| ($this->sharedCache && isset($cacheControl['s-maxage']))
|
|
|| isset($cacheControl['max-age']);
|
|
}
|
|
|
|
/**
|
|
* 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' => $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]));
|
|
}
|
|
}
|