Skip to content

Commit

Permalink
Automatic request retries
Browse files Browse the repository at this point in the history
  • Loading branch information
ob-stripe committed Feb 5, 2018
1 parent ca38119 commit e5ba09d
Show file tree
Hide file tree
Showing 6 changed files with 348 additions and 12 deletions.
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 [email protected].";

$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;
}
}
30 changes: 30 additions & 0 deletions lib/Util/RandomGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Stripe\Util;

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

0 comments on commit e5ba09d

Please sign in to comment.