Skip to content
Merged
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
92 changes: 34 additions & 58 deletions src/api/class-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,54 +60,30 @@ class Client {
/**
* Default timeout, in seconds, for a BeyondWords API request.
*
* The create/update content calls can be slow, so this is deliberately
* generous. On WordPress VIP those calls are deferred to background cron
* (see `\BeyondWords\Post\Sync::is_async_generation_enabled()`), so the long
* timeout never blocks a web request there.
* VIP's approved ceiling for a blocking remote request — the
* `WordPressVIPMinimum.Performance.RemoteRequestTimeout` sniff errors above
* 3 seconds. The API answers fast on every endpoint except voices: content
* create/update returns the content ID immediately and generates the audio
* asynchronously server-side, so even the write paths have no reason to
* wait longer than this.
*
* @since 7.0.0
*/
const REQUEST_TIMEOUT = 30;
const DEFAULT_REQUEST_TIMEOUT = 3;

/**
* Timeout, in seconds, for a DELETE request.
* Timeout, in seconds, for the voices GET — the one slow endpoint.
*
* Deletion runs on the synchronous trash/delete admin path — once per post
* on a bulk trash off-VIP — so a long blocking timeout would hang the request
* and can breach VIP request limits. Deletes are best-effort (a timeout just
* leaves the audio in the BeyondWords dashboard), so we cap the wait short.
* Voices is genuinely slow to prepare — ~3.7s p95 in Sentry, against
* ~250ms p95 for every other GET — so the shared `DEFAULT_REQUEST_TIMEOUT`
* would abandon more than 5% of cold-cache fetches. Because a failure is
* then negative-cached, that would blank the Voice dropdown for a whole
* `CACHE_TTL_ON_ERROR` rather than just the one render. Only ever paid on
* a cache miss.
*
* @since 7.0.0
*/
const DELETE_TIMEOUT = 3;

/**
* Timeout, in seconds, for a cached render-path GET (editor dropdowns).
*
* These run inline on every classic-editor metabox render, so a long
* blocking timeout would tie up a PHP-FPM worker for the length of an admin
* page load. Short, per VIP guidance (see `vip_safe_wp_remote_get`), and
* paired with negative caching ({@see CACHE_TTL_ON_ERROR}) to bound repeat
* failures.
*
* @since 7.0.0
*/
const RENDER_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 `RENDER_TIMEOUT` would abandon more than 5% of cold-cache
* fetches. Because a failure is then negative-cached, that would blank the
* Voice dropdown for a whole `CACHE_TTL_ON_ERROR` rather than just the one
* render. Still well under the generous `REQUEST_TIMEOUT` default, and only
* ever paid on a cache miss.
*
* @since 7.0.0
*/
const VOICES_TIMEOUT = 8;
const VOICES_REQUEST_TIMEOUT = 8;

/**
* Register WordPress hooks.
Expand Down Expand Up @@ -262,9 +238,10 @@ public static function delete_audio( int $post_id ): array|null|false {
*
* Split out from `delete_audio()` so the deferred trash/delete cron job can
* delete audio from the project + content IDs captured before the post meta
* was wiped (see `\BeyondWords\Post\Sync::delete_audio_by_ids()`). Uses the
* short `DELETE_TIMEOUT` because deletion runs on the synchronous
* trash/delete admin path.
* was wiped (see `\BeyondWords\Post\Sync::delete_audio_by_ids()`). Deletion
* runs on the synchronous trash/delete admin path and is best-effort (a
* timeout just leaves the audio in the BeyondWords dashboard), so the short
* default timeout applies.
*
* @since 7.0.0
*
Expand All @@ -280,7 +257,7 @@ public static function delete_audio_by_ids( int|string|false $project_id, int|st
}

$url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
$response = self::call_api( 'DELETE', $url, '', $post_id, [], self::DELETE_TIMEOUT );
$response = self::call_api( 'DELETE', $url, '', $post_id );

if ( 204 !== wp_remote_retrieve_response_code( $response ) ) {
return false;
Expand Down Expand Up @@ -393,8 +370,8 @@ 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.
* Passes the longer `VOICES_REQUEST_TIMEOUT` — this endpoint is materially
* slower than the other editor dropdowns.
*
* @param int|string $language_code BeyondWords language code (or numeric ID).
*
Expand All @@ -407,7 +384,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, self::VOICES_TIMEOUT );
return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_REQUEST_TIMEOUT );
}

/**
Expand Down Expand Up @@ -533,9 +510,9 @@ public static function get_video_settings_templates(): array|null|false {
* @param string $body Request body (already JSON-encoded for write methods).
* @param int|false $post_id WordPress post ID for error attribution; false to suppress.
* @param array<string,string> $headers Extra per-request headers.
* @param int $timeout Request timeout in seconds. Defaults to REQUEST_TIMEOUT.
* @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT.
*/
public static function call_api( string $method, string $url, string $body = '', int|false $post_id = false, array $headers = [], int $timeout = self::REQUEST_TIMEOUT ): array|\WP_Error {
public static function call_api( string $method, string $url, string $body = '', int|false $post_id = false, array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|\WP_Error {
$post = get_post( $post_id );

self::delete_errors( $post_id );
Expand Down Expand Up @@ -570,13 +547,12 @@ public static function call_api( string $method, string $url, string $body = '',
* @param string $method HTTP method.
* @param string $body Request body.
* @param array<string,string> $headers Extra per-request headers.
* @param int $timeout Request timeout in seconds. Defaults to REQUEST_TIMEOUT;
* DELETEs pass the shorter DELETE_TIMEOUT and cached
* render-path GETs the shorter RENDER_TIMEOUT.
* @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT;
* the voices GET passes the longer VOICES_REQUEST_TIMEOUT.
*
* @return array<string,mixed>
*/
private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::REQUEST_TIMEOUT ): array {
private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array {
return [
'blocking' => true,
'body' => $body,
Expand Down Expand Up @@ -614,9 +590,9 @@ private static function cache_key( string $suffix ): string {
* (WP_Error / timeout, 5xx, 4xx, non-JSON) are negative-cached for the much
* shorter {@see CACHE_TTL_ON_ERROR}, so a slow or unreachable API is probed
* at most once per interval instead of re-issuing a blocking request on
* every admin render. The request itself defaults to the short
* {@see RENDER_TIMEOUT} for the same reason; the slower voices endpoint
* passes its own {@see VOICES_TIMEOUT}.
* every admin render. The request itself uses the short
* {@see DEFAULT_REQUEST_TIMEOUT} for the same reason; the slower voices
* endpoint passes its own {@see VOICES_REQUEST_TIMEOUT}.
*
* A 401 additionally self-heals: call_api() clears
* `beyondwords_valid_api_connection`, which ungates the metabox next load.
Expand All @@ -625,13 +601,13 @@ private static function cache_key( string $suffix ): string {
*
* @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 RENDER_TIMEOUT;
* the voices GET passes the longer VOICES_TIMEOUT.
* @param int $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT;
* the voices GET passes the longer VOICES_REQUEST_TIMEOUT.
*
* @return array<mixed>|null|false Decoded body on the fetching call; the cached
* value ([] after a cached failure) thereafter.
*/
private static function cached_get( string $suffix, string $url, int $timeout = self::RENDER_TIMEOUT ): array|null|false {
private static function cached_get( string $suffix, string $url, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|null|false {
$key = self::cache_key( $suffix );
$cached = get_transient( $key );

Expand Down
2 changes: 1 addition & 1 deletion src/post/class-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ private static function unschedule_audio_generation( int $post_id ): void {
* one remote DELETE per post. We read the IDs before the caller wipes the meta
* (on trash) or WordPress deletes the row (on permanent delete). Off-VIP,
* where WP-Cron is unreliable, we fall back to a synchronous DELETE, bounded
* by the client's short `DELETE_TIMEOUT`.
* by the client's short `DEFAULT_REQUEST_TIMEOUT`.
*
* The caller must have confirmed `Meta::has_content()` first.
*
Expand Down
13 changes: 4 additions & 9 deletions src/settings/class-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,6 @@ class Utils {
*/
const CONNECTION_CHECK_TTL = 5 * MINUTE_IN_SECONDS;

/**
* Timeout (seconds) for the connection-check GET. Kept at the VIP-approved
* 3-second ceiling so a slow API can't stall the settings page render.
*/
const CONNECTION_CHECK_TIMEOUT = 3;

/**
* Transient that throttles connection re-validation. Its value is a
* fingerprint of the credentials last checked, so changing the API key or
Expand Down Expand Up @@ -165,8 +159,9 @@ public static function has_valid_api_connection(): bool {
* per {@see self::CONNECTION_CHECK_TTL}. Changing the API key or project ID
* changes the fingerprint, so saving new credentials re-validates
* immediately rather than waiting out the throttle window.
* 2. The request uses a short {@see self::CONNECTION_CHECK_TIMEOUT} timeout so
* a slow API can't block the page render.
* 2. The request uses the client's short default timeout
* ({@see \BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT}) so a slow API
* can't block the page render.
*
* The stored flag is only cleared on a definitive auth failure (401/403) —
* `Client::call_api()` clears it on 401 and we mirror that for 403 here. A
Expand All @@ -192,7 +187,7 @@ public static function validate_api_connection(): bool {
}

$url = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id );
$response = \BeyondWords\Api\Client::call_api( 'GET', $url, '', false, [], self::CONNECTION_CHECK_TIMEOUT );
$response = \BeyondWords\Api\Client::call_api( 'GET', $url );

// Record the attempt whatever the outcome, so repeated page loads don't
// re-issue the request — a down API is throttled just like a healthy one.
Expand Down
72 changes: 62 additions & 10 deletions src/site-health/class-site-health.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,36 +186,88 @@ public static function add_rest_api_connection( array &$info ): void {
'value' => $api_url,
];

$info['beyondwords']['fields']['api-communication'] = array_merge(
[ 'label' => __( 'Communication with REST API', 'speechkit' ) ],
self::get_api_communication( $api_url )
);
}

/**
* Resolve the "Communication with REST API" value/debug pair.
*
* Deliberately **not** cached. This mirrors WordPress core's own
* `dotorg_communication` field ({@see \WP_Debug_Data::get_wp_core()}), which
* probes wordpress.org on every Info-screen render: Site Health is a
* diagnostic, so a cached result would keep reporting "unreachable" to an
* admin who has just fixed their credentials, DNS or firewall and hit
* refresh to confirm. The request is instead bounded by the shared
* {@see \BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT} and skipped
* entirely when there are no credentials, which keeps the render cost
* predictable without ever serving stale diagnostics.
*
* @param string $api_url BeyondWords REST API base URL.
*
* @return array<string,string>
*/
private static function get_api_communication( string $api_url ): array {
// Nothing to probe until the site has API credentials — skip the
// blocking request on fresh installs entirely.
if ( ! \BeyondWords\Settings\Utils::has_api_creds() ) {
return [
'value' => __( 'Not checked — BeyondWords API credentials are not configured', 'speechkit' ),
'debug' => 'not-configured',
];
}

// A plain request, not `Client::call_api()`: this is a passive probe, and
// `call_api()` would clear `beyondwords_valid_api_connection` on a 401 as
// a side effect of merely viewing Site Health. `vip_safe_wp_remote_get()`
// is avoided for the same reason — its circuit breaker reports a failure
// without actually retrying, which would make the diagnostic lie.
$response = wp_remote_request(
$api_url,
[
'blocking' => true,
'body' => '',
'method' => 'GET',
'method' => 'GET',
'timeout' => \BeyondWords\Api\Client::DEFAULT_REQUEST_TIMEOUT,
]
);

if ( ! is_wp_error( $response ) ) {
$info['beyondwords']['fields']['api-communication'] = [
'label' => __( 'Communication with REST API', 'speechkit' ),
return [
'value' => __( 'BeyondWords API is reachable', 'speechkit' ),
'debug' => 'true',
];
return;
}

$info['beyondwords']['fields']['api-communication'] = [
'label' => __( 'Communication with REST API', 'speechkit' ),
return [
'value' => sprintf(
/* translators: 1: The IP address the REST API resolves to. 2: The error returned by the lookup. */
/* translators: 1: The IP address the REST API resolves to. 2: The error returned by the request. */
__( 'Unable to reach BeyondWords API at %1$s: %2$s', 'speechkit' ),
gethostbyname( $api_url ),
self::resolve_api_host( $api_url ),
$response->get_error_message()
),
'debug' => $response->get_error_message(),
];
}

/**
* Resolve the API host to an IP address for the connectivity diagnostic.
*
* `gethostbyname()` needs a bare hostname, so the URL is parsed first; it
* returns the host (or URL) unchanged when resolution fails.
*
* @param string $api_url BeyondWords REST API base URL.
*/
private static function resolve_api_host( string $api_url ): string {
$host = wp_parse_url( $api_url, PHP_URL_HOST );

if ( ! is_string( $host ) || '' === $host ) {
return $api_url;
}

return gethostbyname( $host );
}

/**
* Add registered + deprecated filter hook info to the debug array.
*
Expand Down
Loading
Loading