Skip to content

[HttpClient] Make CachingHttpClient compatible with RFC 9111 #59576

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 53 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
867ee2a
[HttpClient] Add an RFC 9111 compliant client
Lctrs Jan 21, 2025
d8b4992
rename and doc
Lctrs Jan 22, 2025
492b4ab
remove configurable status codes and methods + doc constructor
Lctrs Jan 22, 2025
4dc0157
phpdoc
Lctrs Jan 22, 2025
5462d60
into cachinghttpclient with bc layer
Lctrs Jan 22, 2025
ea6004f
cs
Lctrs Jan 22, 2025
6a2ee73
encode vary field value to avoid collision
Lctrs Jan 22, 2025
dc8069f
require-dev symfony/cache
Lctrs Jan 22, 2025
c06169d
fixing legacy tests
Lctrs Jan 22, 2025
5339c8f
fix legacy tests 2
Lctrs Feb 18, 2025
033f9d8
throw exception when chunk cache item not found
Lctrs Feb 18, 2025
ffedf61
fix check if response is cacheable
Lctrs Feb 18, 2025
c5b0404
more fixes and tests
Lctrs Feb 18, 2025
71f2e3f
cs fixes
Lctrs Feb 18, 2025
54e9fb2
replace ttl by maxTtl (should be clearer) + add missing docs + fix tests
Lctrs Feb 18, 2025
4079e2e
fix lowest tests
Lctrs Feb 18, 2025
22d5691
add changelogs
Lctrs Feb 18, 2025
fb273c9
own cache pool
Lctrs Feb 19, 2025
061118e
added -> add
Lctrs Feb 19, 2025
9c7fe49
$store -> $cache
Lctrs Feb 19, 2025
59cbd40
private legacy
Lctrs Feb 19, 2025
288a2a2
cleanup phpdocs
Lctrs Feb 19, 2025
04439cd
freshness enum
Lctrs Feb 19, 2025
aeddd21
fix stream issues
Lctrs Feb 19, 2025
ea96bf2
more phpdoc fix
Lctrs Feb 19, 2025
a2969ba
cs fix
Lctrs Feb 19, 2025
a52a36b
fix cache definition
Lctrs Feb 20, 2025
47d5422
put caching client between retry and throttling and invalidate cache …
Lctrs Feb 20, 2025
03b782a
also clock mock symfony cache namespace
Lctrs Feb 20, 2025
c949f8a
bcb: also return async response
Lctrs Feb 20, 2025
7f888ac
fix stream and add tests
Lctrs Feb 20, 2025
e6b3756
extend TransportException
Lctrs Feb 21, 2025
e5260d3
tests: in memory cache adapter
Lctrs Feb 21, 2025
454339a
order UPGRADE-7.3.md
Lctrs Feb 28, 2025
df72186
ensure positive integer for max_ttl option
Lctrs Feb 28, 2025
1ab157d
reword CHANGELOG.md
Lctrs Feb 28, 2025
6d4f0d6
remove empty() usage
Lctrs Feb 28, 2025
1b0a30a
evaluateCachedFreshness -> evaluateCacheFreshness
Lctrs Feb 28, 2025
73311bc
remove dev deps on symfony/filesystem
Lctrs Feb 28, 2025
6b1dfda
add more tests
Lctrs Mar 12, 2025
463b71b
vary asterisk prevents caching
Lctrs Mar 12, 2025
0ebc780
exclude non cacheable headers from cache
Lctrs Mar 12, 2025
d1886eb
remove wrongly cacheable status codes
Lctrs Mar 12, 2025
e15142c
implement heuristic caching
Lctrs Mar 12, 2025
bb9b0ba
Update src/Symfony/Component/HttpClient/CachingHttpClient.php
Lctrs Mar 31, 2025
342fedc
Update src/Symfony/Component/HttpClient/CachingHttpClient.php
Lctrs Mar 31, 2025
e16d16f
Update src/Symfony/Component/HttpClient/CachingHttpClient.php
Lctrs Mar 31, 2025
bf285c0
Update src/Symfony/Component/HttpClient/CachingHttpClient.php
Lctrs Mar 31, 2025
8c9871d
switch to a sha256 based hash
Lctrs Mar 31, 2025
54b9fde
turn some vars by ref to static ones
Lctrs Mar 31, 2025
4c1a097
cs
Lctrs Mar 31, 2025
df3f803
more static methods
Lctrs Mar 31, 2025
7c66ffd
switch to TagAwareCacheInterface
Lctrs Apr 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion UPGRADE-7.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ FrameworkBundle
public function __construct(#[Autowire('@serializer.normalizer.object')] NormalizerInterface $normalizer) {}
```

HttpClient
----------

* Deprecate passing an instance of `StoreInterface` as `$cache` argument to `CachingHttpClient` constructor

HttpFoundation
--------------

Expand Down Expand Up @@ -323,4 +328,4 @@ Workflow
$workflow = $this->workflows->get($event->getWorkflowName());
}
}
```
```
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ CHANGELOG
* Set `framework.rate_limiter.limiters.*.lock_factory` to `auto` by default
* Deprecate `RateLimiterFactory` autowiring aliases, use `RateLimiterFactoryInterface` instead
* Allow configuring compound rate limiters
* Add support for configuring the `CachingHttpClient`

7.2
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2002,6 +2002,7 @@
->defaultNull()
->info('Rate limiter name to use for throttling requests.')
->end()
->append($this->createHttpClientCachingSection())
->append($this->createHttpClientRetrySection())
->end()
->end()
Expand Down Expand Up @@ -2157,6 +2158,7 @@
->defaultNull()
->info('Rate limiter name to use for throttling requests.')
->end()
->append($this->createHttpClientCachingSection())
->append($this->createHttpClientRetrySection())
->end()
->end()
Expand All @@ -2167,6 +2169,33 @@
;
}

private function createHttpClientCachingSection(): ArrayNodeDefinition
{
$root = new NodeBuilder();

return $root
->arrayNode('caching')
->info('Caching configuration.')
->canBeEnabled()
->addDefaultsIfNotSet()
->children()
->stringNode('cache_pool')
->info('The taggable cache pool to use for storing the responses.')
->defaultValue('cache.http_client')
->cannotBeEmpty()
->end()
->booleanNode('shared')

Check failure on line 2187 in src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedInterfaceMethod

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php:2187:23: UndefinedInterfaceMethod: Method Symfony\Component\Config\Definition\Builder\NodeParentInterface::booleanNode does not exist (see https://psalm.dev/181)
->info('Indicates whether the cache is shared (public) or private.')
->defaultTrue()
->end()
->integerNode('max_ttl')
->info('The maximum TTL (in seconds) allowed for cached responses. Null means no cap.')
->defaultNull()
->min(0)
->end()
->end();
}

private function createHttpClientRetrySection(): ArrayNodeDefinition
{
$root = new NodeBuilder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
use Symfony\Component\HttpClient\CachingHttpClient;
use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException;
use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
Expand Down Expand Up @@ -2670,6 +2672,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
$loader->load('http_client.php');

$options = $config['default_options'] ?? [];
$cachingOptions = $options['caching'] ?? ['enabled' => false];
unset($options['caching']);
$rateLimiter = $options['rate_limiter'] ?? null;
unset($options['rate_limiter']);
$retryOptions = $options['retry_failed'] ?? ['enabled' => false];
Expand All @@ -2693,6 +2697,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
$container->removeAlias(HttpClient::class);
}

if ($this->readConfigEnabled('http_client.caching', $container, $cachingOptions)) {
$this->registerCachingHttpClient($cachingOptions, $options, 'http_client', $container);
}

if (null !== $rateLimiter) {
$this->registerThrottlingHttpClient($rateLimiter, 'http_client', $container);
}
Expand All @@ -2718,6 +2726,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder

$scope = $scopeConfig['scope'] ?? null;
unset($scopeConfig['scope']);
$cachingOptions = $scopeConfig['caching'] ?? ['enabled' => false];
unset($scopeConfig['caching']);
$rateLimiter = $scopeConfig['rate_limiter'] ?? null;
unset($scopeConfig['rate_limiter']);
$retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false];
Expand All @@ -2741,6 +2751,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
;
}

if ($this->readConfigEnabled('http_client.scoped_clients.'.$name.'.caching', $container, $cachingOptions)) {
$this->registerCachingHttpClient($cachingOptions, $scopeConfig, $name, $container);
}

if (null !== $rateLimiter) {
$this->registerThrottlingHttpClient($rateLimiter, $name, $container);
}
Expand Down Expand Up @@ -2782,6 +2796,24 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
}
}

private function registerCachingHttpClient(array $options, array $defaultOptions, string $name, ContainerBuilder $container): void
{
if (!class_exists(ChunkCacheItemNotFoundException::class)) {
throw new LogicException('Caching cannot be enabled as version 7.3+ of the HttpClient component is required.');
}

$container
->register($name.'.caching', CachingHttpClient::class)
->setDecoratedService($name, null, 13) // between RetryableHttpClient (10) and ThrottlingHttpClient (15)
->setArguments([
new Reference($name.'.caching.inner'),
new Reference($options['cache_pool']),
$defaultOptions,
$options['shared'],
$options['max_ttl'],
]);
}

private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void
{
if (!class_exists(ThrottlingHttpClient::class)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\HttplugClient;
use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler;
Expand All @@ -25,6 +26,14 @@

return static function (ContainerConfigurator $container) {
$container->services()
->set('cache.http_client.pool')
->parent('cache.app')
->tag('cache.pool')

->set('cache.http_client', TagAwareAdapter::class)
->args([service('cache.http_client.pool')])
->tag('cache.taggable', ['pool' => 'cache.http_client.pool'])

->set('http_client.transport', HttpClientInterface::class)
->factory([HttpClient::class, 'create'])
->args([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="caching" type="http_client_caching" minOccurs="0" maxOccurs="1" />
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
<xsd:element name="extra" type="xsd:anyType" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice>
Expand Down Expand Up @@ -739,6 +740,7 @@
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="caching" type="http_client_caching" minOccurs="0" maxOccurs="1" />
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
<xsd:element name="extra" type="xsd:anyType" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice>
Expand Down Expand Up @@ -772,6 +774,13 @@
</xsd:choice>
</xsd:complexType>

<xsd:complexType name="http_client_caching">
<xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="cache-pool" type="xsd:string" />
<xsd:attribute name="shared" type="xsd:boolean" />
<xsd:attribute name="max-ttl" type="xsd:unsignedInt" />
</xsd:complexType>

<xsd:complexType name="http_client_retry_failed">
<xsd:sequence>
<xsd:element name="http-code" type="http_client_retry_code" minOccurs="0" maxOccurs="unbounded" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

$container->loadFromExtension('framework', [
'annotations' => false,
'http_method_override' => false,
'handle_all_throwables' => true,
'php_errors' => ['log' => true],
'http_client' => [
'default_options' => [
'headers' => ['X-powered' => 'PHP'],
'caching' => [
'cache_pool' => 'foo',
'shared' => false,
'max_ttl' => 2,
],
],
'scoped_clients' => [
'bar' => [
'base_uri' => 'http://example.com',
'caching' => ['cache_pool' => 'baz'],
],
],
],
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:framework="http://symfony.com/schema/dic/symfony"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd
http://symfony.com/schema/dic/symfony http://symfony.com/schema/dic/symfony/symfony-1.0.xsd">

<framework:config http-method-override="false" handle-all-throwables="true">
<framework:annotations enabled="false" />
<framework:php-errors log="true" />
<framework:http-client>
<framework:default-options>
<framework:header name="X-powered">PHP</framework:header>
<framework:caching enabled="true" cache-pool="foo" shared="false" max-ttl="2"/>
</framework:default-options>
<framework:scoped-client name="bar" base-uri="http://example.com">
<framework:caching cache-pool="baz"/>
</framework:scoped-client>
</framework:http-client>
</framework:config>
</container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
framework:
annotations: false
http_method_override: false
handle_all_throwables: true
php_errors:
log: true
http_client:
default_options:
headers:
X-powered: PHP
caching:
cache_pool: foo
shared: false
max_ttl: 2
scoped_clients:
bar:
base_uri: http://example.com
caching:
cache_pool: baz
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
use Symfony\Component\HttpClient\CachingHttpClient;
use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient;
Expand Down Expand Up @@ -2073,6 +2075,39 @@ public function testHttpClientOverrideDefaultOptions()
$this->assertEquals($expected, $container->getDefinition('foo')->getArgument(2));
}

public function testCachingHttpClient()
{
if (!class_exists(ChunkCacheItemNotFoundException::class)) {
$this->expectException(LogicException::class);
}

$container = $this->createContainerFromFile('http_client_caching');

$this->assertTrue($container->hasDefinition('http_client.caching'));
$definition = $container->getDefinition('http_client.caching');
$this->assertSame(CachingHttpClient::class, $definition->getClass());
$this->assertSame('http_client', $definition->getDecoratedService()[0]);
$this->assertCount(5, $arguments = $definition->getArguments());
$this->assertInstanceOf(Reference::class, $arguments[0]);
$this->assertSame('http_client.caching.inner', (string) $arguments[0]);
$this->assertInstanceOf(Reference::class, $arguments[1]);
$this->assertSame('foo', (string) $arguments[1]);
$this->assertArrayHasKey('headers', $arguments[2]);
$this->assertSame(['X-powered' => 'PHP'], $arguments[2]['headers']);
$this->assertFalse($arguments[3]);
$this->assertSame(2, $arguments[4]);

$this->assertTrue($container->hasDefinition('bar.caching'));
$definition = $container->getDefinition('bar.caching');
$this->assertSame(CachingHttpClient::class, $definition->getClass());
$this->assertSame('bar', $definition->getDecoratedService()[0]);
$arguments = $definition->getArguments();
$this->assertInstanceOf(Reference::class, $arguments[0]);
$this->assertSame('bar.caching.inner', (string) $arguments[0]);
$this->assertInstanceOf(Reference::class, $arguments[1]);
$this->assertSame('baz', (string) $arguments[1]);
}

public function testHttpClientRetry()
{
$container = $this->createContainerFromFile('http_client_retry');
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpClient/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CHANGELOG

* Add IPv6 support to `NativeHttpClient`
* Allow using HTTP/3 with the `CurlHttpClient`
* Add RFC 9111–based caching support to `CachingHttpClient`

7.2
---
Expand Down
35 changes: 35 additions & 0 deletions src/Symfony/Component/HttpClient/Caching/Freshness.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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\Caching;

/**
* @internal
*/
enum Freshness
{
/**
* The cached response is fresh and can be used without revalidation.
*/
case Fresh;
/**
* The cached response is stale and must be revalidated before use.
*/
case MustRevalidate;
/**
* The cached response is stale and should not be used.
*/
case Stale;
/**
* The cached response is stale but may be used as a fallback in case of errors.
*/
case StaleButUsable;
}
Loading
Loading