Skip to content

Commit f0f3040

Browse files
committed
feature symfony#53550 [FrameworkBundle][HttpClient] Add ThrottlingHttpClient to limit requests within a timeframe (HypeMC)
This PR was merged into the 7.1 branch. Discussion ---------- [FrameworkBundle][HttpClient] Add `ThrottlingHttpClient` to limit requests within a timeframe | Q | A | ------------- | --- | Branch? | 7.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT This PR adds a simple `ThrottlingHttpClient` to help with limiting the number of requests within a certain period. Simple example, don't send more than 10 requests in 5 second: ```yaml framework: http_client: scoped_clients: ping.client: base_uri: 'http://localhost:8080' rate_limiter: http_throttling rate_limiter: http_throttling: policy: 'token_bucket' limit: 10 rate: { interval: '5 seconds', amount: 10 } ``` ```php #[AsCommand('app:ping')] class PingCommand extends Command { public function __construct( #[Target('ping.client')] private HttpClientInterface $httpClient, ) { parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int { $requests = []; for ($i = 0; $i < 100; $i++) { $requests[] = $this->httpClient->request('GET', '/ping'); } foreach ($requests as $request) { $output->writeln($request->getContent()); } return Command::SUCCESS; } } ``` Receiving controller: ```php #[AsController] class PingController { #[Route('/ping', name: 'app_ping')] public function __invoke(): Response { return new Response((new \DateTime())->format('Y-m-d\TH:i:s.u')); } } ``` Output: ``` $ bin/console app:ping 2024-01-16T11:52:54.922597 2024-01-16T11:52:54.964851 2024-01-16T11:52:55.009504 2024-01-16T11:52:55.053986 2024-01-16T11:52:55.098267 2024-01-16T11:52:55.139621 2024-01-16T11:52:55.182769 2024-01-16T11:52:55.264433 2024-01-16T11:52:55.304775 2024-01-16T11:52:55.223805 2024-01-16T11:52:59.877858 2024-01-16T11:52:59.914724 2024-01-16T11:52:59.950610 2024-01-16T11:52:59.986818 2024-01-16T11:53:00.025638 2024-01-16T11:53:00.063687 2024-01-16T11:53:00.879850 2024-01-16T11:53:00.919651 2024-01-16T11:53:00.959273 2024-01-16T11:53:00.999381 2024-01-16T11:53:04.890126 2024-01-16T11:53:04.965116 2024-01-16T11:53:04.999916 2024-01-16T11:53:05.037780 2024-01-16T11:53:05.073999 2024-01-16T11:53:05.110241 2024-01-16T11:53:05.917045 2024-01-16T11:53:05.971672 2024-01-16T11:53:06.016025 2024-01-16T11:53:06.059220 2024-01-16T11:53:09.920092 2024-01-16T11:53:09.974757 2024-01-16T11:53:10.010406 2024-01-16T11:53:10.045790 2024-01-16T11:53:10.081749 2024-01-16T11:53:10.118100 2024-01-16T11:53:10.924485 2024-01-16T11:53:10.981094 2024-01-16T11:53:11.023935 2024-01-16T11:53:11.067210 ... ``` Commits ------- 5bb5474 [FrameworkBundle][HttpClient] Add `ThrottlingHttpClient` to limit requests within a timeframe
2 parents c8d24c5 + 5bb5474 commit f0f3040

File tree

13 files changed

+313
-6
lines changed

13 files changed

+313
-6
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CHANGELOG
1010
* Deprecate the `router.cache_dir` config option
1111
* Add `rate_limiter` tags to rate limiter services
1212
* Add `secrets:reveal` command
13+
* Add `rate_limiter` option to `http_client.default_options` and `http_client.scoped_clients`
1314

1415
7.0
1516
---

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,17 +1715,32 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
17151715
->fixXmlConfig('scoped_client')
17161716
->beforeNormalization()
17171717
->always(function ($config) {
1718-
if (empty($config['scoped_clients']) || !\is_array($config['default_options']['retry_failed'] ?? null)) {
1718+
if (empty($config['scoped_clients'])) {
1719+
return $config;
1720+
}
1721+
1722+
$hasDefaultRateLimiter = isset($config['default_options']['rate_limiter']);
1723+
$hasDefaultRetryFailed = \is_array($config['default_options']['retry_failed'] ?? null);
1724+
1725+
if (!$hasDefaultRateLimiter && !$hasDefaultRetryFailed) {
17191726
return $config;
17201727
}
17211728

17221729
foreach ($config['scoped_clients'] as &$scopedConfig) {
1723-
if (!isset($scopedConfig['retry_failed']) || true === $scopedConfig['retry_failed']) {
1724-
$scopedConfig['retry_failed'] = $config['default_options']['retry_failed'];
1725-
continue;
1730+
if ($hasDefaultRateLimiter) {
1731+
if (!isset($scopedConfig['rate_limiter']) || true === $scopedConfig['rate_limiter']) {
1732+
$scopedConfig['rate_limiter'] = $config['default_options']['rate_limiter'];
1733+
} elseif (false === $scopedConfig['rate_limiter']) {
1734+
$scopedConfig['rate_limiter'] = null;
1735+
}
17261736
}
1727-
if (\is_array($scopedConfig['retry_failed'])) {
1728-
$scopedConfig['retry_failed'] += $config['default_options']['retry_failed'];
1737+
1738+
if ($hasDefaultRetryFailed) {
1739+
if (!isset($scopedConfig['retry_failed']) || true === $scopedConfig['retry_failed']) {
1740+
$scopedConfig['retry_failed'] = $config['default_options']['retry_failed'];
1741+
} elseif (\is_array($scopedConfig['retry_failed'])) {
1742+
$scopedConfig['retry_failed'] += $config['default_options']['retry_failed'];
1743+
}
17291744
}
17301745
}
17311746

@@ -1830,6 +1845,10 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
18301845
->normalizeKeys(false)
18311846
->variablePrototype()->end()
18321847
->end()
1848+
->scalarNode('rate_limiter')
1849+
->defaultNull()
1850+
->info('Rate limiter name to use for throttling requests')
1851+
->end()
18331852
->append($this->createHttpClientRetrySection())
18341853
->end()
18351854
->end()
@@ -1978,6 +1997,10 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
19781997
->normalizeKeys(false)
19791998
->variablePrototype()->end()
19801999
->end()
2000+
->scalarNode('rate_limiter')
2001+
->defaultNull()
2002+
->info('Rate limiter name to use for throttling requests')
2003+
->end()
19812004
->append($this->createHttpClientRetrySection())
19822005
->end()
19832006
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
8686
use Symfony\Component\HttpClient\RetryableHttpClient;
8787
use Symfony\Component\HttpClient\ScopingHttpClient;
88+
use Symfony\Component\HttpClient\ThrottlingHttpClient;
8889
use Symfony\Component\HttpClient\UriTemplateHttpClient;
8990
use Symfony\Component\HttpFoundation\Request;
9091
use Symfony\Component\HttpKernel\Attribute\AsController;
@@ -345,6 +346,7 @@ public function load(array $configs, ContainerBuilder $container): void
345346
}
346347

347348
if ($this->readConfigEnabled('http_client', $container, $config['http_client'])) {
349+
$this->readConfigEnabled('rate_limiter', $container, $config['rate_limiter']); // makes sure that isInitializedConfigEnabled() will work
348350
$this->registerHttpClientConfiguration($config['http_client'], $container, $loader);
349351
}
350352

@@ -2409,6 +2411,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
24092411
$loader->load('http_client.php');
24102412

24112413
$options = $config['default_options'] ?? [];
2414+
$rateLimiter = $options['rate_limiter'] ?? null;
2415+
unset($options['rate_limiter']);
24122416
$retryOptions = $options['retry_failed'] ?? ['enabled' => false];
24132417
unset($options['retry_failed']);
24142418
$defaultUriTemplateVars = $options['vars'] ?? [];
@@ -2430,6 +2434,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
24302434
$container->removeAlias(HttpClient::class);
24312435
}
24322436

2437+
if (null !== $rateLimiter) {
2438+
$this->registerThrottlingHttpClient($rateLimiter, 'http_client', $container);
2439+
}
2440+
24332441
if ($this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) {
24342442
$this->registerRetryableHttpClient($retryOptions, 'http_client', $container);
24352443
}
@@ -2451,6 +2459,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
24512459

24522460
$scope = $scopeConfig['scope'] ?? null;
24532461
unset($scopeConfig['scope']);
2462+
$rateLimiter = $scopeConfig['rate_limiter'] ?? null;
2463+
unset($scopeConfig['rate_limiter']);
24542464
$retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false];
24552465
unset($scopeConfig['retry_failed']);
24562466

@@ -2470,6 +2480,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
24702480
;
24712481
}
24722482

2483+
if (null !== $rateLimiter) {
2484+
$this->registerThrottlingHttpClient($rateLimiter, $name, $container);
2485+
}
2486+
24732487
if ($this->readConfigEnabled('http_client.scoped_clients.'.$name.'.retry_failed', $container, $retryOptions)) {
24742488
$this->registerRetryableHttpClient($retryOptions, $name, $container);
24752489
}
@@ -2507,6 +2521,25 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
25072521
}
25082522
}
25092523

2524+
private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void
2525+
{
2526+
if (!class_exists(ThrottlingHttpClient::class)) {
2527+
throw new LogicException('Rate limiter support cannot be enabled as version 7.1+ of the HttpClient component is required.');
2528+
}
2529+
2530+
if (!$this->isInitializedConfigEnabled('rate_limiter')) {
2531+
throw new LogicException('Rate limiter cannot be used within HttpClient as the RateLimiter component is not enabled.');
2532+
}
2533+
2534+
$container->register($name.'.throttling.limiter', LimiterInterface::class)
2535+
->setFactory([new Reference('limiter.'.$rateLimiter), 'create']);
2536+
2537+
$container
2538+
->register($name.'.throttling', ThrottlingHttpClient::class)
2539+
->setDecoratedService($name, null, 15) // higher priority than RetryableHttpClient (10)
2540+
->setArguments([new Reference($name.'.throttling.inner'), new Reference($name.'.throttling.limiter')]);
2541+
}
2542+
25102543
private function registerRetryableHttpClient(array $options, string $name, ContainerBuilder $container): void
25112544
{
25122545
if (null !== $options['retry_strategy']) {

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,7 @@
661661
<xsd:attribute name="local-pk" type="xsd:string" />
662662
<xsd:attribute name="passphrase" type="xsd:string" />
663663
<xsd:attribute name="ciphers" type="xsd:string" />
664+
<xsd:attribute name="rate-limiter" type="xsd:string" />
664665
</xsd:complexType>
665666

666667
<xsd:complexType name="http_client_scope_options" mixed="true">
@@ -691,6 +692,7 @@
691692
<xsd:attribute name="local-pk" type="xsd:string" />
692693
<xsd:attribute name="passphrase" type="xsd:string" />
693694
<xsd:attribute name="ciphers" type="xsd:string" />
695+
<xsd:attribute name="rate-limiter" type="xsd:string" />
694696
</xsd:complexType>
695697

696698
<xsd:complexType name="fingerprint">

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,46 @@ public function testEnabledLockNeedsResources()
530530
]);
531531
}
532532

533+
public function testScopedHttpClientsInheritRateLimiterAndRetryFailedConfiguration()
534+
{
535+
$processor = new Processor();
536+
$configuration = new Configuration(true);
537+
538+
$config = $processor->processConfiguration($configuration, [[
539+
'http_client' => [
540+
'default_options' => ['rate_limiter' => 'default_limiter', 'retry_failed' => ['max_retries' => 77]],
541+
'scoped_clients' => [
542+
'foo' => ['base_uri' => 'http://example.com'],
543+
'bar' => ['base_uri' => 'http://example.com', 'rate_limiter' => true, 'retry_failed' => true],
544+
'baz' => ['base_uri' => 'http://example.com', 'rate_limiter' => false, 'retry_failed' => false],
545+
'qux' => ['base_uri' => 'http://example.com', 'rate_limiter' => 'foo_limiter', 'retry_failed' => ['max_retries' => 88, 'delay' => 999]],
546+
],
547+
],
548+
]]);
549+
550+
$scopedClients = $config['http_client']['scoped_clients'];
551+
552+
$this->assertSame('default_limiter', $scopedClients['foo']['rate_limiter']);
553+
$this->assertTrue($scopedClients['foo']['retry_failed']['enabled']);
554+
$this->assertSame(77, $scopedClients['foo']['retry_failed']['max_retries']);
555+
$this->assertSame(1000, $scopedClients['foo']['retry_failed']['delay']);
556+
557+
$this->assertSame('default_limiter', $scopedClients['bar']['rate_limiter']);
558+
$this->assertTrue($scopedClients['bar']['retry_failed']['enabled']);
559+
$this->assertSame(77, $scopedClients['bar']['retry_failed']['max_retries']);
560+
$this->assertSame(1000, $scopedClients['bar']['retry_failed']['delay']);
561+
562+
$this->assertNull($scopedClients['baz']['rate_limiter']);
563+
$this->assertFalse($scopedClients['baz']['retry_failed']['enabled']);
564+
$this->assertSame(3, $scopedClients['baz']['retry_failed']['max_retries']);
565+
$this->assertSame(1000, $scopedClients['baz']['retry_failed']['delay']);
566+
567+
$this->assertSame('foo_limiter', $scopedClients['qux']['rate_limiter']);
568+
$this->assertTrue($scopedClients['qux']['retry_failed']['enabled']);
569+
$this->assertSame(88, $scopedClients['qux']['retry_failed']['max_retries']);
570+
$this->assertSame(999, $scopedClients['qux']['retry_failed']['delay']);
571+
}
572+
533573
protected static function getBundleDefaultConfig()
534574
{
535575
return [
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
$container->loadFromExtension('framework', [
4+
'annotations' => false,
5+
'http_method_override' => false,
6+
'handle_all_throwables' => true,
7+
'php_errors' => ['log' => true],
8+
'rate_limiter' => [
9+
'foo_limiter' => [
10+
'lock_factory' => null,
11+
'policy' => 'token_bucket',
12+
'limit' => 10,
13+
'rate' => ['interval' => '5 seconds', 'amount' => 10],
14+
],
15+
],
16+
'http_client' => [
17+
'default_options' => [
18+
'rate_limiter' => 'default_limiter',
19+
],
20+
'scoped_clients' => [
21+
'foo' => [
22+
'base_uri' => 'http://example.com',
23+
'rate_limiter' => 'foo_limiter',
24+
],
25+
],
26+
],
27+
]);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<container xmlns="http://symfony.com/schema/dic/services"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xmlns:framework="http://symfony.com/schema/dic/symfony"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd
6+
http://symfony.com/schema/dic/symfony http://symfony.com/schema/dic/symfony/symfony-1.0.xsd">
7+
8+
<framework:config http-method-override="false" handle-all-throwables="true">
9+
<framework:annotations enabled="false" />
10+
<framework:php-errors log="true" />
11+
<framework:rate-limiter>
12+
<framework:limiter name="foo_limiter" lock-factory="null" policy="token_bucket" limit="10">
13+
<framework:rate interval="5 seconds" amount="10" />
14+
</framework:limiter>
15+
</framework:rate-limiter>
16+
<framework:http-client>
17+
<framework:default-options rate-limiter="default_limiter" />
18+
<framework:scoped-client name="foo" base-uri="http://example.com" rate-limiter="foo_limiter" />
19+
</framework:http-client>
20+
</framework:config>
21+
</container>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
framework:
2+
annotations: false
3+
http_method_override: false
4+
handle_all_throwables: true
5+
php_errors:
6+
log: true
7+
rate_limiter:
8+
foo_limiter:
9+
lock_factory: null
10+
policy: token_bucket
11+
limit: 10
12+
rate: { interval: '5 seconds', amount: 10 }
13+
http_client:
14+
default_options:
15+
rate_limiter: default_limiter
16+
scoped_clients:
17+
foo:
18+
base_uri: http://example.com
19+
rate_limiter: foo_limiter

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use Symfony\Component\DependencyInjection\ContainerBuilder;
3939
use Symfony\Component\DependencyInjection\ContainerInterface;
4040
use Symfony\Component\DependencyInjection\Definition;
41+
use Symfony\Component\DependencyInjection\Exception\LogicException;
4142
use Symfony\Component\DependencyInjection\Loader\ClosureLoader;
4243
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
4344
use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag;
@@ -51,6 +52,7 @@
5152
use Symfony\Component\HttpClient\MockHttpClient;
5253
use Symfony\Component\HttpClient\RetryableHttpClient;
5354
use Symfony\Component\HttpClient\ScopingHttpClient;
55+
use Symfony\Component\HttpClient\ThrottlingHttpClient;
5456
use Symfony\Component\HttpFoundation\IpUtils;
5557
use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass;
5658
use Symfony\Component\HttpKernel\Fragment\FragmentUriGeneratorInterface;
@@ -1986,6 +1988,35 @@ public function testHttpClientFullDefaultOptions()
19861988
$this->assertSame(['foo' => ['bar' => 'baz']], $defaultOptions['extra']);
19871989
}
19881990

1991+
public function testHttpClientRateLimiter()
1992+
{
1993+
if (!class_exists(ThrottlingHttpClient::class)) {
1994+
$this->expectException(LogicException::class);
1995+
}
1996+
1997+
$container = $this->createContainerFromFile('http_client_rate_limiter');
1998+
1999+
$this->assertTrue($container->hasDefinition('http_client.throttling'));
2000+
$definition = $container->getDefinition('http_client.throttling');
2001+
$this->assertSame(ThrottlingHttpClient::class, $definition->getClass());
2002+
$this->assertSame('http_client', $definition->getDecoratedService()[0]);
2003+
$this->assertCount(2, $arguments = $definition->getArguments());
2004+
$this->assertInstanceOf(Reference::class, $arguments[0]);
2005+
$this->assertSame('http_client.throttling.inner', (string) $arguments[0]);
2006+
$this->assertInstanceOf(Reference::class, $arguments[1]);
2007+
$this->assertSame('http_client.throttling.limiter', (string) $arguments[1]);
2008+
2009+
$this->assertTrue($container->hasDefinition('foo.throttling'));
2010+
$definition = $container->getDefinition('foo.throttling');
2011+
$this->assertSame(ThrottlingHttpClient::class, $definition->getClass());
2012+
$this->assertSame('foo', $definition->getDecoratedService()[0]);
2013+
$this->assertCount(2, $arguments = $definition->getArguments());
2014+
$this->assertInstanceOf(Reference::class, $arguments[0]);
2015+
$this->assertSame('foo.throttling.inner', (string) $arguments[0]);
2016+
$this->assertInstanceOf(Reference::class, $arguments[1]);
2017+
$this->assertSame('foo.throttling.limiter', (string) $arguments[1]);
2018+
}
2019+
19892020
public static function provideMailer(): array
19902021
{
19912022
return [

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add `HttpOptions::setHeader()` to add or replace a single header
88
* Allow mocking `start_time` info in `MockResponse`
99
* Add `MockResponse::fromFile()` and `JsonMockResponse::fromFile()` methods to help using fixtures files
10+
* Add `ThrottlingHttpClient` to enable limiting the number request within a certain period
1011

1112
7.0
1213
---

0 commit comments

Comments
 (0)