Skip to content

Retry throttled calls on a provided HTTP client - #2139

Open
nicolas-grekas wants to merge 1 commit into
async-aws:masterfrom
nicolas-grekas:retry-provided-http-client
Open

nicolas-grekas wants to merge 1 commit into
async-aws:masterfrom
nicolas-grekas:retry-provided-http-client

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Contributor

AbstractApi wraps its HTTP client in a RetryableHttpClient with an AwsRetryStrategy, but only when it builds that client itself:

if (!isset($httpClient)) {
    $httpClient = HttpClient::create();
    if (class_exists(RetryableHttpClient::class)) {
        $httpClient = new RetryableHttpClient($httpClient, new AwsRetryStrategy(...), 3, $this->logger);
    }
}

So a caller that passes a client to set a timeout, a proxy, certificates, or to get the requests into a profiler, silently loses the AWS retry policy. That is not obvious from the outside: the constructor argument reads like "use this transport", not "and give up retrying".

It matters because the two policies are not interchangeable. Symfony's GenericRetryStrategy maps 0, 500, 504, 507 and 510 to IDEMPOTENT_METHODS, which does not include POST, and every AWS API call is a POST. It also never looks at a 400 body, where AWS reports throttling. Measured on the same throttled request, all POST:

response AwsRetryStrategy GenericRetryStrategy
500 Internal Server Error 4 attempts 1
504 Gateway Timeout 4 1
400 ThrottlingException 4 1
400 ProvisionedThroughputExceededException 4 1
400 ValidationException 1 1

This moves the wrapper out of the isset() branch so it applies to a given client too, keeping the error factory and the logger of the client it belongs to.

Symfony's SES, SQS, SNS, DynamoDb and KMS bridges all pass their application's client, so they are all in this case today. The alternative is for each of them to rebuild the wrapper, which duplicates the policy in five places and cannot reach the per-client error factory.

Worth noting it is a behaviour change: a caller who passes a client now gets up to three extra attempts on throttling and 5xx. A caller whose client already retries gets both layers.

Comment thread src/Core/src/AbstractApi.php
Comment thread src/Core/src/AbstractApi.php
@nicolas-grekas
nicolas-grekas force-pushed the retry-provided-http-client branch from 23d666d to 2239bea Compare September 20, 2026 20:44
@nicolas-grekas

nicolas-grekas commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Inlined, thanks. CI was mine too: ### Changed landed on line 5, which BranchAliasTest reads as a patch-level change and so expected 1.30-dev. Moving it after ### Added fixes that and the ordering assertion together.

On muting the given client, I tried it in the Symfony PR and pulled it back out. It does work, including through a decorator, so a retryable client under a TraceableHttpClient is still reached:

Traceable(Retryable(Scoping(mock)))   muted    attempts=4
Retryable(Scoping(mock))              muted    attempts=4
Traceable(Native)                     caught InvalidArgumentException

and the multiplication it avoids is real, 16 attempts for one throttled request rather than 4.

What stopped me is that withOptions() returns a clone, so we would talk through a copy of the client the caller gave us rather than the instance itself:

plain mock muted      : original getRequestsCount()=0
via retryable, muted  : original mock getRequestsCount()=0
wrap only, no mute    : original mock getRequestsCount()=1

The second line is the awkward one: the clone happens even when a retryable client consumes the option, because RetryableHttpClient::withOptions() forwards to $this->client->withOptions([]). Traces still reach a profiler, since TraceableHttpClient shares its ArrayObject across clones, but anyone counting requests on the client they passed stops seeing them. That broke a test in Symfony that does exactly that.

So it is a real trade rather than a clear win, and it is your call which side of it async-aws wants. Happy to add it if you prefer the lower attempt count; the catch is safe to write now that HttpClientInterface::withOptions() is documented as throwing on an unsupported option (symfony/symfony#66183).

@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Applied, you were right and my objection was not.

I had argued the mute detaches the caller from the client it passed, because withOptions() returns a clone. That is true only for state held in a scalar. Every real client shares the object its state lives in:

CurlHttpClient   $multi          : SHARED (CurlClientState)      <- same connection pool
NativeHttpClient $multi          : SHARED (NativeClientState)    <- same DNS cache
Traceable        $tracedRequests : SHARED (ArrayObject)          <- profiler intact
MockHttpClient   $requestsCount  : int, copied on clone

I had generalised from the last line, which is a test double.

One existing test did need a fix: testDiscoveredEndpoint mocks HttpClientInterface without stubbing withOptions(), so the bare mock returned a different instance and its expectation never fired. willReturnSelf() is the right way to mock a method returning static, so the test was under-specified rather than the change being wrong. Nothing else in the repo is affected: getRequestsCount() has no uses here, and the 370 files building a MockHttpClient do not assert on the instance they pass.

testProvidedHttpClientThatAlreadyRetriesDoesNotRetryTwice pins it, through a TraceableHttpClient so the option has to travel the chain rather than hit a RetryableHttpClient directly. Without the mute it fails with Failed asserting that 16 is identical to 4.

@nicolas-grekas
nicolas-grekas force-pushed the retry-provided-http-client branch 2 times, most recently from ca5089b to 7d181b2 Compare September 20, 2026 21:09
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

The lowest job caught a real one: HttpClientInterface::withOptions() only exists from symfony/http-client-contracts 2.4, and src/Core/composer.json allows ^1.1.8 || ^2.0 || ^3.0. Calling it unconditionally would have been a fatal for anyone on an older contract, so it is now behind method_exists($httpClient, 'withOptions'), checked on the instance rather than the interface so an implementation that has it is still muted.

The two test failures had the same root: stubbing withOptions() on a mocked interface that does not declare it raises MethodCannotBeConfiguredException, and the no-double-retry assertion cannot hold when the mute cannot run. The stub is now conditional and that test skips below 2.4.

nicolas-grekas added a commit to symfony/symfony that referenced this pull request Sep 21, 2026
…'s HTTP client (nicolas-grekas)

This PR was merged into the 8.2 branch.

Discussion
----------

[KeyManagement] Let the AWS factory take the application's HTTP client

| Q             | A
| ------------- | ---
| Branch?       | 8.2
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

Follows #66177, which did this for the Azure, Google Cloud and Vault factories and left the AWS one out. async-aws talks through the client it is given as its third constructor argument, the way the SES, SQS, SNS and DynamoDb bridges already pass it:

```php
return new AwsKms(new KmsClient(Configuration::create($options), null, $this->client));
```

So a timeout, certificates and the profiler configured on the application's client reach KMS too.

async-aws retries throttled calls on the client it builds itself and takes a given one as is, so passing a client means its own retry policy applies instead of `AwsRetryStrategy`. That is the same trade the four bridges above already make, and it is better fixed in async-aws than worked around in each of them: async-aws/aws#2139 moves the retry wrapper so it applies to a given client too, with the error factory and the logger of the client it belongs to.

Commits
-------

1f9d089 [KeyManagement] Let the AWS factory take the application's HTTP client
Comment thread src/Core/src/AbstractApi.php Outdated
$this->logger
);
} elseif (method_exists($httpClient, 'withOptions')) {
// withOptions() landed in symfony/http-client-contracts 2.4.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which version of symfony/http-client corresponds to contracts 2.4 ? Maybe we can bump the min version of the dependency if this is only for Symfony 4.4 which is long EOL

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answering my question: it was added in symfony/http-client 5.3

Comment thread src/Core/src/AbstractApi.php Outdated

// Throttled calls are worth retrying whoever built the client: a caller that provides one
// to configure a timeout, a proxy or the profiler should not lose the AWS retry policy.
if (class_exists(RetryableHttpClient::class)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, this was added in symfony/http-client 5.2, so this condition could be removed if we drop support for 4.4

The retry wrapper was only applied to the client built internally, so a
caller passing one to set a timeout, a proxy, certificates or to get the
requests into a profiler silently lost the AWS retry policy along the way.

Wrapping a given client too keeps that policy, with the error factory and
the logger of the client it belongs to. A client that already retries is
muted first, so its attempts do not multiply with the ones made here.

Bumping symfony/http-client to 5.3 drops the class_exists() guard around
RetryableHttpClient, which has been there since 5.2.
@nicolas-grekas
nicolas-grekas force-pushed the retry-provided-http-client branch from 7d181b2 to 6ee753d Compare September 21, 2026 10:09
@nicolas-grekas

Copy link
Copy Markdown
Contributor Author

Bumped symfony/http-client to ^5.3 and symfony/http-client-contracts to ^2.4 in src/Core/composer.json, which lets the class_exists(RetryableHttpClient::class) guard go. Confirmed against the tags rather than from memory:

added in
RetryableHttpClient http-client 5.2 (absent at 5.1.0, present at 5.2.0)
HttpClientTrait::withOptions() http-client 5.3
HttpClientInterface::withOptions() as a real method contracts 3.0

That last row is why I kept the method_exists() check. Contracts 2.4 only added it as a @method annotation on the interface:

 * @method static withOptions(array $options) Returns a new instance of the client with new default options

so an implementation compiled against 2.x is not obliged to have it. Symfony's own clients all do from 5.3, and the check costs nothing, so it seemed better than requiring contracts ^3.0 and dropping Symfony 5 altogether. Happy to go that way instead if you would rather have the branch gone.

http-client 5.3 already requires contracts ^2.4, so the two constraints move together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants