*/ public $body = array(); /** * The server's `reason` (`[a-z0-9_]`), `network` for a network error, or a local code. * * @var string */ public $reason = ''; /** * Seconds the server asked us to wait, or null. * * @var int|null */ public $retry_after = null; /** * Whether the same request may succeed later (PROTOCOL §2 and §3). * * @var bool */ public $retryable = false; /** * The server's clock (its `Date` header) as a Unix timestamp, or null. * * @var int|null */ public $server_time = null; /** * Build a result from what `wp_remote_request()` returned. * * @param array|\WP_Error $response The response. * @param int|null $now The current time, for `Retry-After` dates. Defaults to now. * @return Result */ public static function from_response( $response, $now = null ) { $result = new self(); $now = null === $now ? time() : (int) $now; if ( is_wp_error( $response ) || ! is_array( $response ) ) { $result->reason = 'network'; $result->retryable = true; return $result; } $result->status = (int) wp_remote_retrieve_response_code( $response ); $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $result->body = is_array( $decoded ) ? $decoded : array(); if ( isset( $result->body['reason'] ) && is_string( $result->body['reason'] ) ) { $result->reason = self::clean_reason( $result->body['reason'] ); } $result->retry_after = self::parse_retry_after( self::header( $response, 'retry-after' ), $now ); $date = self::header( $response, 'date' ); if ( '' !== $date ) { $parsed = strtotime( $date ); $result->server_time = false === $parsed ? null : $parsed; } $result->retryable = self::is_retryable( $result->status, $result->reason ); return $result; } /** * A call that never left this site (not connected, a bad origin, a body that would not encode). * * @param string $reason A short code. * @return Result */ public static function local( $reason ) { $result = new self(); $result->reason = self::clean_reason( $reason ); // A setting on this site (missing credentials, a bad LAUNCHLIST_ORIGIN) can be fixed, so the // signup is kept and tried again. A body that will not encode, or a malformed path, never will. $result->retryable = ! in_array( $result->reason, array( 'encoding', 'invalid_path' ), true ); return $result; } /** * Whether the server accepted the call. * * @return bool */ public function ok() { return $this->status >= 200 && $this->status < 300; } /** * Whether the server no longer knows this site or its secret. * * @return bool */ public function revoked() { return 401 === $this->status && in_array( $this->reason, self::REVOKED_REASONS, true ); } /** * Whether a request that got this answer may succeed if repeated later (PROTOCOL.md §3). * * A 4xx is final only when LaunchList itself said why, with a reason it sends: `malformed`, * `closed`, `unknown_form`, `validation`, `rejected`, `unsigned`, `unknown_site`, * `invalid_signature`, `invalid_grant`. Its retry reasons (`in_progress`, `rate_limited`, * `paused`, and `stale`, since a clock gets fixed) are retried. Anything else is retried with * backoff and never dropped: every 5xx, a network error, a redirect, and a 4xx without one of * those reasons (a firewall's page, a 404 while LaunchList deploys). * * @param int $status HTTP status, 0 for a network error. * @param string $reason The server's reason, or `network`. * @return bool */ public static function is_retryable( $status, $reason ) { $status = (int) $status; if ( 0 === $status ) { return 'network' === $reason; } if ( $status >= 200 && $status < 300 ) { return false; } if ( $status >= 400 && $status < 500 && in_array( $reason, self::SERVER_REASONS, true ) ) { return in_array( $reason, self::RETRY_REASONS, true ); } return true; } /** * Whether LaunchList itself refused the request (a 4xx with one of its reasons). * * @return bool */ public function refused() { return $this->status >= 400 && $this->status < 500 && in_array( $this->reason, self::SERVER_REASONS, true ) && ! $this->retryable; } /** * Whether this failure is about one LaunchList form rather than LaunchList as a whole: it limits * signups per form (429) and pauses them per project (503 `paused`). * * @return bool */ public function form_wide() { return ( 429 === $this->status && 'rate_limited' === $this->reason ) || ( 503 === $this->status && 'paused' === $this->reason ); } /** * Read `Retry-After`: seconds or an HTTP date. Clamped to 0 to six hours. * * @param string $value The header value. * @param int $now The current time. * @return int|null Seconds, or null when absent or unreadable. */ public static function parse_retry_after( $value, $now ) { $value = trim( (string) $value ); if ( '' === $value ) { return null; } if ( preg_match( '/^\d+$/D', $value ) ) { $seconds = (int) $value; } else { // Every HTTP date starts with the weekday; anything else (`-5`, `soon`) is not one. if ( ! preg_match( '/^[A-Za-z]{3,9},? /', $value ) ) { return null; } $time = strtotime( $value ); if ( false === $time ) { return null; } $seconds = $time - (int) $now; } return max( 0, min( self::MAX_RETRY_AFTER, $seconds ) ); } /** * Keep a reason code to lowercase letters, digits and underscores, so it is safe to store and * to compare. * * @param string $reason The raw reason. * @return string */ private static function clean_reason( $reason ) { return substr( (string) preg_replace( '/[^a-z0-9_]/', '', strtolower( (string) $reason ) ), 0, 64 ); } /** * One response header as a string (the first value when it repeats). * * @param array $response The response. * @param string $name Lowercase header name. * @return string */ private static function header( $response, $name ) { $value = wp_remote_retrieve_header( $response, $name ); if ( is_array( $value ) ) { $value = reset( $value ); } return is_string( $value ) ? $value : ''; } }