| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Utils\Response; |
| 4 |
|
| 5 |
/** |
| 6 |
* When an outbound cloud request is worth repeating (spec 043 / PRD PHP-1). |
| 7 |
* |
| 8 |
* `Http::post()` retried on `WP_Error` only, three times, **with no delay at |
| 9 |
* all** — a tight loop. That is the worst possible shape: it misses every |
| 10 |
* retryable HTTP status (a 502 from a restarting upstream was final), and for the |
| 11 |
* transport failures it did catch it fired three requests within milliseconds at |
| 12 |
* a server that had just failed to answer one. |
| 13 |
* |
| 14 |
* Decisions live here rather than in the client so the outbound retry and the |
| 15 |
* `retryable` flag the UI shows a Retry button for cannot disagree. |
| 16 |
*/ |
| 17 |
class RetryPolicy { |
| 18 |
|
| 19 |
/** |
| 20 |
* Attempts AFTER the first. Beyond this the user is better served by an error |
| 21 |
* they can act on than by a request that keeps not finishing. |
| 22 |
*/ |
| 23 |
const MAX_ATTEMPTS = 3; |
| 24 |
|
| 25 |
const BASE_DELAY_MS = 300; |
| 26 |
const MAX_DELAY_MS = 5000; |
| 27 |
|
| 28 |
/** |
| 29 |
* HTTP statuses worth repeating. |
| 30 |
* |
| 31 |
* All transient by nature: a gateway restarting, a worker timing out, an edge |
| 32 |
* dropping the connection. Deliberately NO other 4xx — a 401 or a 422 fails |
| 33 |
* identically however many times it is sent. |
| 34 |
*/ |
| 35 |
private static $retryable_statuses = [ 408, 502, 503, 504, 522, 524 ]; |
| 36 |
|
| 37 |
/** |
| 38 |
* @param mixed $response A `wp_remote_*` result. |
| 39 |
* @param int $attempt 0 for the first failure. |
| 40 |
* @return bool |
| 41 |
*/ |
| 42 |
public static function should_retry( $response, $attempt ) { |
| 43 |
if ( $attempt >= self::MAX_ATTEMPTS - 1 ) { |
| 44 |
return false; |
| 45 |
} |
| 46 |
|
| 47 |
// Transport failure — DNS, refused, reset. Worth another attempt. |
| 48 |
if ( is_wp_error( $response ) ) { |
| 49 |
return true; |
| 50 |
} |
| 51 |
|
| 52 |
$status = (int) wp_remote_retrieve_response_code( $response ); |
| 53 |
|
| 54 |
// A 429 is NOT auto-retried. `Retry-After` says when to come back, and |
| 55 |
// looping is precisely what the server is asking us to stop doing; the |
| 56 |
// normalizer surfaces it with the delay so the caller can honour it once. |
| 57 |
return in_array( $status, self::$retryable_statuses, true ); |
| 58 |
} |
| 59 |
|
| 60 |
/** |
| 61 |
* Backoff in MICROseconds, ready for `usleep()`. |
| 62 |
* |
| 63 |
* Exponential with full jitter. The jitter matters more than the curve: every |
| 64 |
* site that failed on the same upstream blip would otherwise retry in the same |
| 65 |
* instant and recreate the load that caused it. |
| 66 |
* |
| 67 |
* Filterable so tests do not sleep. |
| 68 |
* |
| 69 |
* @param int $attempt |
| 70 |
* @return int |
| 71 |
*/ |
| 72 |
public static function delay( $attempt ) { |
| 73 |
$exponential = self::BASE_DELAY_MS * pow( 2, max( 0, $attempt ) ); |
| 74 |
$capped = (int) min( $exponential, self::MAX_DELAY_MS ); |
| 75 |
$jittered = wp_rand( 0, $capped ); |
| 76 |
|
| 77 |
/** |
| 78 |
* Backoff before the next outbound retry, in milliseconds. |
| 79 |
* |
| 80 |
* Return 0 to disable sleeping (what the test suite does). |
| 81 |
* |
| 82 |
* @since 3.7.0 |
| 83 |
* @param int $jittered Milliseconds. |
| 84 |
* @param int $attempt 0 for the first retry. |
| 85 |
*/ |
| 86 |
$delay_ms = (int) apply_filters( 'templately_http_retry_delay', $jittered, $attempt ); |
| 87 |
|
| 88 |
return max( 0, $delay_ms ) * 1000; |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* Sleep for the computed backoff. No-op when the filter returns 0. |
| 93 |
* |
| 94 |
* @param int $attempt |
| 95 |
* @return void |
| 96 |
*/ |
| 97 |
public static function wait( $attempt ) { |
| 98 |
$microseconds = self::delay( $attempt ); |
| 99 |
|
| 100 |
if ( $microseconds > 0 ) { |
| 101 |
usleep( $microseconds ); |
| 102 |
} |
| 103 |
} |
| 104 |
} |
| 105 |
|