Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
21 changes: 18 additions & 3 deletions src/php/Client/Cloud_Public_Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 );
Expand All @@ -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 );
Expand Down Expand Up @@ -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 );
Expand Down
16 changes: 15 additions & 1 deletion src/php/Client/Welcome_Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion src/php/Settings/Version_Switch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 [];
Expand Down
174 changes: 174 additions & 0 deletions tests/unit/Client/Remote_Request_Timeouts_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

namespace Code_Snippets\Client;

use Code_Snippets\Model\Basic_Cloud_Connection;
use Code_Snippets\Settings\Version_Switch;
use Code_Snippets\UnitTestCase;
use WP_Error;

/**
* Tests that outbound requests state how long they are prepared to wait.
*
* These are all made while an admin screen is rendering, so a request left to
* WordPress's default holds the screen for as long as that default allows, and
* a slow or unreachable service is felt by every administrator on the site.
* Each call site names its own limit instead.
*
* @group http
*/
class Remote_Request_Timeouts_Test extends UnitTestCase {

/**
* Timeouts seen on requests made during a test.
*
* @var array<int, mixed>
*/
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' );
}
}
Loading