friendship ended with social-app. php is my new best friend
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}