retryable_failure = $retryable; $this->retry_after_seconds = max( 0, $retry_after ); } public static function terminal( string $message ): self { return new self( $message, false ); } public static function transient( string $message, int $retry_after = 0 ): self { return new self( $message, true, $retry_after ); } /** @param array $response */ public static function from_http_response( string $provider, array $response, string $body = '' ): self { $provider = sanitize_text_field( $provider ); $status = (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $status ) { $retry_after = self::retry_after( $response, $body ); if ( self::is_non_retryable_quota( $body ) && 0 === $retry_after ) { return self::terminal( $provider . ' quota or credit allowance is exhausted. Regenerate after it becomes available.' ); } return self::transient( $provider . ' temporarily limited narration requests (HTTP 429). Retrying later.', $retry_after > 0 ? $retry_after : self::DEFAULT_RATE_LIMIT_DELAY ); } if ( 408 === $status || $status >= 500 ) { return self::transient( $provider . ' temporarily failed to process narration (HTTP ' . $status . ').' ); } if ( 401 === $status || 403 === $status ) { return self::terminal( $provider . ' rejected the configured API credential or its permissions (HTTP ' . $status . ').' ); } if ( $status >= 400 && $status < 500 ) { return self::terminal( $provider . ' rejected the narration request (HTTP ' . $status . ').' ); } return self::transient( $provider . ' returned an invalid narration response (HTTP ' . $status . ').' ); } public function is_retryable(): bool { return $this->retryable_failure; } public function retry_delay( int $attempt ): int { $base = $this->retry_after_seconds > 0 ? $this->retry_after_seconds : 30; $multiplier = max( 1, 2 ** max( 0, $attempt - 1 ) ); return $base * $multiplier; } /** @param array $response */ private static function retry_after( array $response, string $body ): int { $header = wp_remote_retrieve_header( $response, 'retry-after' ); $header = is_array( $header ) ? reset( $header ) : $header; if ( is_scalar( $header ) ) { $value = trim( (string) $header ); if ( ctype_digit( $value ) ) { return max( 1, (int) $value ); } $timestamp = strtotime( $value ); if ( false !== $timestamp ) { return max( 1, $timestamp - time() ); } } $payload = json_decode( $body, true ); return is_array( $payload ) ? self::retry_delay_from_payload( $payload ) : 0; } /** @param array $payload */ private static function retry_delay_from_payload( array $payload ): int { foreach ( $payload as $key => $value ) { $key = strtolower( (string) $key ); if ( in_array( $key, array( 'retrydelay', 'retry_delay', 'quotaresetdelay', 'quota_reset_delay' ), true ) && is_scalar( $value ) ) { $seconds = self::duration_seconds( (string) $value ); if ( $seconds > 0 ) { return $seconds; } } if ( is_array( $value ) ) { $seconds = self::retry_delay_from_payload( $value ); if ( $seconds > 0 ) { return $seconds; } } } return 0; } private static function duration_seconds( string $value ): int { $value = strtolower( trim( $value ) ); if ( is_numeric( $value ) ) { return max( 1, (int) ceil( (float) $value ) ); } if ( preg_match( '/^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m)?(?:(\d+(?:\.\d+)?)s)?$/', $value, $matches ) ) { $hours = isset( $matches[1] ) && '' !== $matches[1] ? (float) $matches[1] : 0.0; $minutes = isset( $matches[2] ) && '' !== $matches[2] ? (float) $matches[2] : 0.0; $seconds = isset( $matches[3] ) && '' !== $matches[3] ? (float) $matches[3] : 0.0; return max( 0, (int) ceil( ( $hours * HOUR_IN_SECONDS ) + ( $minutes * MINUTE_IN_SECONDS ) + $seconds ) ); } return 0; } private static function is_non_retryable_quota( string $body ): bool { $body = strtolower( $body ); foreach ( array( 'daily quota', 'daily limit', 'per day', 'per_day', 'perday', 'requests per day', 'request_per_day', 'requestsperday', '"rpd"', 'insufficient_quota', 'insufficient credits', 'insufficient_credits', 'billing_hard_limit' ) as $marker ) { if ( str_contains( $body, $marker ) ) { return true; } } return false; } }