friendship ended with social-app. php is my new best friend
at main 7.8 kB view raw
1<?php 2 3declare(strict_types=1); 4 5namespace Fetch\Concerns; 6 7use Fetch\Interfaces\ClientHandler; 8use Fetch\Interfaces\Response as ResponseInterface; 9use GuzzleHttp\Exception\ConnectException; 10use GuzzleHttp\Exception\RequestException; 11use InvalidArgumentException; 12use RuntimeException; 13use Throwable; 14 15trait ManagesRetries 16{ 17 /** 18 * The maximum number of retries before giving up. 19 */ 20 protected ?int $maxRetries = null; 21 22 /** 23 * The initial delay between retries in milliseconds. 24 */ 25 protected ?int $retryDelay = null; 26 27 /** 28 * The status codes that should be retried. 29 * 30 * @var array<int> 31 */ 32 protected array $retryableStatusCodes = [ 33 408, 429, 500, 502, 503, 34 504, 507, 509, 520, 521, 35 522, 523, 525, 527, 530, 36 ]; 37 38 /** 39 * The exceptions that should be retried. 40 * 41 * @var array<class-string<Throwable>> 42 */ 43 protected array $retryableExceptions = [ 44 ConnectException::class, 45 ]; 46 47 /** 48 * Set the retry logic for the request. 49 * 50 * @param int $retries Maximum number of retry attempts 51 * @param int $delay Initial delay in milliseconds 52 * @return $this 53 * 54 * @throws InvalidArgumentException If the parameters are invalid 55 */ 56 public function retry(int $retries, int $delay = 100): ClientHandler 57 { 58 if ($retries < 0) { 59 throw new InvalidArgumentException('Retries must be a non-negative integer'); 60 } 61 62 if ($delay < 0) { 63 throw new InvalidArgumentException('Delay must be a non-negative integer'); 64 } 65 66 $this->maxRetries = $retries; 67 $this->retryDelay = $delay; 68 69 return $this; 70 } 71 72 /** 73 * Set the status codes that should be retried. 74 * 75 * @param array<int> $statusCodes HTTP status codes 76 * @return $this 77 */ 78 public function retryStatusCodes(array $statusCodes): ClientHandler 79 { 80 $this->retryableStatusCodes = array_map('intval', $statusCodes); 81 82 return $this; 83 } 84 85 /** 86 * Set the exception types that should be retried. 87 * 88 * @param array<class-string<Throwable>> $exceptions Exception class names 89 * @return $this 90 */ 91 public function retryExceptions(array $exceptions): ClientHandler 92 { 93 $this->retryableExceptions = $exceptions; 94 95 return $this; 96 } 97 98 /** 99 * Get the current maximum retries setting. 100 * 101 * @return int The maximum retries 102 */ 103 public function getMaxRetries(): int 104 { 105 return $this->maxRetries ?? self::DEFAULT_RETRIES; 106 } 107 108 /** 109 * Get the current retry delay setting. 110 * 111 * @return int The retry delay in milliseconds 112 */ 113 public function getRetryDelay(): int 114 { 115 return $this->retryDelay ?? self::DEFAULT_RETRY_DELAY; 116 } 117 118 /** 119 * Get the retryable status codes. 120 * 121 * @return array<int> The retryable HTTP status codes 122 */ 123 public function getRetryableStatusCodes(): array 124 { 125 return $this->retryableStatusCodes; 126 } 127 128 /** 129 * Get the retryable exception types. 130 * 131 * @return array<class-string<Throwable>> The retryable exception classes 132 */ 133 public function getRetryableExceptions(): array 134 { 135 return $this->retryableExceptions; 136 } 137 138 /** 139 * Implement retry logic for the request with exponential backoff. 140 * 141 * @param callable $request The request to execute 142 * @return ResponseInterface The response after successful execution 143 * 144 * @throws RequestException If the request fails after all retries 145 * @throws RuntimeException If something unexpected happens 146 */ 147 protected function retryRequest(callable $request): ResponseInterface 148 { 149 $attempts = $this->maxRetries ?? self::DEFAULT_RETRIES; 150 $delay = $this->retryDelay ?? self::DEFAULT_RETRY_DELAY; 151 $exceptions = []; 152 153 for ($attempt = 0; $attempt <= $attempts; $attempt++) { 154 try { 155 // Execute the request 156 return $request(); 157 } catch (RequestException $e) { 158 // Collect exception for later 159 $exceptions[] = $e; 160 161 // If this was the last attempt, break to throw the most recent exception 162 if ($attempt === $attempts) { 163 break; 164 } 165 166 // Only retry on retryable errors 167 if (! $this->isRetryableError($e)) { 168 throw $e; 169 } 170 171 // Log the retry for debugging purposes 172 if (method_exists($this, 'logRetry')) { 173 $this->logRetry($attempt + 1, $attempts, $e); 174 } 175 176 // Calculate delay with exponential backoff and jitter 177 $currentDelay = $this->calculateBackoffDelay($delay, $attempt); 178 179 // Sleep before the next retry 180 usleep($currentDelay * 1000); // Convert milliseconds to microseconds 181 } catch (Throwable $e) { 182 // Handle unexpected exceptions (not RequestException) 183 throw new RuntimeException( 184 sprintf('Unexpected error during request: %s', $e->getMessage()), 185 (int) $e->getCode(), 186 $e 187 ); 188 } 189 } 190 191 // If we got here, all retries failed 192 $lastException = end($exceptions) ?: new RuntimeException('Request failed after all retries'); 193 194 // Enhanced failure reporting 195 if ($lastException instanceof RequestException) { 196 $statusCode = $lastException->getCode(); 197 throw new RuntimeException( 198 sprintf( 199 'Request failed after %d attempts with status code %d: %s', 200 $attempts + 1, 201 $statusCode, 202 $lastException->getMessage() 203 ), 204 $statusCode, 205 $lastException 206 ); 207 } 208 209 throw $lastException; 210 } 211 212 /** 213 * Calculate backoff delay with exponential growth and jitter. 214 * 215 * @param int $baseDelay The base delay in milliseconds 216 * @param int $attempt The current attempt number (0-based) 217 * @return int The calculated delay in milliseconds 218 */ 219 protected function calculateBackoffDelay(int $baseDelay, int $attempt): int 220 { 221 // Exponential backoff: baseDelay * 2^attempt 222 $exponentialDelay = $baseDelay * (2 ** $attempt); 223 224 // Add jitter: random value between 0-100% of the calculated delay 225 $jitter = mt_rand(0, 100) / 100; // Random value between 0 and 1 226 $delay = (int) ($exponentialDelay * (1 + $jitter)); 227 228 // Cap the maximum delay at 30 seconds (30000ms) 229 return min($delay, 30000); 230 } 231 232 /** 233 * Determine if an error is retryable. 234 * 235 * @param RequestException $e The exception to check 236 * @return bool Whether the error is retryable 237 */ 238 protected function isRetryableError(RequestException $e): bool 239 { 240 $statusCode = $e->getCode(); 241 242 // Check if the status code is in our list of retryable codes 243 $isRetryableStatusCode = in_array($statusCode, $this->retryableStatusCodes, true); 244 245 // Check if the exception or its previous is one of our retryable exception types 246 $isRetryableException = false; 247 $exception = $e; 248 249 while ($exception) { 250 if (in_array(get_class($exception), $this->retryableExceptions, true)) { 251 $isRetryableException = true; 252 break; 253 } 254 $exception = $exception->getPrevious(); 255 } 256 257 return $isRetryableStatusCode || $isRetryableException; 258 } 259}