From 88e731f0aee985ec0967e134217f72cf2a0313c1 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 29 Sep 2026 12:12:47 +0100 Subject: [PATCH 1/2] fix: bound how long admin screens wait on remote requests --- CHANGELOG.md | 2 ++ src/php/Client/Cloud_Public_Client.php | 22 +++++++++++++++++++--- src/php/Client/Welcome_Client.php | 16 +++++++++++++++- src/php/Settings/Version_Switch.php | 15 ++++++++++++++- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e94b17f42..47964dfc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ * Fixed featured Community Cloud snippets failing to load with some cloud API responses. (PRO) * Fixed bulk actions in Community Cloud running against an empty selection, so selected snippets were never downloaded. (PRO) +* Fixed admin screens waiting on remote requests for longer than intended when the Code Snippets service is slow or + unreachable. ## [3.10.2] (2026-09-01) diff --git a/src/php/Client/Cloud_Public_Client.php b/src/php/Client/Cloud_Public_Client.php index 46d619a06..0007b6384 100644 --- a/src/php/Client/Cloud_Public_Client.php +++ b/src/php/Client/Cloud_Public_Client.php @@ -21,6 +21,17 @@ class Cloud_Public_Client { */ private const SEARCH_REQUEST_TIMEOUT = 15; + /** + * Seconds to wait for the remaining public API requests. + * + * Each is made while an admin screen renders, so the wait is bounded here + * rather than left to the shorter default, which was cutting slower + * responses short, while still giving up before the screen stalls. + * + * @var int + */ + private const REQUEST_TIMEOUT = 10; + /** * Maximum number of cloud search results allowed per page. */ @@ -99,7 +110,8 @@ public function fetch_search_results( string $search_method, string $search, int */ public function get_cloud_snippet( int $cloud_id ): ?Cloud_Snippet { $response = wp_remote_get( - sprintf( '%s/public/getsnippet/%s', $this->connection->get_api_url(), $cloud_id ) + sprintf( '%s/public/getsnippet/%s', $this->connection->get_api_url(), $cloud_id ), + [ 'timeout' => self::REQUEST_TIMEOUT ] ); $data = unpack_response_body( $response ); @@ -120,7 +132,8 @@ public function get_cloud_snippet( int $cloud_id ): ?Cloud_Snippet { */ public function get_cloud_snippet_revision( string $cloud_id ): ?string { $response = wp_remote_get( - sprintf( '%s/public/getsnippetrevision/%s', $this->connection->get_api_url(), $cloud_id ) + sprintf( '%s/public/getsnippetrevision/%s', $this->connection->get_api_url(), $cloud_id ), + [ 'timeout' => self::REQUEST_TIMEOUT ] ); $body = unpack_response_body( $response ); @@ -150,7 +163,10 @@ public function get_featured_snippets( int $page, int $per_page, array $filters $response = wp_remote_get( add_query_arg( $params, sprintf( '%s/public/featured', $this->connection->get_api_url() ) ), - [ 'headers' => $this->connection->get_request_headers() ] + [ + 'headers' => $this->connection->get_request_headers(), + 'timeout' => self::REQUEST_TIMEOUT, + ] ); return Cloud_Snippets::unpack_api_response( unpack_response_body( $response ), $page ); diff --git a/src/php/Client/Welcome_Client.php b/src/php/Client/Welcome_Client.php index 2a5481944..0b553424a 100644 --- a/src/php/Client/Welcome_Client.php +++ b/src/php/Client/Welcome_Client.php @@ -42,6 +42,17 @@ class Welcome_Client { */ protected const CACHE_KEY = 'code_snippets_welcome_data'; + /** + * Seconds to wait for the remote welcome document. + * + * This is fetched while an admin screen is rendering, and nothing on the + * screen depends on it, so it gives up sooner than a request the reader is + * actually waiting on. + * + * @var int + */ + protected const REQUEST_TIMEOUT = 3; + /** * Data fetched from the remote API. * @@ -201,7 +212,10 @@ private function parse_partners( array $remote ): array { * @return void */ protected function fetch_remote_welcome_data() { - $remote_welcome_data = wp_remote_get( self::WELCOME_JSON_URL ); + $remote_welcome_data = wp_remote_get( + self::WELCOME_JSON_URL, + [ 'timeout' => self::REQUEST_TIMEOUT ] + ); if ( is_wp_error( $remote_welcome_data ) ) { return; diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index 786a14a4c..ca4e4d026 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -21,6 +21,16 @@ class Version_Switch { */ private const CACHE_KEY = 'code_snippets_available_versions'; + /** + * Seconds to wait for the WordPress.org plugin API. + * + * Requested while the settings screen renders, so the wait is bounded here + * rather than left to the default. + * + * @var int + */ + private const REQUEST_TIMEOUT = 10; + /** * Transient key used to indicate when a version switch is currently taking place. */ @@ -60,7 +70,10 @@ public static function get_available_versions(): array { $versions = get_transient( self::CACHE_KEY ); if ( false === $versions ) { - $response = wp_remote_get( self::WORDPRESS_API_ENDPOINT ); + $response = wp_remote_get( + self::WORDPRESS_API_ENDPOINT, + [ 'timeout' => self::REQUEST_TIMEOUT ] + ); if ( is_wp_error( $response ) ) { return []; From 64c6551dcea6a32af0e8b2f93fdec7a17ef2fc08 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 29 Sep 2026 12:12:55 +0100 Subject: [PATCH 2/2] test: cover timeouts on outbound requests --- .../Client/Remote_Request_Timeouts_Test.php | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/unit/Client/Remote_Request_Timeouts_Test.php diff --git a/tests/unit/Client/Remote_Request_Timeouts_Test.php b/tests/unit/Client/Remote_Request_Timeouts_Test.php new file mode 100644 index 000000000..07f5c68f1 --- /dev/null +++ b/tests/unit/Client/Remote_Request_Timeouts_Test.php @@ -0,0 +1,162 @@ + + */ + private array $timeouts = []; + + /** + * Set up before each test. + * + * @return void + */ + public function set_up() { + parent::set_up(); + + $this->timeouts = []; + delete_transient( 'code_snippets_welcome_data' ); + delete_transient( 'code_snippets_available_versions' ); + + add_filter( 'pre_http_request', [ $this, 'capture_request' ], 10, 2 ); + } + + /** + * Tear down after each test. + * + * @return void + */ + public function tear_down() { + remove_filter( 'pre_http_request', [ $this, 'capture_request' ], 10 ); + delete_transient( 'code_snippets_welcome_data' ); + delete_transient( 'code_snippets_available_versions' ); + + parent::tear_down(); + } + + /** + * Record each request's timeout and answer it without leaving the machine. + * + * Answers with a failure, so each caller takes its own error path and none + * of them parse a response body. What is under test is the request that went + * out, not what any of them make of what comes back. + * + * @param mixed $preempt Short-circuit value, from an earlier callback. + * @param array $parsed_args Request arguments, with defaults already applied. + * + * @return WP_Error Canned failure. + */ + public function capture_request( $preempt, $parsed_args ) { + $this->timeouts[] = $parsed_args['timeout'] ?? null; + + return new WP_Error( 'http_request_failed', 'Refused by the test.' ); + } + + /** + * Assert every request made during the test asked for a given limit. + * + * @param int $expected Limit the call site should have set, in seconds. + * @param string $subject What was being fetched, for the failure message. + * + * @return void + */ + private function assert_requests_waited_for( int $expected, string $subject ): void { + $this->assertNotEmpty( $this->timeouts, "fetching $subject should have made a request" ); + + foreach ( $this->timeouts as $timeout ) { + $this->assertEquals( + $expected, + $timeout, + "fetching $subject should state how long it waits; without one it falls back to " + . 'WordPress\'s default and holds the admin screen open for that long instead' + ); + } + } + + /** + * The welcome document gives up soonest, as no screen depends on it. + * + * @return void + */ + public function test_welcome_data_request_is_bounded(): void { + new Welcome_Client(); + + $this->assert_requests_waited_for( 3, 'the welcome document' ); + } + + /** + * A single cloud snippet states its own limit. + * + * @return void + */ + public function test_cloud_snippet_request_is_bounded(): void { + ( new Cloud_Public_Client( new Basic_Cloud_Connection() ) )->get_cloud_snippet( 1 ); + + $this->assert_requests_waited_for( 10, 'a cloud snippet' ); + } + + /** + * A cloud snippet's revision states its own limit. + * + * @return void + */ + public function test_cloud_snippet_revision_request_is_bounded(): void { + ( new Cloud_Public_Client( new Basic_Cloud_Connection() ) )->get_cloud_snippet_revision( '1' ); + + $this->assert_requests_waited_for( 10, "a cloud snippet's revision" ); + } + + /** + * Featured cloud snippets state their own limit. + * + * @return void + */ + public function test_featured_snippets_request_is_bounded(): void { + ( new Cloud_Public_Client( new Basic_Cloud_Connection() ) )->get_featured_snippets( 1, 10, [] ); + + $this->assert_requests_waited_for( 10, 'featured cloud snippets' ); + } + + /** + * Searching the cloud is allowed the longest, as the reader is waiting on it. + * + * @return void + */ + public function test_cloud_search_request_is_bounded(): void { + ( new Cloud_Public_Client( new Basic_Cloud_Connection() ) ) + ->fetch_search_results( 'search', 'term', 1, 10, [] ); + + $this->assert_requests_waited_for( 15, 'cloud search results' ); + } + + /** + * The list of installable versions states its own limit. + * + * @return void + */ + public function test_available_versions_request_is_bounded(): void { + Version_Switch::get_available_versions(); + + $this->assert_requests_waited_for( 10, 'the list of available versions' ); + } +}