From e06e78c99d4669171fe1c68f46f4044b6c1fdc2c Mon Sep 17 00:00:00 2001 From: Stuart McAlpine Date: Sat, 11 Jul 2026 21:06:06 +0100 Subject: [PATCH 1/2] perf: bound BeyondWords API requests to a 3s timeout build_args() set a 30s timeout on every BeyondWords API request and suppressed the VIP RemoteRequestTimeout sniff to allow it. These are blocking requests on real page-generating paths: the classic editor render fires up to five sequential cached GETs (languages, voices, summarization/video templates, video settings), and the write POST/PUT runs synchronously inside wp_after_insert_post. A slow or unresponsive API could pin a PHP worker for up to 30s per call (~150s worst case on a cold cache), degrading the whole site under concurrency on VIP and hanging wp-admin for editors. Drop the timeout to 3s -- the VIP-recommended bound for blocking requests on render paths -- and remove the now-unnecessary phpcs:ignore. The 15-minute transients already keep these calls off the network on the hot path; the shorter timeout only changes behaviour when the API is slow, where failing fast beats worker exhaustion. Verified with phpcs (WordPress-VIP-Go ruleset): passes with no suppression. No PHPUnit/Jest test references the timeout value. Co-Authored-By: Claude Opus 4.8 --- src/api/class-client.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api/class-client.php b/src/api/class-client.php index d2741ae1..50ce3513 100644 --- a/src/api/class-client.php +++ b/src/api/class-client.php @@ -488,8 +488,11 @@ private static function build_args( string $method, string $body = '', array $he 'body' => $body, 'headers' => $headers, 'method' => strtoupper( $method ), - // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout - 'timeout' => 30, + // VIP requires a short, bounded timeout on blocking requests: these + // run synchronously on the classic-editor render path (cached GETs) + // and inside `wp_after_insert_post` (write POST/PUT), so a slow API + // must not pin a PHP worker. + 'timeout' => 3, ]; } From 72f2b2c6ebdc54959cacc19171db959ece797227 Mon Sep 17 00:00:00 2001 From: Stuart McAlpine Date: Sun, 19 Jul 2026 08:39:31 +0100 Subject: [PATCH 2/2] perf: give the voices GET its own longer timeout Sentry p95 for the editor dropdown GETs: voices ~3.7s, every other endpoint ~250ms. The shared CACHED_GET_TIMEOUT (3s) would therefore abandon more than 5% of cold-cache voices fetches, leaving the Voice dropdown empty in the classic editor. Add VOICES_TIMEOUT (8s) and a $timeout param on cached_get() so get_voices() passes its own bound while the other dropdowns keep the short 3s. 8s is ~2x the observed p95 to absorb the tail, still well under the 30s REQUEST_TIMEOUT default, and only ever paid on a cache miss (15-minute CACHE_TTL). Verified: full phpcs ruleset clean; ClientTest 44/145, SelectVoiceTest 12/61 and SyncTest 79/164 pass, with a new test asserting voices is issued with VOICES_TIMEOUT and the other dropdowns keep CACHED_GET_TIMEOUT. Co-Authored-By: Claude Opus 4.8 --- src/api/class-client.php | 32 ++++++++++++++++++----- tests/phpunit/api/test-client.php | 43 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/api/class-client.php b/src/api/class-client.php index b15ea26b..a68b5725 100644 --- a/src/api/class-client.php +++ b/src/api/class-client.php @@ -84,6 +84,19 @@ class Client { */ const CACHED_GET_TIMEOUT = 3; + /** + * Timeout, in seconds, for the voices GET. + * + * Voices is the one editor dropdown that is genuinely slow to prepare — + * ~3.7s p95 in Sentry, against ~250ms p95 for every other editor GET — so + * the shared `CACHED_GET_TIMEOUT` would abandon more than 5% of cold-cache + * fetches and leave the Voice dropdown empty. Bounded well under the + * generous `REQUEST_TIMEOUT` default, and only ever paid on a cache miss. + * + * @since 7.0.0 + */ + const VOICES_TIMEOUT = 8; + /** * Register WordPress hooks. * @@ -368,6 +381,9 @@ public static function get_languages(): array|null|false { /** * GET /organization/voices?filter[language.code]=… * + * Passes the longer `VOICES_TIMEOUT` — this endpoint is materially slower + * than the other editor dropdowns. + * * @param int|string $language_code BeyondWords language code (or numeric ID). * * @return array|null|false @@ -379,7 +395,7 @@ public static function get_voices( int|string $language_code ): array|null|false rawurlencode( strval( $language_code ) ) ); - return self::cached_get( 'voices_' . $language_code, $url ); + return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_TIMEOUT ); } /** @@ -584,17 +600,19 @@ private static function cache_key( string $suffix ): string { * Only 2xx array responses are cached, so a transient API error retries on * the next call rather than caching an empty/error payload for the full TTL. * - * Uses the short `CACHED_GET_TIMEOUT` because these run while the classic - * editor renders. + * Defaults to the short `CACHED_GET_TIMEOUT` because these run while the + * classic editor renders; the slower voices endpoint passes its own bound. * * @since 7.0.0 * - * @param string $suffix Cache-key suffix (include any project/language id). - * @param string $url Absolute endpoint URL. + * @param string $suffix Cache-key suffix (include any project/language id). + * @param string $url Absolute endpoint URL. + * @param int $timeout Request timeout in seconds. Defaults to CACHED_GET_TIMEOUT; + * the voices GET passes the longer VOICES_TIMEOUT. * * @return array|null|false */ - private static function cached_get( string $suffix, string $url ): array|null|false { + private static function cached_get( string $suffix, string $url, int $timeout = self::CACHED_GET_TIMEOUT ): array|null|false { $key = self::cache_key( $suffix ); $cached = get_transient( $key ); @@ -602,7 +620,7 @@ private static function cached_get( string $suffix, string $url ): array|null|fa return $cached; } - $response = self::call_api( 'GET', $url, '', false, [], self::CACHED_GET_TIMEOUT ); + $response = self::call_api( 'GET', $url, '', false, [], $timeout ); $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); if ( diff --git a/tests/phpunit/api/test-client.php b/tests/phpunit/api/test-client.php index 9742409c..a9be2096 100644 --- a/tests/phpunit/api/test-client.php +++ b/tests/phpunit/api/test-client.php @@ -544,6 +544,7 @@ public function editor_dropdown_responses_are_cached() * Editor-dropdown GETs run while the classic editor renders — up to five * sequentially on a cold cache — so they must be bounded by the short * CACHED_GET_TIMEOUT rather than the generous default REQUEST_TIMEOUT. + * Voices is the one exception (see voices_get_uses_longer_timeout). */ public function editor_dropdown_gets_use_short_timeout() { @@ -574,6 +575,48 @@ public function editor_dropdown_gets_use_short_timeout() delete_option('beyondwords_project_id'); } + /** + * @test + * @group settings + * + * Voices is materially slower to prepare than the other dropdowns (~3.7s p95 + * vs ~250ms), so it gets its own longer bound — the shared CACHED_GET_TIMEOUT + * would abandon cold-cache fetches and leave the Voice dropdown empty. + */ + public function voices_get_uses_longer_timeout() + { + update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY); + update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID); + + $captured = null; + $filter = function ($preempt, $args, $url) use (&$captured) { + if (str_contains((string) $url, '/organization/voices')) { + $captured = $args['timeout'] ?? null; + } + return $preempt; // Pass through — let the mock supply the response. + }; + add_filter('pre_http_request', $filter, 0, 3); + + Client::get_voices('en_US'); + + remove_filter('pre_http_request', $filter, 0); + + $this->assertSame(Client::VOICES_TIMEOUT, $captured); + $this->assertGreaterThan( + Client::CACHED_GET_TIMEOUT, + Client::VOICES_TIMEOUT, + 'Voices needs a longer bound than the other editor GETs' + ); + $this->assertLessThan( + Client::REQUEST_TIMEOUT, + Client::VOICES_TIMEOUT, + 'Voices still runs during page generation, so it must stay well under the default' + ); + + delete_option('beyondwords_api_key'); + delete_option('beyondwords_project_id'); + } + /** * @test * @group settings