Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(storage): retry SignBlob call for URL signing #7862

Merged
merged 32 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
809530a
feat: add integration test for universe domain
thiyaguk09 Sep 27, 2024
b250912
Merge branch 'googleapis:main' into universe-domain-add-integration-t…
thiyaguk09 Sep 27, 2024
2bcb0cd
Merge pull request #1 from thiyaguk09/universe-domain-add-integration…
thiyaguk09 Sep 30, 2024
a315588
style fix
thiyaguk09 Sep 30, 2024
f701a10
style fix
thiyaguk09 Sep 30, 2024
a31e6ec
Merge branch 'googleapis:main' into main
thiyaguk09 Oct 1, 2024
a93d0ec
Merge branch 'googleapis:main' into main
thiyaguk09 Oct 23, 2024
f7d79c3
Adds support for the restore token feature
thiyaguk09 Oct 29, 2024
0d55821
Merge branch 'googleapis:main' into main
thiyaguk09 Oct 29, 2024
3f5079e
lint fix
thiyaguk09 Oct 29, 2024
9720b9b
Merge pull request #2 from thiyaguk09/support-for-restore-token
thiyaguk09 Oct 29, 2024
2c935f4
Merge branch 'main' into main
bshaffer Oct 31, 2024
8dc9eb1
Merge branch 'googleapis:main' into main
thiyaguk09 Nov 12, 2024
bec1876
Merge branch 'googleapis:main' into main
thiyaguk09 Nov 25, 2024
42526e7
Merge branch 'googleapis:main' into correct_retries-signBlob_api
thiyaguk09 Nov 25, 2024
d1f4b32
Merge branch 'googleapis:main' into main
thiyaguk09 Nov 26, 2024
f1fe1e3
signblob retries
thiyaguk09 Nov 27, 2024
93a16be
Merge branch 'googleapis:main' into correct_retries-signBlob_api
thiyaguk09 Nov 27, 2024
18171d5
Merge branch 'correct_retries-signBlob_api' of https://github.com/thi…
thiyaguk09 Nov 27, 2024
7048bbf
lint fix
thiyaguk09 Nov 27, 2024
6f64bed
signblob retry test cases
thiyaguk09 Nov 27, 2024
1ab9c96
Merge pull request #3 from thiyaguk09/correct_retries-signBlob_api
thiyaguk09 Nov 27, 2024
4607e65
using existing retryTrait
thiyaguk09 Dec 3, 2024
b8247f2
Merge branch 'googleapis:main' into main
thiyaguk09 Dec 3, 2024
173d8bb
Merge pull request #4 from thiyaguk09/correct_retries-signBlob_api
thiyaguk09 Dec 3, 2024
5efdf4b
lint issues fix
thiyaguk09 Dec 3, 2024
031c0a7
review corrections and lint fix
thiyaguk09 Dec 3, 2024
69eaeb1
Test case error fix
thiyaguk09 Dec 3, 2024
cf9fd55
add retry using idempontentOps list
thiyaguk09 Dec 9, 2024
e9c05ce
remove bug
thiyaguk09 Dec 9, 2024
a0fb38d
code standard
thiyaguk09 Dec 10, 2024
9b67a34
Retry the test case for maximum attempts.
thiyaguk09 Dec 11, 2024
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ keys/
.testing
.split
__pycache__
.vscode/
93 changes: 78 additions & 15 deletions Storage/src/SigningHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Google\Cloud\Core\JsonTrait;
use Google\Cloud\Core\Timestamp;
use Google\Cloud\Storage\Connection\ConnectionInterface;
use Google\Cloud\Core\Exception\ServiceException;

/**
* Provides common methods for signing storage URLs.
Expand All @@ -34,12 +35,23 @@ class SigningHelper
use ArrayTrait;
use JsonTrait;

const DEFAULT_URL_SIGNING_VERSION = 'v2';
const DEFAULT_DOWNLOAD_HOST = 'storage.googleapis.com';
public const DEFAULT_URL_SIGNING_VERSION = 'v2';
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved
public const DEFAULT_DOWNLOAD_HOST = 'storage.googleapis.com';

const V4_ALGO_NAME = 'GOOG4-RSA-SHA256';
const V4_TIMESTAMP_FORMAT = 'Ymd\THis\Z';
const V4_DATESTAMP_FORMAT = 'Ymd';
public const V4_ALGO_NAME = 'GOOG4-RSA-SHA256';
public const V4_TIMESTAMP_FORMAT = 'Ymd\THis\Z';
public const V4_DATESTAMP_FORMAT = 'Ymd';

/**
* The HTTP codes that will be retried by our custom retry function.
* @var array
*/
private static $httpRetryCodes = [
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved
500,
502,
503,
504
];

/**
* Create or fetch a SigningHelper instance.
Expand All @@ -50,7 +62,7 @@ public static function getHelper()
{
static $helper;
if (!$helper) {
$helper = new static;
$helper = new static();
}

return $helper;
Expand Down Expand Up @@ -169,7 +181,7 @@ public function v2Sign(ConnectionInterface $connection, $expires, $resource, $ge

$signedHeaders = [];
foreach ($headers as $name => $value) {
$signedHeaders[] = $name .':'. $value;
$signedHeaders[] = $name . ':' . $value;
}

// Push the headers onto the end of the signing string.
Expand All @@ -181,9 +193,12 @@ public function v2Sign(ConnectionInterface $connection, $expires, $resource, $ge

$stringToSign = $this->createV2CanonicalRequest($toSign);

$signature = $credentials->signBlob($stringToSign, [
'forceOpenssl' => $options['forceOpenssl']
]);
// Use exponential backOff
$signature = $this->retrySignBlob(function () use ($credentials, $stringToSign, $options) {
return $credentials->signBlob($stringToSign, [
'forceOpenssl' => $options['forceOpenssl']
]);
});
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved

// Start with user-provided query params and add required parameters.
$params = $options['queryParams'];
Expand Down Expand Up @@ -337,9 +352,15 @@ public function v4Sign(ConnectionInterface $connection, $expires, $resource, $ge
$requestHash
]);

$signature = bin2hex(base64_decode($credentials->signBlob($stringToSign, [
'forceOpenssl' => $options['forceOpenssl']
])));
$signature = bin2hex(base64_decode($this->retrySignBlob(function () use (
$credentials,
$stringToSign,
$options
) {
return $credentials->signBlob($stringToSign, [
'forceOpenssl' => $options['forceOpenssl']
]);
})));
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved

// Construct the modified resource name. If a custom hostname is provided,
// this will remove the bucket name from the resource.
Expand Down Expand Up @@ -677,8 +698,8 @@ private function normalizeOptions(array $options)
if (!$options['timestamp']) {
throw new \InvalidArgumentException(
'Given timestamp string is in an invalid format. Provide timestamp formatted as follows: `' .
\DateTime::RFC3339 .
'`. Note that timestamps MUST be in UTC.'
\DateTime::RFC3339 .
'`. Note that timestamps MUST be in UTC.'
);
}
}
Expand Down Expand Up @@ -882,4 +903,46 @@ private function buildQueryString(array $input)

return implode('&', $q);
}

/**
* Retry logic for signBlob
*
* @param callable $signBlobFn A callable that perform the actual signBlob operation.
* @param int $maxRetries Maximum number of retry attempts.
* @param int $initialDelay Initial delay between retries in milliseconds.
* @return string The signature genarated by signBlob.
* @throws ServiceException If non-retryable error occur.
* @throws \RuntimeException If retries are exhausted.
*/
private function retrySignBlob(callable $signBlobFn, int $maxRetries = 5, int $initialDelay = 100)
{
$attempts = 0;
$delay = $initialDelay;

while ($attempts < $maxRetries) {
try {
return $signBlobFn();
} catch (ServiceException $exception) {
$attempts++;
$statusCode = $exception->getCode();

// Retry if the exception status code matches
// with one of the retriable status code
if (in_array($statusCode, self::$httpRetryCodes)) {
usleep($delay * 1000);
bshaffer marked this conversation as resolved.
Show resolved Hide resolved

$delay *= 2; // Exponential backoff
continue;
}

// Non-retryable error
throw $exception;
}
}

throw new \RuntimeException(sprintf(
'Failed to sign message after `%s` attempts.',
$attempts
));
}
}
118 changes: 109 additions & 9 deletions Storage/tests/Unit/SigningHelperTest.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

/**
* Copyright 2019 Google LLC
*
Expand All @@ -19,6 +20,7 @@

use Google\Auth\Credentials\ServiceAccountCredentials;
use Google\Auth\SignBlobInterface;
use Google\Cloud\Core\Exception\ServiceException;
use Google\Cloud\Core\RequestWrapper;
use Google\Cloud\Core\Testing\TestHelpers;
use Google\Cloud\Core\Timestamp;
Expand All @@ -37,10 +39,10 @@ class SigningHelperTest extends TestCase
{
use ProphecyTrait;

const CLIENT_EMAIL = '[email protected]';
const BUCKET = 'test-bucket';
const OBJECT = 'test-object';
const GENERATION = 11111;
public const CLIENT_EMAIL = '[email protected]';
public const BUCKET = 'test-bucket';
public const OBJECT = 'test-object';
public const GENERATION = 11111;
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved

private $helper;

Expand Down Expand Up @@ -417,7 +419,7 @@ public function testV4SignInvalidExpiration()
{
$this->expectException(\InvalidArgumentException::class);

$expires = (new \DateTime)->modify('+20 days');
$expires = (new \DateTime())->modify('+20 days');
$this->helper->v4Sign(
$this->mockConnection($this->createCredentialsMock()->reveal()),
$expires,
Expand Down Expand Up @@ -453,7 +455,7 @@ public function testExpirations($expiration, $expected)

public function expirations()
{
$tenMins = (new \DateTimeImmutable)->modify('+10 minutes');
$tenMins = (new \DateTimeImmutable())->modify('+10 minutes');

return [
[
Expand Down Expand Up @@ -789,6 +791,104 @@ private function mockConnection($credentials = null, $rw = null)

return $conn->reveal();
}

public function testRetrySignBlobSuccessFirstAttempt()
{
$signBlobFn = function () {
return 'signature';
};

$res = $this->helper->proxyPrivateMethodCall('retrySignBlob', [
$signBlobFn,
5,
10
]);

$this->assertEquals('signature', $res);
}

public function testRetrySignBlobSuccessAfterRetries()
{
$attempts = 0;
$signBlobFn = function () use (&$attempts) {
$attempts++;
if ($attempts < 5) {
throw new ServiceException('Transient error', 503);
}
return 'signature';
};

$res = $this->helper->proxyPrivateMethodCall('retrySignBlob', [
$signBlobFn,
5,
10
]);

$this->assertEquals('signature', $res);
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved
$this->assertEquals(5, $attempts);
}

public function testRetrySignBlobNonRetryableError()
{
$this->expectException(ServiceException::class);
$this->getExpectedExceptionMessage('Non-retryable error');
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved

$signBlobFn = function () use (&$attempt) {
throw new ServiceException('Non-retryable error', 400);
};

$res = $this->helper->proxyPrivateMethodCall('retrySignBlob', [
$signBlobFn,
5,
10
]);
}

public function testRetrySignBlobRetriesExhausted()
{
$this->expectException(\RuntimeException::class);
$this->getExpectedExceptionMessage('Failed to sign message after maximum attempts.');
thiyaguk09 marked this conversation as resolved.
Show resolved Hide resolved

$signBlobFn = function () use (&$attempt) {
throw new ServiceException('Transient error', 503);
};

$res = $this->helper->proxyPrivateMethodCall('retrySignBlob', [
Copy link
Contributor

Choose a reason for hiding this comment

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

generally speaking, calling private methods is a bad way to test. It would be better to mock the Connection class being passed to v2Sign, in a way that can trigger the retry.

I can work with you on how to write tests like that, if you'd like.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a great point. Mocking the Connection class to trigger retries is a much more robust and testable approach. I'd be happy to work with you on implementing this.

$signBlobFn,
5,
10
]);
}

public function testRetrySignBlobExponentialBackoff()
{
$attempts = 0;
$delays = [];

$signBlobFn = function () use (&$attempts, &$delays) {
$attempts++;
if ($attempts < 5) {
// Mock usleep to record delays
$delays[] = $attempts * 1000;
throw new ServiceException('Transient error', 503);
}
return 'signature';
};

$originalUsleep = \Closure::fromCallable('usleep');
$mockUsleep = function ($microseconds) use (&$delays, $originalUsleep) {
$delays[] = $microseconds;
};

$this->helper->proxyPrivateMethodCall('retrySignBlob', [
$signBlobFn,
5,
10
]);

$expectedDelays = [1000, 2000, 3000, 4000];
$this->assertEquals($expectedDelays, $delays);
}
}

//@codingStandardsIgnoreStart
Expand All @@ -803,20 +903,20 @@ private function createV4CanonicalRequest(array $request)
$callPrivate = $this->callPrivate('createV4CanonicalRequest', [$request]);
return $this->createV4CanonicalRequest
? call_user_func($this->createV4CanonicalRequest, $request)
: \Closure::bind($callPrivate, null, new SigningHelper);
: \Closure::bind($callPrivate, null, new SigningHelper());
}

private function createV2CanonicalRequest(array $request)
{
$callPrivate = $this->callPrivate('createV2CanonicalRequest', [$request]);
return $this->createV2CanonicalRequest
? call_user_func($this->createV2CanonicalRequest, $request)
: \Closure::bind($callPrivate, null, new SigningHelper);
: \Closure::bind($callPrivate, null, new SigningHelper());
}

public function proxyPrivateMethodCall($method, array $args)
{
$parent = new SigningHelper;
$parent = new SigningHelper();
$cb = function () use ($method) {
return call_user_func_array([$this, $method], func_get_args()[0]);
};
Expand Down