Skip to content

Commit

Permalink
feature #53550 [FrameworkBundle][HttpClient] Add `ThrottlingHttpClien…
Browse files Browse the repository at this point in the history
…t` 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
  • Loading branch information
fabpot committed Feb 3, 2024
2 parents c8d24c5 + 5bb5474 commit f0f3040
Show file tree
Hide file tree
Showing 13 changed files with 313 additions and 6 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Expand Up @@ -10,6 +10,7 @@ CHANGELOG
* Deprecate the `router.cache_dir` config option
* Add `rate_limiter` tags to rate limiter services
* Add `secrets:reveal` command
* Add `rate_limiter` option to `http_client.default_options` and `http_client.scoped_clients`

7.0
---
Expand Down
Expand Up @@ -1715,17 +1715,32 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
->fixXmlConfig('scoped_client')
->beforeNormalization()
->always(function ($config) {
if (empty($config['scoped_clients']) || !\is_array($config['default_options']['retry_failed'] ?? null)) {
if (empty($config['scoped_clients'])) {
return $config;
}

$hasDefaultRateLimiter = isset($config['default_options']['rate_limiter']);
$hasDefaultRetryFailed = \is_array($config['default_options']['retry_failed'] ?? null);

if (!$hasDefaultRateLimiter && !$hasDefaultRetryFailed) {
return $config;
}

foreach ($config['scoped_clients'] as &$scopedConfig) {
if (!isset($scopedConfig['retry_failed']) || true === $scopedConfig['retry_failed']) {
$scopedConfig['retry_failed'] = $config['default_options']['retry_failed'];
continue;
if ($hasDefaultRateLimiter) {
if (!isset($scopedConfig['rate_limiter']) || true === $scopedConfig['rate_limiter']) {
$scopedConfig['rate_limiter'] = $config['default_options']['rate_limiter'];
} elseif (false === $scopedConfig['rate_limiter']) {
$scopedConfig['rate_limiter'] = null;
}
}
if (\is_array($scopedConfig['retry_failed'])) {
$scopedConfig['retry_failed'] += $config['default_options']['retry_failed'];

if ($hasDefaultRetryFailed) {
if (!isset($scopedConfig['retry_failed']) || true === $scopedConfig['retry_failed']) {
$scopedConfig['retry_failed'] = $config['default_options']['retry_failed'];
} elseif (\is_array($scopedConfig['retry_failed'])) {
$scopedConfig['retry_failed'] += $config['default_options']['retry_failed'];
}
}
}

Expand Down Expand Up @@ -1830,6 +1845,10 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
->normalizeKeys(false)
->variablePrototype()->end()
->end()
->scalarNode('rate_limiter')
->defaultNull()
->info('Rate limiter name to use for throttling requests')
->end()
->append($this->createHttpClientRetrySection())
->end()
->end()
Expand Down Expand Up @@ -1978,6 +1997,10 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
->normalizeKeys(false)
->variablePrototype()->end()
->end()
->scalarNode('rate_limiter')
->defaultNull()
->info('Rate limiter name to use for throttling requests')
->end()
->append($this->createHttpClientRetrySection())
->end()
->end()
Expand Down
Expand Up @@ -85,6 +85,7 @@
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpClient\ThrottlingHttpClient;
use Symfony\Component\HttpClient\UriTemplateHttpClient;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;
Expand Down Expand Up @@ -345,6 +346,7 @@ public function load(array $configs, ContainerBuilder $container): void
}

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

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

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

if (null !== $rateLimiter) {
$this->registerThrottlingHttpClient($rateLimiter, 'http_client', $container);
}

if ($this->readConfigEnabled('http_client.retry_failed', $container, $retryOptions)) {
$this->registerRetryableHttpClient($retryOptions, 'http_client', $container);
}
Expand All @@ -2451,6 +2459,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder

$scope = $scopeConfig['scope'] ?? null;
unset($scopeConfig['scope']);
$rateLimiter = $scopeConfig['rate_limiter'] ?? null;
unset($scopeConfig['rate_limiter']);
$retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false];
unset($scopeConfig['retry_failed']);

Expand All @@ -2470,6 +2480,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
;
}

if (null !== $rateLimiter) {
$this->registerThrottlingHttpClient($rateLimiter, $name, $container);
}

if ($this->readConfigEnabled('http_client.scoped_clients.'.$name.'.retry_failed', $container, $retryOptions)) {
$this->registerRetryableHttpClient($retryOptions, $name, $container);
}
Expand Down Expand Up @@ -2507,6 +2521,25 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
}
}

private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void
{
if (!class_exists(ThrottlingHttpClient::class)) {
throw new LogicException('Rate limiter support cannot be enabled as version 7.1+ of the HttpClient component is required.');
}

if (!$this->isInitializedConfigEnabled('rate_limiter')) {
throw new LogicException('Rate limiter cannot be used within HttpClient as the RateLimiter component is not enabled.');
}

$container->register($name.'.throttling.limiter', LimiterInterface::class)
->setFactory([new Reference('limiter.'.$rateLimiter), 'create']);

$container
->register($name.'.throttling', ThrottlingHttpClient::class)
->setDecoratedService($name, null, 15) // higher priority than RetryableHttpClient (10)
->setArguments([new Reference($name.'.throttling.inner'), new Reference($name.'.throttling.limiter')]);
}

private function registerRetryableHttpClient(array $options, string $name, ContainerBuilder $container): void
{
if (null !== $options['retry_strategy']) {
Expand Down
Expand Up @@ -661,6 +661,7 @@
<xsd:attribute name="local-pk" type="xsd:string" />
<xsd:attribute name="passphrase" type="xsd:string" />
<xsd:attribute name="ciphers" type="xsd:string" />
<xsd:attribute name="rate-limiter" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="http_client_scope_options" mixed="true">
Expand Down Expand Up @@ -691,6 +692,7 @@
<xsd:attribute name="local-pk" type="xsd:string" />
<xsd:attribute name="passphrase" type="xsd:string" />
<xsd:attribute name="ciphers" type="xsd:string" />
<xsd:attribute name="rate-limiter" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="fingerprint">
Expand Down
Expand Up @@ -530,6 +530,46 @@ public function testEnabledLockNeedsResources()
]);
}

public function testScopedHttpClientsInheritRateLimiterAndRetryFailedConfiguration()
{
$processor = new Processor();
$configuration = new Configuration(true);

$config = $processor->processConfiguration($configuration, [[
'http_client' => [
'default_options' => ['rate_limiter' => 'default_limiter', 'retry_failed' => ['max_retries' => 77]],
'scoped_clients' => [
'foo' => ['base_uri' => 'http://example.com'],
'bar' => ['base_uri' => 'http://example.com', 'rate_limiter' => true, 'retry_failed' => true],
'baz' => ['base_uri' => 'http://example.com', 'rate_limiter' => false, 'retry_failed' => false],
'qux' => ['base_uri' => 'http://example.com', 'rate_limiter' => 'foo_limiter', 'retry_failed' => ['max_retries' => 88, 'delay' => 999]],
],
],
]]);

$scopedClients = $config['http_client']['scoped_clients'];

$this->assertSame('default_limiter', $scopedClients['foo']['rate_limiter']);
$this->assertTrue($scopedClients['foo']['retry_failed']['enabled']);
$this->assertSame(77, $scopedClients['foo']['retry_failed']['max_retries']);
$this->assertSame(1000, $scopedClients['foo']['retry_failed']['delay']);

$this->assertSame('default_limiter', $scopedClients['bar']['rate_limiter']);
$this->assertTrue($scopedClients['bar']['retry_failed']['enabled']);
$this->assertSame(77, $scopedClients['bar']['retry_failed']['max_retries']);
$this->assertSame(1000, $scopedClients['bar']['retry_failed']['delay']);

$this->assertNull($scopedClients['baz']['rate_limiter']);
$this->assertFalse($scopedClients['baz']['retry_failed']['enabled']);
$this->assertSame(3, $scopedClients['baz']['retry_failed']['max_retries']);
$this->assertSame(1000, $scopedClients['baz']['retry_failed']['delay']);

$this->assertSame('foo_limiter', $scopedClients['qux']['rate_limiter']);
$this->assertTrue($scopedClients['qux']['retry_failed']['enabled']);
$this->assertSame(88, $scopedClients['qux']['retry_failed']['max_retries']);
$this->assertSame(999, $scopedClients['qux']['retry_failed']['delay']);
}

protected static function getBundleDefaultConfig()
{
return [
Expand Down
@@ -0,0 +1,27 @@
<?php

$container->loadFromExtension('framework', [
'annotations' => false,
'http_method_override' => false,
'handle_all_throwables' => true,
'php_errors' => ['log' => true],
'rate_limiter' => [
'foo_limiter' => [
'lock_factory' => null,
'policy' => 'token_bucket',
'limit' => 10,
'rate' => ['interval' => '5 seconds', 'amount' => 10],
],
],
'http_client' => [
'default_options' => [
'rate_limiter' => 'default_limiter',
],
'scoped_clients' => [
'foo' => [
'base_uri' => 'http://example.com',
'rate_limiter' => 'foo_limiter',
],
],
],
]);
@@ -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:rate-limiter>
<framework:limiter name="foo_limiter" lock-factory="null" policy="token_bucket" limit="10">
<framework:rate interval="5 seconds" amount="10" />
</framework:limiter>
</framework:rate-limiter>
<framework:http-client>
<framework:default-options rate-limiter="default_limiter" />
<framework:scoped-client name="foo" base-uri="http://example.com" rate-limiter="foo_limiter" />
</framework:http-client>
</framework:config>
</container>
@@ -0,0 +1,19 @@
framework:
annotations: false
http_method_override: false
handle_all_throwables: true
php_errors:
log: true
rate_limiter:
foo_limiter:
lock_factory: null
policy: token_bucket
limit: 10
rate: { interval: '5 seconds', amount: 10 }
http_client:
default_options:
rate_limiter: default_limiter
scoped_clients:
foo:
base_uri: http://example.com
rate_limiter: foo_limiter
Expand Up @@ -38,6 +38,7 @@
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Loader\ClosureLoader;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag;
Expand All @@ -51,6 +52,7 @@
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpClient\ThrottlingHttpClient;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass;
use Symfony\Component\HttpKernel\Fragment\FragmentUriGeneratorInterface;
Expand Down Expand Up @@ -1986,6 +1988,35 @@ public function testHttpClientFullDefaultOptions()
$this->assertSame(['foo' => ['bar' => 'baz']], $defaultOptions['extra']);
}

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

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

$this->assertTrue($container->hasDefinition('http_client.throttling'));
$definition = $container->getDefinition('http_client.throttling');
$this->assertSame(ThrottlingHttpClient::class, $definition->getClass());
$this->assertSame('http_client', $definition->getDecoratedService()[0]);
$this->assertCount(2, $arguments = $definition->getArguments());
$this->assertInstanceOf(Reference::class, $arguments[0]);
$this->assertSame('http_client.throttling.inner', (string) $arguments[0]);
$this->assertInstanceOf(Reference::class, $arguments[1]);
$this->assertSame('http_client.throttling.limiter', (string) $arguments[1]);

$this->assertTrue($container->hasDefinition('foo.throttling'));
$definition = $container->getDefinition('foo.throttling');
$this->assertSame(ThrottlingHttpClient::class, $definition->getClass());
$this->assertSame('foo', $definition->getDecoratedService()[0]);
$this->assertCount(2, $arguments = $definition->getArguments());
$this->assertInstanceOf(Reference::class, $arguments[0]);
$this->assertSame('foo.throttling.inner', (string) $arguments[0]);
$this->assertInstanceOf(Reference::class, $arguments[1]);
$this->assertSame('foo.throttling.limiter', (string) $arguments[1]);
}

public static function provideMailer(): array
{
return [
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpClient/CHANGELOG.md
Expand Up @@ -7,6 +7,7 @@ CHANGELOG
* Add `HttpOptions::setHeader()` to add or replace a single header
* Allow mocking `start_time` info in `MockResponse`
* Add `MockResponse::fromFile()` and `JsonMockResponse::fromFile()` methods to help using fixtures files
* Add `ThrottlingHttpClient` to enable limiting the number request within a certain period

7.0
---
Expand Down

0 comments on commit f0f3040

Please sign in to comment.