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

Automatic request retries #428

Merged
merged 2 commits into from
Feb 7, 2018
Merged
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
1 change: 1 addition & 0 deletions init.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require(dirname(__FILE__) . '/lib/Util/AutoPagingIterator.php');
require(dirname(__FILE__) . '/lib/Util/LoggerInterface.php');
require(dirname(__FILE__) . '/lib/Util/DefaultLogger.php');
require(dirname(__FILE__) . '/lib/Util/RandomGenerator.php');
require(dirname(__FILE__) . '/lib/Util/RequestOptions.php');
require(dirname(__FILE__) . '/lib/Util/Set.php');
require(dirname(__FILE__) . '/lib/Util/Util.php');
Expand Down
123 changes: 111 additions & 12 deletions lib/HttpClient/CurlClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ public static function instance()
*
* @param array|callable|null $defaultOptions
*/
public function __construct($defaultOptions = null)
public function __construct($defaultOptions = null, $randomGenerator = null)
{
$this->defaultOptions = $defaultOptions;
$this->randomGenerator = $randomGenerator ?: new Util\RandomGenerator();
$this->initUserAgentInfo();
}

Expand Down Expand Up @@ -110,7 +111,6 @@ public function getConnectTimeout()

public function request($method, $absUrl, $headers, $params, $hasFile)
{
$curl = curl_init();
$method = strtolower($method);

$opts = [];
Expand Down Expand Up @@ -147,6 +147,14 @@ public function request($method, $absUrl, $headers, $params, $hasFile)
throw new Error\Api("Unrecognized method $method");
}

// It is only safe to retry network failures on POST requests if we
// add an Idempotency-Key header
if (($method == 'post') && (Stripe::$maxNetworkRetries > 0)) {
if (!isset($headers['Idempotency-Key'])) {
array_push($headers, 'Idempotency-Key: ' . $this->randomGenerator->uuid());
}
}

// Create a callback to capture HTTP headers for the response
$rheaders = [];
$headerCallback = function ($curl, $header_line) use (&$rheaders) {
Expand Down Expand Up @@ -185,27 +193,58 @@ public function request($method, $absUrl, $headers, $params, $hasFile)
$opts[CURLOPT_SSL_VERIFYPEER] = false;
}

curl_setopt_array($curl, $opts);
$rbody = curl_exec($curl);
list($rbody, $rcode) = $this->executeRequestWithRetries($opts, $absUrl);

if ($rbody === false) {
$errno = curl_errno($curl);
$message = curl_error($curl);
return [$rbody, $rcode, $rheaders];
}

/**
* @param array $opts cURL options
*/
private function executeRequestWithRetries($opts, $absUrl)
{
$numRetries = 0;

while (true) {
$rcode = 0;
$errno = 0;

$curl = curl_init();
curl_setopt_array($curl, $opts);
$rbody = curl_exec($curl);

if ($rbody === false) {
$errno = curl_errno($curl);
$message = curl_error($curl);
} else {
$rcode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
}
curl_close($curl);
$this->handleCurlError($absUrl, $errno, $message);

if ($this->shouldRetry($errno, $rcode, $numRetries)) {
$numRetries += 1;
$sleepSeconds = $this->sleepTime($numRetries);
usleep(intval($sleepSeconds * 1000000));
} else {
break;
}
}

$rcode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
return [$rbody, $rcode, $rheaders];
if ($rbody === false) {
$this->handleCurlError($absUrl, $errno, $message, $numRetries);
}

return [$rbody, $rcode];
}

/**
* @param string $url
* @param number $errno
* @param string $message
* @param int $numRetries
* @throws Error\ApiConnection
*/
private function handleCurlError($url, $errno, $message)
private function handleCurlError($url, $errno, $message, $numRetries)
{
switch ($errno) {
case CURLE_COULDNT_CONNECT:
Expand All @@ -230,6 +269,66 @@ private function handleCurlError($url, $errno, $message)
$msg .= " let us know at support@stripe.com.";

$msg .= "\n\n(Network error [errno $errno]: $message)";

if ($numRetries > 0) {
$msg .= "\n\nRequest was retried $numRetries times.";
}

throw new Error\ApiConnection($msg);
}

/**
* Checks if an error is a problem that we should retry on. This includes both
* socket errors that may represent an intermittent problem and some special
* HTTP statuses.
* @param int $errno
* @param int $rcode
* @param int $numRetries
* @return bool
*/
private function shouldRetry($errno, $rcode, $numRetries)
{
if ($numRetries >= Stripe::getMaxNetworkRetries()) {
return false;
}

// Retry on timeout-related problems (either on open or read).
if ($errno === CURLE_OPERATION_TIMEOUTED) {
return true;
}

// Destination refused the connection, the connection was reset, or a
// variety of other connection failures. This could occur from a single
// saturated server, so retry in case it's intermittent.
if ($errno === CURLE_COULDNT_CONNECT) {
return true;
}

// 409 conflict
if ($rcode === 409) {
return true;
}

return false;
}

private function sleepTime($numRetries)
{
// Apply exponential backoff with $initialNetworkRetryDelay on the
// number of $numRetries so far as inputs. Do not allow the number to exceed
// $maxNetworkRetryDelay.
$sleepSeconds = min(
Stripe::getInitialNetworkRetryDelay() * 1.0 * pow(2, $numRetries - 1),
Stripe::getMaxNetworkRetryDelay()
);

// Apply some jitter by randomizing the value in the range of
// ($sleepSeconds / 2) to ($sleepSeconds).
$sleepSeconds *= 0.5 * (1 + $this->randomGenerator->randFloat());

// But never sleep less than the base sleep seconds.
$sleepSeconds = max(Stripe::getInitialNetworkRetryDelay(), $sleepSeconds);

return $sleepSeconds;
}
}
41 changes: 41 additions & 0 deletions lib/Stripe.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ class Stripe
// produce messages.
public static $logger = null;

// @var int Maximum number of request retries
public static $maxNetworkRetries = 0;

// @var float Maximum delay between retries, in seconds
private static $maxNetworkRetryDelay = 2.0;

// @var float Initial delay between retries, in seconds
private static $initialNetworkRetryDelay = 0.5;

const VERSION = '5.8.0';

/**
Expand Down Expand Up @@ -197,4 +206,36 @@ public static function setAppInfo($appName, $appVersion = null, $appUrl = null)
self::$appInfo['version'] = $appVersion;
self::$appInfo['url'] = $appUrl;
}

/**
* @return int Maximum number of request retries
*/
public static function getMaxNetworkRetries()
{
return self::$maxNetworkRetries;
}

/**
* @param int $maxNetworkRetries Maximum number of request retries
*/
public static function setMaxNetworkRetries($maxNetworkRetries)
{
self::$maxNetworkRetries = $maxNetworkRetries;
}

/**
* @return float Maximum delay between retries, in seconds
*/
public static function getMaxNetworkRetryDelay()
{
return self::$maxNetworkRetryDelay;
}

/**
* @return float Initial delay between retries, in seconds
*/
public static function getInitialNetworkRetryDelay()
{
return self::$initialNetworkRetryDelay;
}
}
34 changes: 34 additions & 0 deletions lib/Util/RandomGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace Stripe\Util;

/**
* A basic random generator. This is in a separate class so we the generator
* can be injected as a dependency and replaced with a mock in tests.
*/
class RandomGenerator
{
/**
* Returns a random value between 0 and $max.
*
* @param float $max (optional)
* @return float
*/
public function randFloat($max = 1.0)
{
return mt_rand() / mt_getrandmax() * $max;
}

/**
* Returns a v4 UUID.
*
* @return string
*/
public function uuid()
{
$arr = array_values(unpack('N1a/n4b/N1c', openssl_random_pseudo_bytes(16)));
$arr[2] = ($arr[2] & 0x0fff) | 0x4000;
$arr[3] = ($arr[3] & 0x3fff) | 0x8000;
return vsprintf('%08x-%04x-%04x-%04x-%04x%08x', $arr);
}
}
Loading