diff --git a/CHANGELOG.md b/CHANGELOG.md index 022676d69..e78f950c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ * Fixed translations being loaded too early, with a notice from WordPress, when posts were queried while plugins were still loading. * Fixed a long unbroken line of code widening the snippet editor page and pushing the sidebar out of view. +* Fixed admin screens waiting on remote requests for as long as the site's default timeout allows when the Code + Snippets service or WordPress.org is slow or unreachable: these requests now give up after 5 seconds, or 3 seconds + for the welcome content. ## [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..e6d418813 100644 --- a/src/php/Client/Cloud_Public_Client.php +++ b/src/php/Client/Cloud_Public_Client.php @@ -21,6 +21,16 @@ 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 is waiting on it, so it gives up + * before the screen stalls, whatever the site's default timeout. + * + * @var int + */ + private const REQUEST_TIMEOUT = 5; + /** * Maximum number of cloud search results allowed per page. */ @@ -99,7 +109,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 +131,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 +162,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..fd035c34b 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 it gives up before the + * screen stalls, whatever the site's default timeout. + * + * @var int + */ + private const REQUEST_TIMEOUT = 5; + /** * 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 []; 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..f8b99dd82 --- /dev/null +++ b/tests/unit/Client/Remote_Request_Timeouts_Test.php @@ -0,0 +1,174 @@ + + */ + 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' ); + + // A raised default shows which requests set no limit of their own. + add_filter( 'http_request_timeout', [ $this, 'raise_default_timeout' ] ); + add_filter( 'pre_http_request', [ $this, 'capture_request' ], 10, 2 ); + } + + /** + * Tear down after each test. + * + * @return void + */ + public function tear_down() { + remove_filter( 'http_request_timeout', [ $this, 'raise_default_timeout' ] ); + remove_filter( 'pre_http_request', [ $this, 'capture_request' ], 10 ); + delete_transient( 'code_snippets_welcome_data' ); + delete_transient( 'code_snippets_available_versions' ); + + parent::tear_down(); + } + + /** + * Raise the default request timeout above every limit set by the plugin. + * + * @return int + */ + public function raise_default_timeout(): int { + return 30; + } + + /** + * 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( 5, '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( 5, "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( 5, '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( 5, 'the list of available versions' ); + } +}