PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.7
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.7
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / myyoast-client / infrastructure / http / http-client.php

http-client.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 27.7, at src/myyoast-client/infrastructure/http/http-client.php

333 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
3
4 namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http;
5
6 use SensitiveParameter;
7 use WP_Error;
8 use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
9 use Yoast\WP\SEO\Expiring_Store\Application\Expiring_Store;
10 use Yoast\WP\SEO\Expiring_Store\Domain\Corrupted_Value_Exception;
11 use Yoast\WP\SEO\Expiring_Store\Domain\Key_Not_Found_Exception;
12 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\OAuth_Server_Client_Interface;
13 use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type;
14 use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
15 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\DPoP\DPoP_Handler;
16 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\DPoP\DPoP_Proof_Exception;
17 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
18 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
19 use YoastSEO_Vendor\Psr\Log\NullLogger;
20
21 /**
22 * HTTP client wrapping WordPress HTTP API with DPoP header injection.
23 *
24 * Provides automatic DPoP proof generation for all requests and handles
25 * `use_dpop_nonce` errors by retrying once with the new nonce.
26 */
27 class HTTP_Client implements OAuth_Server_Client_Interface, LoggerAwareInterface {
28 use LoggerAwareTrait;
29
30 private const RATE_LIMIT_KEY_PREFIX = 'myyoast_rate_limit:';
31 private const DEFAULT_BACKOFF_SECONDS = \MINUTE_IN_SECONDS;
32
33 /**
34 * The DPoP handler.
35 *
36 * @var DPoP_Handler
37 */
38 private $dpop_handler;
39
40 /**
41 * The expiring store for rate limit backoff.
42 *
43 * @var Expiring_Store
44 */
45 private $expiring_store;
46
47 /**
48 * HTTP_Client constructor.
49 *
50 * @param DPoP_Handler $dpop_handler The DPoP handler.
51 * @param Expiring_Store $expiring_store The expiring store for rate limit tracking.
52 */
53 public function __construct( DPoP_Handler $dpop_handler, Expiring_Store $expiring_store ) {
54 $this->dpop_handler = $dpop_handler;
55 $this->expiring_store = $expiring_store;
56 $this->logger = new NullLogger();
57 }
58
59 /**
60 * Sends an HTTP request with optional DPoP proof.
61 *
62 * @param string $method The HTTP method.
63 * @param string $url The request URL.
64 * @param array<string, string|int|bool|string[]|null> $options Request options: 'headers', 'body', 'timeout', 'dpop' (bool), 'access_token'.
65 *
66 * @return HTTP_Response The parsed response.
67 */
68 public function request( string $method, string $url, array $options = [] ): HTTP_Response {
69 $cached_response = $this->get_cached_rate_limit_response( $url );
70 if ( $cached_response !== null ) {
71 return $cached_response;
72 }
73
74 $result = $this->do_request( $method, $url, $options );
75
76 // Handle DPoP nonce lifecycle when DPoP is active.
77 if ( ! empty( $options['dpop'] ) ) {
78 $this->dpop_handler->handle_nonce_response( $result->get_headers() );
79
80 // Retry once on use_dpop_nonce error with the fresh nonce.
81 if ( $this->is_dpop_nonce_error( $result ) ) {
82 $this->logger->debug(
83 'Retrying request with fresh DPoP nonce for {method} {url}.',
84 [
85 'method' => $method,
86 'url' => $url,
87 ],
88 );
89 $result = $this->do_request( $method, $url, $options );
90 $this->dpop_handler->handle_nonce_response( $result->get_headers() );
91 }
92 }
93
94 return $result;
95 }
96
97 /**
98 * Sends an authenticated resource request with DPoP proof.
99 * Authenticate with a (DPoP bound) access token or refresh token, an Initial Access Token, or a Client Registration Access Token, depending on the token type and endpoint requirements.
100 *
101 * @param string $method The HTTP method.
102 * @param string $url The resource URL.
103 * @param string $access_token The access token.
104 * @param string $token_type An Auth_Token_Type constant.
105 * @param array<string, string|int|bool|string[]|null> $options Additional request options.
106 *
107 * @return HTTP_Response The parsed response.
108 */
109 public function authenticated_request(
110 string $method,
111 string $url,
112 // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+.
113 #[SensitiveParameter]
114 string $access_token,
115 string $token_type = Auth_Token_Type::DPOP,
116 array $options = []
117 ): HTTP_Response {
118 $headers = ( $options['headers'] ?? [] );
119
120 $headers['Authorization'] = $token_type . ' ' . $access_token;
121
122 return $this->request(
123 $method,
124 $url,
125 \array_merge(
126 $options,
127 [
128 'headers' => $headers,
129 'dpop' => ( $token_type === Auth_Token_Type::DPOP ),
130 'access_token' => $access_token,
131 ],
132 ),
133 );
134 }
135
136 /**
137 * Checks if the response indicates a use_dpop_nonce error.
138 *
139 * @param HTTP_Response $result The parsed response.
140 *
141 * @return bool Whether this is a DPoP nonce error.
142 */
143 private function is_dpop_nonce_error( HTTP_Response $result ): bool {
144 return ( $result->get_body_value( 'error' ) === 'use_dpop_nonce' );
145 }
146
147 /**
148 * Executes a single HTTP request with optional DPoP proof injection.
149 *
150 * @param string $method The HTTP method.
151 * @param string $url The request URL.
152 * @param array<string, string|int|bool|string[]|null> $options Request options.
153 *
154 * @return HTTP_Response The parsed response.
155 */
156 private function do_request( string $method, string $url, array $options ): HTTP_Response {
157 $headers = ( $options['headers'] ?? [] );
158 $timeout = ( $options['timeout'] ?? 10 );
159
160 // Add DPoP proof header if requested.
161 if ( ! empty( $options['dpop'] ) ) {
162 $access_token = ( $options['access_token'] ?? null );
163 try {
164 $headers['DPoP'] = $this->dpop_handler->create_proof( $method, $url, $access_token );
165 } catch ( DPoP_Proof_Exception $e ) {
166 $this->logger->error(
167 'DPoP proof generation failed for {method} {url}: {error}',
168 [
169 'method' => $method,
170 'url' => $url,
171 'error' => $e->getMessage(),
172 ],
173 );
174 return new HTTP_Response(
175 0,
176 [],
177 [
178 'error' => 'dpop_proof_failed',
179 'error_description' => $e->getMessage(),
180 ],
181 );
182 }
183 }
184
185 $wp_args = [
186 'method' => \strtoupper( $method ),
187 'headers' => $headers,
188 'timeout' => $timeout,
189 ];
190
191 if ( isset( $options['body'] ) ) {
192 $wp_args['body'] = $options['body'];
193 }
194
195 $response = \wp_remote_request( $url, $wp_args );
196
197 return $this->parse_response( $response, $url );
198 }
199
200 /**
201 * Parses a WordPress HTTP API response into a standardized format.
202 *
203 * @param array<string, string|int|array<string, string>>|WP_Error $response The raw WordPress response.
204 * @param string $url The request URL (for error messages).
205 *
206 * @return HTTP_Response The parsed response.
207 */
208 private function parse_response( $response, string $url ): HTTP_Response {
209 if ( \is_wp_error( $response ) ) {
210 $this->logger->warning(
211 'Network error for {url}: {error}',
212 [
213 'url' => $url,
214 'error' => $response->get_error_message(),
215 ],
216 );
217 return new HTTP_Response(
218 0,
219 [],
220 [
221 'error' => 'network_error',
222 'error_description' => $response->get_error_message(),
223 ],
224 );
225 }
226
227 $status = \wp_remote_retrieve_response_code( $response );
228 $headers = \wp_remote_retrieve_headers( $response );
229 $body = \wp_remote_retrieve_body( $response );
230
231 $headers_array = [];
232 if ( $headers instanceof CaseInsensitiveDictionary ) {
233 $headers_array = (array) $headers->getAll();
234 }
235 elseif ( \is_array( $headers ) ) {
236 $headers_array = $headers;
237 }
238
239 $decoded = \json_decode( $body, true );
240
241 $parsed = new HTTP_Response(
242 (int) $status,
243 $headers_array,
244 ( \is_array( $decoded ) ? $decoded : $body ),
245 );
246
247 if ( (int) $status === 429 ) {
248 $this->logger->warning( 'Rate limited (429) by {url}.', [ 'url' => $url ] );
249 $this->store_rate_limit_response( $url, $parsed );
250 }
251
252 return $parsed;
253 }
254
255 /**
256 * Returns a cached 429 response with an updated Retry-After header, or null if not rate limited.
257 *
258 * @param string $url The request URL.
259 *
260 * @return HTTP_Response|null The cached response or null.
261 */
262 private function get_cached_rate_limit_response( string $url ): ?HTTP_Response {
263 $key = $this->get_rate_limit_key( $url );
264
265 try {
266 $cached = $this->expiring_store->get( $key );
267 } catch ( Key_Not_Found_Exception |Corrupted_Value_Exception $e ) {
268 return null;
269 }
270
271 if ( ! \is_array( $cached ) || ! isset( $cached['stored_at'], $cached['backoff_seconds'], $cached['status'], $cached['headers'], $cached['body'] ) ) {
272 return null;
273 }
274
275 $remaining = ( ( $cached['stored_at'] + $cached['backoff_seconds'] ) - \time() );
276 if ( $remaining <= 0 ) {
277 return null;
278 }
279
280 $headers = $cached['headers'];
281 $headers['retry-after'] = (string) $remaining;
282
283 return new HTTP_Response( (int) $cached['status'], $headers, $cached['body'] );
284 }
285
286 /**
287 * Stores a 429 response in the expiring store for later replay.
288 *
289 * @param string $url The request URL.
290 * @param HTTP_Response $response The parsed 429 response.
291 *
292 * @return void
293 */
294 private function store_rate_limit_response( string $url, HTTP_Response $response ): void {
295 $headers = $response->get_headers();
296 $retry_after = ( $headers['retry-after'] ?? null );
297
298 if ( \is_numeric( $retry_after ) ) {
299 $backoff_seconds = (int) $retry_after;
300 }
301 else {
302 $backoff_seconds = self::DEFAULT_BACKOFF_SECONDS;
303 }
304
305 $this->expiring_store->persist(
306 $this->get_rate_limit_key( $url ),
307 [
308 'stored_at' => \time(),
309 'backoff_seconds' => $backoff_seconds,
310 'status' => $response->get_status(),
311 'headers' => $headers,
312 'body' => $response->get_body(),
313 ],
314 $backoff_seconds,
315 );
316 }
317
318 /**
319 * Builds a rate limit key from a URL using its host and path.
320 *
321 * @param string $url The request URL.
322 *
323 * @return string The rate limit key.
324 */
325 private function get_rate_limit_key( string $url ): string {
326 $parsed = \wp_parse_url( $url );
327 $host = ( $parsed['host'] ?? 'unknown' );
328 $path = ( $parsed['path'] ?? '/' );
329
330 return self::RATE_LIMIT_KEY_PREFIX . $host . $path;
331 }
332 }
333