Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 7 additions & 0 deletions .changes/nextrelease/bugfix-retries.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"type": "bugfix",
"category": "",
"description": "`Aws\\Retry` - Under the `AWS_NEW_RETRIES_2026` opt-in, emit the SEP-required retry headers `amz-sdk-invocation-id` and `amz-sdk-request` (formatted as `attempt=<n>; max=<m>`) instead of the legacy `aws-sdk-invocation-id` / `aws-sdk-retry`. Both legacy and new header names are excluded from SigV4 signing so intermediaries can add or modify them without invalidating signatures."
}
]
10 changes: 7 additions & 3 deletions src/Middleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,17 @@ public static function retry(
*/
public static function invocationId()
{
return function (callable $handler) {
$headerName = \Aws\Retry\V3\OptIn::isEnabled()
? 'amz-sdk-invocation-id'
: 'aws-sdk-invocation-id';

return function (callable $handler) use ($headerName) {
return function (
CommandInterface $command,
RequestInterface $request
) use ($handler){
) use ($handler, $headerName){
return $handler($command, $request->withHeader(
'aws-sdk-invocation-id',
$headerName,
md5(uniqid(gethostname(), true))
));
};
Expand Down
44 changes: 31 additions & 13 deletions src/Retry/ConfigurationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,22 @@ public static function defaultProvider(array $config = [])
public static function env()
{
return function () {
// Use config from environment variables, if available
$mode = getenv(self::ENV_MODE);
$maxAttempts = getenv(self::ENV_MAX_ATTEMPTS)
? getenv(self::ENV_MAX_ATTEMPTS)
: self::DEFAULT_MAX_ATTEMPTS;
if (!empty($mode)) {
$modeEnv = getenv(self::ENV_MODE);
$maxAttemptsEnv = getenv(self::ENV_MAX_ATTEMPTS);

if (!empty($modeEnv) || !empty($maxAttemptsEnv)) {
$mode = !empty($modeEnv)
? $modeEnv
: self::getDefaultMode();
$maxAttempts = !empty($maxAttemptsEnv)
? $maxAttemptsEnv
: self::DEFAULT_MAX_ATTEMPTS;

if (empty($modeEnv) && !OptIn::isEnabled()) {
return self::reject('Could not find environment variable'
. ' config in ' . self::ENV_MODE);
}

return Promise\Create::promiseFor(
new Configuration($mode, $maxAttempts)
);
Expand All @@ -136,10 +146,6 @@ public static function fallback()
};
}

/**
* Returns the default retry mode. Reflects the AWS_NEW_RETRIES_2026
* opt-in: 'standard' when the env flag is set, 'legacy' otherwise.
*/
public static function getDefaultMode(): string
{
return OptIn::isEnabled() ? 'standard' : self::DEFAULT_MODE;
Expand Down Expand Up @@ -175,18 +181,30 @@ public static function ini(
if (!isset($data[$profile])) {
return self::reject("'$profile' not found in config file");
}
if (!isset($data[$profile][self::INI_MODE])) {

$hasMode = isset($data[$profile][self::INI_MODE]);
$hasMaxAttempts = isset($data[$profile][self::INI_MAX_ATTEMPTS]);

if (!$hasMode && !$hasMaxAttempts) {
return self::reject("Required retry config values
not present in INI profile '{$profile}' ({$filename})");
}

if (!$hasMode && !OptIn::isEnabled()) {
return self::reject("Required retry config values
not present in INI profile '{$profile}' ({$filename})");
}

$maxAttempts = isset($data[$profile][self::INI_MAX_ATTEMPTS])
$mode = $hasMode
? $data[$profile][self::INI_MODE]
: self::getDefaultMode();
$maxAttempts = $hasMaxAttempts
? $data[$profile][self::INI_MAX_ATTEMPTS]
: self::DEFAULT_MAX_ATTEMPTS;

return Promise\Create::promiseFor(
new Configuration(
$data[$profile][self::INI_MODE],
$mode,
$maxAttempts
)
);
Expand Down
23 changes: 23 additions & 0 deletions src/Retry/RateLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ public function getSendToken()
$this->acquireToken(1);
}

/**
* @return int Non-blocking delay hint in milliseconds; 0 if a token is
* available immediately. A token is debited on each call.
*/
public function getSendDelayMs(): int
{
if (!$this->enabled) {
return 0;
}

$this->refillTokenBucket();
$delayMs = 0;
if (1 > $this->currentCapacity) {
$needed = 1 - $this->currentCapacity;
$fillRate = max($this->fillRate, $this->minFillRate);
$delayMs = (int) ceil(1000 * $needed / $fillRate);
}

$this->currentCapacity = max(0, $this->currentCapacity - 1);

return $delayMs;
}

public function updateSendingRate($isThrottled)
{
$this->updateMeasuredRate();
Expand Down
8 changes: 8 additions & 0 deletions src/Retry/RetryHelperTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ private function addRetryHeader($request, $retries, $delayBy)
return $request->withHeader('aws-sdk-retry', "{$retries}/{$delayBy}");
}

private function addRetryHeaderV3($request, int $attempt, int $maxAttempts)
{
return $request->withHeader(
'amz-sdk-request',
"attempt={$attempt}; max={$maxAttempts}"
);
}


private function updateStats($retries, $delay, array &$stats)
{
Expand Down
55 changes: 38 additions & 17 deletions src/Retry/V3/RetryMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class RetryMiddleware
private static array $standardTransientErrors = [
'RequestTimeout' => true,
'RequestTimeoutException' => true,
'InternalError' => true,
];

private static array $standardTransientStatusCodes = [
Expand Down Expand Up @@ -90,10 +91,7 @@ public static function wrap(ConfigurationInterface $config, array $options): \Cl
*/
public static function createDefaultDecider(array $options = []): \Closure
{
$retryCurlErrors = [];
if (extension_loaded('curl')) {
$retryCurlErrors[CURLE_RECV_ERROR] = true;
}
$retryCurlErrors = self::defaultRetryCurlErrors();

return function (
int $attempts,
Expand Down Expand Up @@ -128,10 +126,7 @@ public function __construct(
? ($options['delayer'])(...)
: null;

$this->retryCurlErrors = [];
if (extension_loaded('curl')) {
$this->retryCurlErrors[CURLE_RECV_ERROR] = true;
}
$this->retryCurlErrors = self::defaultRetryCurlErrors();
if (!empty($options['curl_errors']) && is_array($options['curl_errors'])) {
foreach ($options['curl_errors'] as $code) {
$this->retryCurlErrors[$code] = true;
Expand All @@ -143,6 +138,25 @@ public function __construct(
}
}

private static function defaultRetryCurlErrors(): array
{
$errors = [];
if (!extension_loaded('curl')) {
return $errors;
}
foreach ([
'CURLE_RECV_ERROR',
'CURLE_PARTIAL_FILE',
'CURLE_GOT_NOTHING',
'CURLE_HTTP2_STREAM',
] as $name) {
if (defined($name)) {
$errors[constant($name)] = true;
}
}
return $errors;
}

public function __invoke(CommandInterface $cmd, RequestInterface $req): PromiseInterface
{
$handler = $this->nextHandler;
Expand All @@ -152,7 +166,10 @@ public function __invoke(CommandInterface $cmd, RequestInterface $req): PromiseI
$requestStats = [];
$capacityUsed = null;

$req = $this->addRetryHeader($req, 0, 0);
$maxAttemptsForHeader = ($cmd['@retries'] !== null)
? $cmd['@retries'] + 1
: $this->maxAttempts;
$req = $this->addRetryHeaderV3($req, 1, $maxAttemptsForHeader);

$callback = function ($value) use (
$handler,
Expand Down Expand Up @@ -247,10 +264,8 @@ public function __invoke(CommandInterface $cmd, RequestInterface $req): PromiseI

$acquired = $this->quotaManager->acquireRetryQuota($isThrottling);
if ($acquired === false) {
// Long-polling: sleep and surface the error rather than retry.
if (LongPolling::isLongPolling($this->service, $cmd->getName())) {
$cmd['@http']['delay'] = $delayByMs;
usleep((int) ($delayByMs * 1000));
}

if ($isError) {
Expand All @@ -271,21 +286,27 @@ public function __invoke(CommandInterface $cmd, RequestInterface $req): PromiseI
$attempts++;
$cmd['@http']['delay'] = $delayByMs;

if ($this->mode === 'adaptive') {
$rlDelayMs = $this->rateLimiter->getSendDelayMs();
if ($rlDelayMs > 0) {
$cmd['@http']['delay'] = ($cmd['@http']['delay'] ?? 0) + $rlDelayMs;
}
}

if ($this->collectStats) {
$this->updateStats($attempts - 1, $delayByMs, $requestStats);
}

$req = $this->addRetryHeader($req, $attempts - 1, $delayByMs);

if ($this->mode === 'adaptive') {
$this->rateLimiter->getSendToken();
}
$req = $this->addRetryHeaderV3($req, $attempts, $maxAttempts);

return $handler($cmd, $req)->then($callback, $callback);
};

if ($this->mode === 'adaptive') {
$this->rateLimiter->getSendToken();
$rlDelayMs = $this->rateLimiter->getSendDelayMs();
if ($rlDelayMs > 0) {
$cmd['@http']['delay'] = ($cmd['@http']['delay'] ?? 0) + $rlDelayMs;
}
}

return $handler($cmd, $req)->then($callback, $callback);
Expand Down
6 changes: 5 additions & 1 deletion src/S3/AmbiguousSuccessParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ public function __invoke(
throw new $this->exceptionClass(
$parsed['message'],
$command,
['connection_error' => true]
[
'connection_error' => true,
'code' => $parsed['code'],
'message' => $parsed['message'],
]
);
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/Signature/SignatureV4.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ protected function getHeaderBlacklist()
'x-amzn-trace-id' => true,
'aws-sdk-invocation-id' => true,
'aws-sdk-retry' => true,
'amz-sdk-invocation-id' => true,
'amz-sdk-request' => true,
];
}

Expand Down Expand Up @@ -496,6 +498,8 @@ private function removeIllegalV4aHeaders(&$request)
self::AMZ_CONTENT_SHA256_HEADER,
'aws-sdk-invocation-id',
'aws-sdk-retry',
'amz-sdk-invocation-id',
'amz-sdk-request',
'x-amz-region-set',
'transfer-encoding',
];
Expand Down
70 changes: 70 additions & 0 deletions tests/Retry/RateLimiterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,74 @@ public function testUpdatesClientSendingRatesCorrectly()
);
}
}

public function testGetSendDelayMsReturnsZeroWhenDisabled()
{
$rateLimiter = new RateLimiter();
$this->assertSame(0, $rateLimiter->getSendDelayMs());
$this->assertSame(0, $rateLimiter->getSendDelayMs());
}

public function testGetSendDelayMsReturnsZeroWhenTokensAvailable()
{
$now = 0.0;
$rateLimiter = new RateLimiter([
'time_provider' => function () use (&$now) {
return $now;
},
]);

// Enable the bucket via the public API; with an initial measuredTxRate
// of 0, this settles at fillRate = minFillRate (0.5) and
// maxCapacity = minCapacity (1), with currentCapacity drained to 0.
$rateLimiter->updateSendingRate(true);

// Advance the clock enough to refill one token (>= 1 / 0.5 = 2 s).
$now = 3.0;

$this->assertSame(0, $rateLimiter->getSendDelayMs());
// A second call at the same virtual time drains the bucket back to
// empty, so it must now return a non-zero delay — proving the debit
// from the first call landed.
$this->assertGreaterThan(0, $rateLimiter->getSendDelayMs());
}

public function testGetSendDelayMsReflectsFillRateDeficit()
{
$now = 0.0;
$rateLimiter = new RateLimiter([
'time_provider' => function () use (&$now) {
return $now;
},
]);

// Enabling the bucket at t=0 with measuredTxRate=0 leaves it at
// fillRate=0.5 and currentCapacity=0.
$rateLimiter->updateSendingRate(true);

// fillRate = 0.5 tokens/s → 2000 ms to accrue 1 token.
$this->assertSame(2000, $rateLimiter->getSendDelayMs());
}

public function testGetSendDelayMsDoesNotBlock()
{
$now = 0.0;
$rateLimiter = new RateLimiter([
'time_provider' => function () use (&$now) {
return $now;
},
]);
$rateLimiter->updateSendingRate(true);

$start = microtime(true);
$delayMs = $rateLimiter->getSendDelayMs();
$elapsed = microtime(true) - $start;

$this->assertGreaterThan(0, $delayMs, 'Expected a non-zero delay hint');
$this->assertLessThan(
0.05,
$elapsed,
'getSendDelayMs() blocked the PHP thread instead of returning a delay hint'
);
}
}
Loading