Files
wp-agentic-writer/vendor/symfony/http-client/CachingHttpClient.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

1211 lines
45 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 Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
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:
* - These request directives are not parsed; the client ignores them.
*
* @see https://www.rfc-editor.org/rfc/rfc9111
*/
class CachingHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
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;
private array $defaultOptions = self::OPTIONS_DEFAULTS;
private bool $isInnerRequest = false;
private ?LoggerInterface $logger = null;
/**
* @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 setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
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);
$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);
}
// 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,
$hasClientConditionalValidator,
$sentCacheValidator,
$allowStaleFallback,
): \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) {
$this->logger?->info('Serving stale cached response for "{method} {url}" because the upstream call failed: {error}.', [
'method' => $method,
'url' => $url,
'error' => $chunk instanceof ErrorChunk ? $chunk->getError() : 'timeout',
]);
// 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();
$requestTime = (int) ($context->getInfo('start_time') ?? $responseTime);
if ($requestTime <= $cachedData['stored_at']) {
$requestTime = $responseTime;
}
$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) {
$this->logger?->info('Serving stale cached response for "{method} {url}" because the upstream returned a server error (HTTP {status}).', [
'method' => $method,
'url' => $url,
'status' => $statusCode,
]);
$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);
$requestTime = (int) ($context->getInfo('start_time') ?? time());
$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);
}
})());
}
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),
]));
}
}