1<?php 2/* 3 * Copyright 2014 Google Inc. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18namespace Google\Task; 19 20use Google\Service\Exception as GoogleServiceException; 21use Google\Task\Exception as GoogleTaskException; 22 23/** 24 * A task runner with exponential backoff support. 25 * 26 * @see https://developers.google.com/drive/web/handle-errors#implementing_exponential_backoff 27 */ 28class Runner 29{ 30 const TASK_RETRY_NEVER = 0; 31 const TASK_RETRY_ONCE = 1; 32 const TASK_RETRY_ALWAYS = -1; 33 34 /** 35 * @var integer $maxDelay The max time (in seconds) to wait before a retry. 36 */ 37 private $maxDelay = 60; 38 /** 39 * @var integer $delay The previous delay from which the next is calculated. 40 */ 41 private $delay = 1; 42 43 /** 44 * @var integer $factor The base number for the exponential back off. 45 */ 46 private $factor = 2; 47 /** 48 * @var float $jitter A random number between -$jitter and $jitter will be 49 * added to $factor on each iteration to allow for a better distribution of 50 * retries. 51 */ 52 private $jitter = 0.5; 53 54 /** 55 * @var integer $attempts The number of attempts that have been tried so far. 56 */ 57 private $attempts = 0; 58 /** 59 * @var integer $maxAttempts The max number of attempts allowed. 60 */ 61 private $maxAttempts = 1; 62 63 /** 64 * @var callable $action The task to run and possibly retry. 65 */ 66 private $action; 67 /** 68 * @var array $arguments The task arguments. 69 */ 70 private $arguments; 71 72 /** 73 * @var array $retryMap Map of errors with retry counts. 74 */ 75 protected $retryMap = [ 76 '500' => self::TASK_RETRY_ALWAYS, 77 '503' => self::TASK_RETRY_ALWAYS, 78 'rateLimitExceeded' => self::TASK_RETRY_ALWAYS, 79 'userRateLimitExceeded' => self::TASK_RETRY_ALWAYS, 80 6 => self::TASK_RETRY_ALWAYS, // CURLE_COULDNT_RESOLVE_HOST 81 7 => self::TASK_RETRY_ALWAYS, // CURLE_COULDNT_CONNECT 82 28 => self::TASK_RETRY_ALWAYS, // CURLE_OPERATION_TIMEOUTED 83 35 => self::TASK_RETRY_ALWAYS, // CURLE_SSL_CONNECT_ERROR 84 52 => self::TASK_RETRY_ALWAYS, // CURLE_GOT_NOTHING 85 'lighthouseError' => self::TASK_RETRY_NEVER 86 ]; 87 88 /** 89 * Creates a new task runner with exponential backoff support. 90 * 91 * @param array $config The task runner config 92 * @param string $name The name of the current task (used for logging) 93 * @param callable $action The task to run and possibly retry 94 * @param array $arguments The task arguments 95 * @throws \Google\Task\Exception when misconfigured 96 */ 97 public function __construct( 98 $config, 99 $name, 100 $action, 101 array $arguments = array() 102 ) { 103 if (isset($config['initial_delay'])) { 104 if ($config['initial_delay'] < 0) { 105 throw new GoogleTaskException( 106 'Task configuration `initial_delay` must not be negative.' 107 ); 108 } 109 110 $this->delay = $config['initial_delay']; 111 } 112 113 if (isset($config['max_delay'])) { 114 if ($config['max_delay'] <= 0) { 115 throw new GoogleTaskException( 116 'Task configuration `max_delay` must be greater than 0.' 117 ); 118 } 119 120 $this->maxDelay = $config['max_delay']; 121 } 122 123 if (isset($config['factor'])) { 124 if ($config['factor'] <= 0) { 125 throw new GoogleTaskException( 126 'Task configuration `factor` must be greater than 0.' 127 ); 128 } 129 130 $this->factor = $config['factor']; 131 } 132 133 if (isset($config['jitter'])) { 134 if ($config['jitter'] <= 0) { 135 throw new GoogleTaskException( 136 'Task configuration `jitter` must be greater than 0.' 137 ); 138 } 139 140 $this->jitter = $config['jitter']; 141 } 142 143 if (isset($config['retries'])) { 144 if ($config['retries'] < 0) { 145 throw new GoogleTaskException( 146 'Task configuration `retries` must not be negative.' 147 ); 148 } 149 $this->maxAttempts += $config['retries']; 150 } 151 152 if (!is_callable($action)) { 153 throw new GoogleTaskException( 154 'Task argument `$action` must be a valid callable.' 155 ); 156 } 157 158 $this->action = $action; 159 $this->arguments = $arguments; 160 } 161 162 /** 163 * Checks if a retry can be attempted. 164 * 165 * @return boolean 166 */ 167 public function canAttempt() 168 { 169 return $this->attempts < $this->maxAttempts; 170 } 171 172 /** 173 * Runs the task and (if applicable) automatically retries when errors occur. 174 * 175 * @return mixed 176 * @throws \Google\Service\Exception on failure when no retries are available. 177 */ 178 public function run() 179 { 180 while ($this->attempt()) { 181 try { 182 return call_user_func_array($this->action, $this->arguments); 183 } catch (GoogleServiceException $exception) { 184 $allowedRetries = $this->allowedRetries( 185 $exception->getCode(), 186 $exception->getErrors() 187 ); 188 189 if (!$this->canAttempt() || !$allowedRetries) { 190 throw $exception; 191 } 192 193 if ($allowedRetries > 0) { 194 $this->maxAttempts = min( 195 $this->maxAttempts, 196 $this->attempts + $allowedRetries 197 ); 198 } 199 } 200 } 201 } 202 203 /** 204 * Runs a task once, if possible. This is useful for bypassing the `run()` 205 * loop. 206 * 207 * NOTE: If this is not the first attempt, this function will sleep in 208 * accordance to the backoff configurations before running the task. 209 * 210 * @return boolean 211 */ 212 public function attempt() 213 { 214 if (!$this->canAttempt()) { 215 return false; 216 } 217 218 if ($this->attempts > 0) { 219 $this->backOff(); 220 } 221 222 $this->attempts++; 223 return true; 224 } 225 226 /** 227 * Sleeps in accordance to the backoff configurations. 228 */ 229 private function backOff() 230 { 231 $delay = $this->getDelay(); 232 233 usleep($delay * 1000000); 234 } 235 236 /** 237 * Gets the delay (in seconds) for the current backoff period. 238 * 239 * @return float 240 */ 241 private function getDelay() 242 { 243 $jitter = $this->getJitter(); 244 $factor = $this->attempts > 1 ? $this->factor + $jitter : 1 + abs($jitter); 245 246 return $this->delay = min($this->maxDelay, $this->delay * $factor); 247 } 248 249 /** 250 * Gets the current jitter (random number between -$this->jitter and 251 * $this->jitter). 252 * 253 * @return float 254 */ 255 private function getJitter() 256 { 257 return $this->jitter * 2 * mt_rand() / mt_getrandmax() - $this->jitter; 258 } 259 260 /** 261 * Gets the number of times the associated task can be retried. 262 * 263 * NOTE: -1 is returned if the task can be retried indefinitely 264 * 265 * @return integer 266 */ 267 public function allowedRetries($code, $errors = array()) 268 { 269 if (isset($this->retryMap[$code])) { 270 return $this->retryMap[$code]; 271 } 272 273 if ( 274 !empty($errors) && 275 isset($errors[0]['reason'], $this->retryMap[$errors[0]['reason']]) 276 ) { 277 return $this->retryMap[$errors[0]['reason']]; 278 } 279 280 return 0; 281 } 282 283 public function setRetryMap($retryMap) 284 { 285 $this->retryMap = $retryMap; 286 } 287} 288