Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@
snippet running under file-based execution.
* Fixed the snippet editor reporting a save as failed and undelivered when no response was received, which it cannot
determine.
* 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)

Expand Down
22 changes: 19 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,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.
*/
Expand Down Expand Up @@ -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 );
Expand All @@ -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 );
Expand Down Expand Up @@ -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 );
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 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.
*/
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
162 changes: 162 additions & 0 deletions tests/unit/Client/Remote_Request_Timeouts_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?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' );

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' );
}
}
Loading