<?php
/**
* Copyright 2022 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
namespace Google\Cloud\Storage\Connection;
use Google\Cloud\Storage\StorageClient;
/**
* Trait which provides helper methods for retry logic.
*
* @internal
*/
trait RetryTrait
{
/**
* The HTTP codes that will be retried by our custom retry function.
* @var array
*/
private static $httpRetryCodes = [
0, // connetion-refused OR connection-reset gives status code of 0
200, // partial download cases
408,
429,
500,
502,
503,
504
];
/**
* The operations which can be retried without any conditions
* (Idempotent)
* @var array
*/
private static $idempotentOps = [
'bucket_acl.get',
'bucket_acl.list',
'buckets.delete',
'buckets.get',
'buckets.getIamPolicy',
'buckets.insert',
'buckets.list',
'buckets.lockRetentionPolicy',
'buckets.testIamPermissions',
'default_object_acl.get',
'default_object_acl.list',
'hmacKey.delete',
'hmacKey.get',
'hmacKey.list',
'notifications.delete',
'notifications.get',
'notifications.list',
'object_acl.get',
'object_acl.list',
'objects.get',
'objects.list',
'serviceaccount.get',
'signBlob.execute'
];
/**
* The operations which can be retried with specific conditions
* (Conditionally idempotent)
* @var array
*/
private static $condIdempotentOps = [
'buckets.patch' => ['ifMetagenerationMatch', 'etag'],
// Currently etag is not supported, so this preCondition never available
'buckets.setIamPolicy' => ['etag'],
'buckets.update' => ['ifMetagenerationMatch', 'etag'],
'hmacKey.update' => ['etag'],
'objects.compose' => ['ifGenerationMatch'],
'objects.copy' => ['ifGenerationMatch'],
'objects.delete' => ['ifGenerationMatch'],
'objects.insert' => ['ifGenerationMatch', 'ifGenerationNotMatch'],
'objects.patch' => ['ifMetagenerationMatch', 'etag'],
'objects.rewrite' => ['ifGenerationMatch'],
'objects.update' => ['ifMetagenerationMatch']
];
/**
* Retry strategies which enforce certain behaviour like:
* - Always retrying a call when an exception occurs(within the limits of 'max retries').
* - Never retrying a call when an exception occurs.
* - Retrying only when the operation is considered idempotent(default).
* These configurations are supplied for per api call basis.
*
*/
/**
* Header that identifies a specific request hash. The
* hash needs to stay the same for multiple retries.
*/
private static $INVOCATION_ID_HEADER = 'gccl-invocation-id';
/**
* Header that identifies the attempt count for a request. The
* value will increment by 1 with every retry.
*/
private static $ATTEMPT_COUNT_HEADER = 'gccl-attempt-count';
/**
* Return a retry decider function.
*
* @param string $resource resource name, eg: buckets.
* @param string $method method name, eg: get
* @param array $args
* @return callable
*/
private function getRestRetryFunction($resource, $method, array $args)
{
if (isset($args['restRetryFunction'])) {
return $args['restRetryFunction'];
}
$methodName = sprintf('%s.%s', $resource, $method);
$isOpIdempotent = in_array($methodName, self::$idempotentOps);
$preconditionNeeded = array_key_exists($methodName, self::$condIdempotentOps);
$preconditionSupplied = $this->isPreConditionSupplied($methodName, $args);
$retryStrategy = isset($args['retryStrategy']) ?
$args['retryStrategy'] :
StorageClient::RETRY_IDEMPOTENT;
return function (
\Exception $exception,
$currentAttempt = 0,
$maxRetries = null
) use (
$isOpIdempotent,
$preconditionNeeded,
$preconditionSupplied,
$retryStrategy
) {
return $this->retryDeciderFunction(
$exception,
$isOpIdempotent,
$preconditionNeeded,
$preconditionSupplied,
$retryStrategy,
$currentAttempt,
$maxRetries
);
};
}
/**
* This function returns true when the user given
* precondtions ($preConditions) has values that are present
* in the precondition map ($this->condIdempotentMap) for that method.
* eg: condIdempotentMap has entry 'objects.copy' => ['ifGenerationMatch'],
* if the user has given 'ifGenerationMatch' in the 'objects.copy' operation,
* it will be available in the $preConditions
* as an array ['ifGenerationMatch']. This makes the array_intersect
* function return a non empty result and this function returns true.
*
* @param string $methodName method name, eg: buckets.get.
* @param array $args arguments which include preconditions provided,
* eg: ['ifGenerationMatch' => 0].
* @return bool
*/
private function isPreConditionSupplied($methodName, array $args)
{
if (isset(self::$condIdempotentOps[$methodName])) {
// return true if required precondition are given.
return !empty(array_intersect(
self::$condIdempotentOps[$methodName],
array_keys($args)
));
}
return false;
}
/**
* Decide whether the op needs to be retried or not.
*
* @param \Exception $exception The exception object received
* while sending the request.
* @param int $currentAttempt Current retry attempt.
* @param bool $isIdempotent
* @param bool $preconditionNeeded
* @param bool $preconditionSupplied
* @param int|null $maxRetries The maximum number of retries allowd.
* Null for no limit.
* @return bool
*/
private function retryDeciderFunction(
\Exception $exception,
$isIdempotent,
$preconditionNeeded,
$preconditionSupplied,
$retryStrategy,
$currentAttempt = 0,
$maxRetries = null
) {
// If maxRetries is specified, ensure we don't exceed it
if ($maxRetries !== null && $currentAttempt >= $maxRetries) {
return false;
}
if ($retryStrategy == StorageClient::RETRY_NEVER) {
return false;
}
$statusCode = $exception->getCode();
// Retry if the exception status code matches
// with one of the retriable status code and
// the operation is either idempotent or conditionally
// idempotent with preconditions supplied.
if (in_array($statusCode, self::$httpRetryCodes)) {
if ($retryStrategy == StorageClient::RETRY_ALWAYS) {
return true;
} elseif ($isIdempotent) {
return true;
} elseif ($preconditionNeeded) {
return $preconditionSupplied;
}
}
return false;
}
/**
* Utility func that returns the list of headers that need to be
* attached to every request and its retries.
*/
private static function getRetryHeaders($invocationId, $attemptCount)
{
return [
sprintf('%s/%s', self::$INVOCATION_ID_HEADER, $invocationId),
sprintf('%s/%d', self::$ATTEMPT_COUNT_HEADER, $attemptCount)
];
}
}
|