From dae165c72a58ae2b0a20a0072f534355f74d5e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 2 Oct 2026 17:32:54 +0000 Subject: [PATCH 1/5] Retry transient publish failures and request finalize explicitly A single transient failure (a 5xx or timeout on one of thousands of file uploads, or a 503 from the upload-refresh call) threw, the Simply Static task reset the publisher state, and the next export declared a brand-new version and started uploading from scratch. Large sites never finished. Retry retryable failures in place for up to five consecutive background steps, keeping the same version and target. Once every file has landed, ask Spacefast to finalize (async) before polling, so activation no longer depends only on the runtime's upload-completion callback. --- includes/class-spacefast-client.php | 17 ++++ includes/class-spacefast-static-publisher.php | 45 ++++++++++- readme.txt | 6 +- spacefast-wordpress.php | 4 +- tests/acceptance/wordpress.php | 2 +- tests/behavior.php | 79 +++++++++++++++++++ 6 files changed, 147 insertions(+), 6 deletions(-) diff --git a/includes/class-spacefast-client.php b/includes/class-spacefast-client.php index a83295b..84de1f2 100644 --- a/includes/class-spacefast-client.php +++ b/includes/class-spacefast-client.php @@ -309,6 +309,23 @@ public function resume_static_upload( string $version_id ): array { ); } + /** + * Ask Spacefast to finalize an uploaded version. Auto-finalize normally does + * this when the last upload lands; an explicit request joins the same + * finalize operation, so a lost completion signal cannot strand the version. + * + * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} + */ + public function finalize_static_version( string $version_id ): array { + $settings = Spacefast_Settings::get(); + return $this->request( + 'POST', + '/v1/spaces/' . rawurlencode( $settings['space_id'] ) . '/versions/' + . rawurlencode( $version_id ) . '/finalize?async=1', + array( 'channel' => 'live' ) + ); + } + /** * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index d21e9c5..bbddcb9 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -4,6 +4,10 @@ final class Spacefast_Static_Publisher { const OPTION = 'spacefast_wordpress_publish_state'; + // Consecutive transient failures tolerated before a publish gives up. Each + // retry is one background step, so a brief outage no longer restarts the + // whole export as a new version. + const MAX_TRANSIENT_RETRIES = 5; /** * Advance one bounded step of a Simply Static publish. @@ -96,8 +100,9 @@ public static function step( if ( in_array( $result['code'], array( 'upload_http_401', 'upload_http_403' ), true ) ) { return self::resume( $state, $client ); } - throw new RuntimeException( $result['message'] ); + return self::retry_or_throw( $state, $result, 'uploading' ); } + $state['retries'] = 0; $state['next_target'] = $next_target + 1; $state['uploaded'] = (int) ( $state['uploaded'] ?? 0 ) + 1; update_option( self::OPTION, $state, false ); @@ -212,8 +217,9 @@ private static function resume( array $state, Spacefast_Client $client ): array } $result = $client->resume_static_upload( (string) $state['version_id'] ); if ( ! $result['ok'] ) { - throw new RuntimeException( $result['message'] ); + return self::retry_or_throw( $state, $result, 'uploading' ); } + $state['retries'] = 0; $upload = $result['data']['upload'] ?? null; if ( null !== $upload && ! is_array( $upload ) ) { throw new RuntimeException( 'Spacefast returned invalid upload instructions.' ); @@ -261,6 +267,17 @@ private static function poll_version( array $state, Spacefast_Client $client ): if ( $polls > 300 ) { throw new RuntimeException( 'Spacefast did not finish publishing the version.' ); } + if ( empty( $state['finalize_requested'] ) ) { + $finalize = $client->finalize_static_version( (string) $state['version_id'] ); + if ( ! $finalize['ok'] && $finalize['retryable'] ) { + return self::retry_or_throw( $state, $finalize, 'finalizing' ); + } + // A refusal here usually means auto-finalize already owns the version; + // the status poll below reports whatever it settles to. + $state['finalize_requested'] = true; + $state['retries'] = 0; + update_option( self::OPTION, $state, false ); + } $result = $client->get_static_version( (string) $state['version_id'] ); if ( ! $result['ok'] ) { if ( $result['retryable'] ) { @@ -300,4 +317,28 @@ private static function poll_version( array $state, Spacefast_Client $client ): 'status' => 'finalizing', ); } + + /** + * Keep the publish state and try the same step again on a transient failure, + * up to MAX_TRANSIENT_RETRIES in a row. Anything else ends the publish. + * + * @param array $state State. + * @param array{ok:bool,retryable:bool,code:string,message:string,data:array} $result Failed result. + * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string} + */ + private static function retry_or_throw( array $state, array $result, string $status ): array { + $retries = (int) ( $state['retries'] ?? 0 ) + 1; + if ( ! $result['retryable'] || $retries > self::MAX_TRANSIENT_RETRIES ) { + throw new RuntimeException( $result['message'] ); + } + $state['retries'] = $retries; + update_option( self::OPTION, $state, false ); + return array( + 'done' => false, + 'version_id' => (string) ( $state['version_id'] ?? '' ), + 'uploaded' => (int) ( $state['uploaded'] ?? 0 ), + 'total' => (int) ( $state['total'] ?? 0 ), + 'status' => $status, + ); + } } diff --git a/readme.txt b/readme.txt index 021bb43..11ba613 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: spacefast Tags: static site, headless cms, simply static, deployment Requires at least: 6.5 Requires PHP: 8.1 -Stable tag: 0.5.7 +Stable tag: 0.5.8 License: GPLv2 or later Publish a Simply Static export to Spacefast, or rebuild a headless site when WordPress content changes. @@ -31,6 +31,10 @@ OAuth access is limited to the Team you authorize and the mode you choose. WordP == Changelog == += 0.5.8 = +* Retry transient upload, upload-refresh, and finalize failures in place instead of restarting the whole export as a new version. +* Ask Spacefast to finalize once every file has uploaded, so activation no longer depends on the upload-completion signal alone. + = 0.5.7 = * Include public custom post type archive routes in Simply Static exports. diff --git a/spacefast-wordpress.php b/spacefast-wordpress.php index 0b65d4d..8b775fd 100644 --- a/spacefast-wordpress.php +++ b/spacefast-wordpress.php @@ -3,7 +3,7 @@ * Plugin Name: Spacefast * Plugin URI: https://spacefast.com/ * Description: Publishes static WordPress exports or rebuilds a headless Spacefast site. - * Version: 0.5.7 + * Version: 0.5.8 * Update URI: https://github.com/spacefast/wordpress * Requires at least: 6.5 * Requires PHP: 8.1 @@ -14,7 +14,7 @@ defined( 'ABSPATH' ) || exit; -define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.7' ); +define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.8' ); define( 'SPACEFAST_WORDPRESS_FILE', __FILE__ ); require_once __DIR__ . '/includes/class-spacefast-settings.php'; diff --git a/tests/acceptance/wordpress.php b/tests/acceptance/wordpress.php index dc620bd..4572378 100644 --- a/tests/acceptance/wordpress.php +++ b/tests/acceptance/wordpress.php @@ -113,7 +113,7 @@ function spacefast_accept( bool $condition, string $message ): void { ); $plugin = get_plugin_data( WP_PLUGIN_DIR . '/spacefast-wordpress/spacefast-wordpress.php', false, false ); -spacefast_accept( '0.5.7' === $plugin['Version'], 'Unexpected plugin version.' ); +spacefast_accept( '0.5.8' === $plugin['Version'], 'Unexpected plugin version.' ); spacefast_accept( 'https://github.com/spacefast/wordpress' === $plugin['UpdateURI'], 'Update URI is missing.' ); Spacefast_Settings::disconnect(); diff --git a/tests/behavior.php b/tests/behavior.php index 8cd742c..a12acb4 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -1084,6 +1084,85 @@ public function run_static_export() { self::$runs++; return true; } Spacefast_Plugin::static_publish_progress( 'uploading' ); check( 'uploading' === Spacefast_Sync_State::get()['last_status'], 'static upload progress refreshes the delivery heartbeat' ); +Spacefast_Static_Publisher::reset(); +$retry_requests = array(); +$retry_uploads = 0; +$retry_target = array( + 'path' => 'index.html', + 'method' => 'PUT', + 'url' => 'https://uploads.example.test/retry', + 'headers' => array(), +); +$retry_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( &$retry_requests, $retry_target ): array { + $retry_requests[] = array( $url, $args['method'] ); + if ( 'GET' === $args['method'] ) { + return response( 200, array( 'id' => 'ver_retry', 'status' => 'ready', 'isCurrentProduction' => true ) ); + } + if ( str_contains( $url, '/finalize' ) ) { + return response( 202, array( 'id' => 'ver_retry', 'status' => 'finalizing' ) ); + } + return response( + 201, + array( + 'versionId' => 'ver_retry', + 'upload' => array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ), + ) + ); + }, + static function () use ( &$retry_uploads ): array { + $retry_uploads++; + return response( 1 === $retry_uploads ? 503 : 204 ); + } +); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( false === $retry_step['done'] && 0 === $retry_step['uploaded'], 'a transient upload failure keeps the publish running' ); +check( 'ver_retry' === $retry_step['version_id'], 'a transient upload failure keeps the same Spacefast version' ); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( 1 === $retry_step['uploaded'] && 2 === $retry_uploads, 'the next step retries the same upload target' ); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( 'finalizing' === $retry_step['status'], 'a finished upload waits for activation' ); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( true === $retry_step['done'] && 'live' === $retry_step['status'], 'the retried publish goes live' ); +$retry_creates = array_filter( + $retry_requests, + static fn ( array $request ): bool => 'POST' === $request[1] && str_ends_with( $request[0], '/versions' ) +); +$retry_finalizes = array_filter( + $retry_requests, + static fn ( array $request ): bool => str_contains( $request[0], '/versions/ver_retry/finalize?async=1' ) +); +check( 1 === count( $retry_creates ), 'a retried publish never declares a second version' ); +check( 1 === count( $retry_finalizes ), 'a finished upload asks Spacefast to finalize once' ); + +Spacefast_Static_Publisher::reset(); +$rejecting_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( $retry_target ): array { + return response( + 201, + array( + 'versionId' => 'ver_rejected', + 'upload' => array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ), + ) + ); + }, + static fn (): array => response( 400 ) +); +$rejected = false; +try { + Spacefast_Static_Publisher::step( $archive, $rejecting_client ); +} catch ( RuntimeException $error ) { + $rejected = 'Spacefast rejected a generated file upload.' === $error->getMessage(); +} +check( $rejected, 'a rejected upload still ends the publish' ); +Spacefast_Static_Publisher::reset(); + Spacefast_Settings::merge( $connection ); unlink( $archive . '/assets/app.js' ); From e627f6f8830f31f4d00953f5cbc5685920ac0255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 2 Oct 2026 17:42:39 +0000 Subject: [PATCH 2/5] Refresh an expired upload session before reusing its targets When an upload came back 401/403 and the refresh then failed transiently, the next step re-sent the file on the stale target before refreshing again. Remember the pending refresh and retry it first. --- includes/class-spacefast-static-publisher.php | 7 ++++ tests/behavior.php | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index bbddcb9..3f11884 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -83,6 +83,11 @@ public static function step( } } + // An expired upload session whose refresh failed transiently: refresh + // again before sending bytes on the stale targets. + if ( ! empty( $state['resume_pending'] ) ) { + return self::resume( $state, $client ); + } $upload = $state['upload'] ?? null; if ( ! is_array( $upload ) ) { return self::begin_finalizing( $state ); @@ -98,6 +103,7 @@ public static function step( $result = $client->upload_static_file( $target, $path ); if ( ! $result['ok'] ) { if ( in_array( $result['code'], array( 'upload_http_401', 'upload_http_403' ), true ) ) { + $state['resume_pending'] = true; return self::resume( $state, $client ); } return self::retry_or_throw( $state, $result, 'uploading' ); @@ -220,6 +226,7 @@ private static function resume( array $state, Spacefast_Client $client ): array return self::retry_or_throw( $state, $result, 'uploading' ); } $state['retries'] = 0; + $state['resume_pending'] = false; $upload = $result['data']['upload'] ?? null; if ( null !== $upload && ! is_array( $upload ) ) { throw new RuntimeException( 'Spacefast returned invalid upload instructions.' ); diff --git a/tests/behavior.php b/tests/behavior.php index a12acb4..ab3a2c9 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -1138,6 +1138,39 @@ static function () use ( &$retry_uploads ): array { check( 1 === count( $retry_creates ), 'a retried publish never declares a second version' ); check( 1 === count( $retry_finalizes ), 'a finished upload asks Spacefast to finalize once' ); +Spacefast_Static_Publisher::reset(); +$expired_uploads = 0; +$expired_resumes = 0; +$expired_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( &$expired_resumes, $retry_target ): array { + if ( 'GET' === $args['method'] ) { + return response( 200, array( 'id' => 'ver_expired', 'status' => 'ready', 'isCurrentProduction' => true ) ); + } + if ( str_contains( $url, '/finalize' ) ) { + return response( 202, array( 'id' => 'ver_expired', 'status' => 'finalizing' ) ); + } + $upload = array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ); + if ( str_contains( $url, '/uploads/resume' ) ) { + $expired_resumes++; + return 1 === $expired_resumes ? response( 503 ) : response( 200, array( 'upload' => $upload ) ); + } + return response( 201, array( 'versionId' => 'ver_expired', 'upload' => $upload ) ); + }, + static function () use ( &$expired_uploads ): array { + $expired_uploads++; + return response( 1 === $expired_uploads ? 401 : 204 ); + } +); +$expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); +check( false === $expired_step['done'] && 1 === $expired_resumes, 'an expired upload session asks for fresh targets' ); +$expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); +check( 2 === $expired_resumes && 1 === $expired_uploads, 'a failed refresh is retried before any stale target is sent again' ); +$expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); +check( 2 === $expired_uploads && 1 === $expired_step['uploaded'], 'the refreshed target uploads' ); + Spacefast_Static_Publisher::reset(); $rejecting_client = new Spacefast_Client( static function ( string $url, array $args ) use ( $retry_target ): array { From 2aae731959333ef01b96203edd225589cdc38d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 2 Oct 2026 17:47:25 +0000 Subject: [PATCH 3/5] Confirm the missing set through uploads/refresh instead of calling finalize Finalize requires versions:promote, which the plugin's static-mode OAuth scopes do not grant, so the explicit call always got a 403. After the last target, refresh the upload instead: it re-lists anything the runtime never received (uploaded before waiting), and with spacefast/monorepo#3494 an empty refresh runs the same completion as the upload callback. --- includes/class-spacefast-client.php | 17 ------ includes/class-spacefast-static-publisher.php | 33 +++++++++--- readme.txt | 2 +- tests/behavior.php | 53 ++++++++++++++++--- 4 files changed, 72 insertions(+), 33 deletions(-) diff --git a/includes/class-spacefast-client.php b/includes/class-spacefast-client.php index 84de1f2..a83295b 100644 --- a/includes/class-spacefast-client.php +++ b/includes/class-spacefast-client.php @@ -309,23 +309,6 @@ public function resume_static_upload( string $version_id ): array { ); } - /** - * Ask Spacefast to finalize an uploaded version. Auto-finalize normally does - * this when the last upload lands; an explicit request joins the same - * finalize operation, so a lost completion signal cannot strand the version. - * - * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} - */ - public function finalize_static_version( string $version_id ): array { - $settings = Spacefast_Settings::get(); - return $this->request( - 'POST', - '/v1/spaces/' . rawurlencode( $settings['space_id'] ) . '/versions/' - . rawurlencode( $version_id ) . '/finalize?async=1', - array( 'channel' => 'live' ) - ); - } - /** * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index 3f11884..ca94d0e 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -274,14 +274,33 @@ private static function poll_version( array $state, Spacefast_Client $client ): if ( $polls > 300 ) { throw new RuntimeException( 'Spacefast did not finish publishing the version.' ); } - if ( empty( $state['finalize_requested'] ) ) { - $finalize = $client->finalize_static_version( (string) $state['version_id'] ); - if ( ! $finalize['ok'] && $finalize['retryable'] ) { - return self::retry_or_throw( $state, $finalize, 'finalizing' ); + if ( empty( $state['completion_confirmed'] ) ) { + // Ask Spacefast which files it still lacks. When none are missing, the + // refresh runs the same completion the runtime's upload callback runs, + // so a lost callback cannot strand the version; when some are, upload + // them before waiting. + $refresh = $client->resume_static_upload( (string) $state['version_id'] ); + if ( ! $refresh['ok'] && $refresh['retryable'] ) { + return self::retry_or_throw( $state, $refresh, 'finalizing' ); } - // A refusal here usually means auto-finalize already owns the version; - // the status poll below reports whatever it settles to. - $state['finalize_requested'] = true; + $missing = $refresh['ok'] ? ( $refresh['data']['upload'] ?? null ) : null; + if ( is_array( $missing ) && ! empty( $missing['targets'] ) ) { + $state['phase'] = 'uploading'; + $state['upload'] = $missing; + $state['next_target'] = 0; + $state['retries'] = 0; + update_option( self::OPTION, $state, false ); + return array( + 'done' => false, + 'version_id' => (string) $state['version_id'], + 'uploaded' => (int) $state['uploaded'], + 'total' => (int) $state['total'], + 'status' => 'uploading', + ); + } + // A refusal means the version already left the draft states; the + // status poll below reports what it settled to. + $state['completion_confirmed'] = true; $state['retries'] = 0; update_option( self::OPTION, $state, false ); } diff --git a/readme.txt b/readme.txt index 11ba613..8de9749 100644 --- a/readme.txt +++ b/readme.txt @@ -33,7 +33,7 @@ OAuth access is limited to the Team you authorize and the mode you choose. WordP = 0.5.8 = * Retry transient upload, upload-refresh, and finalize failures in place instead of restarting the whole export as a new version. -* Ask Spacefast to finalize once every file has uploaded, so activation no longer depends on the upload-completion signal alone. +* Once every file has uploaded, ask Spacefast which files it still lacks, upload any it names, and let that check trigger activation instead of depending on the upload-completion signal alone. = 0.5.7 = * Include public custom post type archive routes in Simply Static exports. diff --git a/tests/behavior.php b/tests/behavior.php index ab3a2c9..6fb4a3d 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -927,6 +927,10 @@ static function ( string $url, array $args ) use ( &$static_requests ): array { ); } if ( str_contains( $url, '/uploads/resume' ) ) { + // The completion check after the last page finds nothing missing. + if ( count( array_filter( $static_requests, static fn ( array $request ): bool => str_contains( $request[0], '/uploads/resume' ) ) ) > 1 ) { + return response( 200, array( 'upload' => null ) ); + } return response( 200, array( @@ -1099,8 +1103,8 @@ static function ( string $url, array $args ) use ( &$retry_requests, $retry_targ if ( 'GET' === $args['method'] ) { return response( 200, array( 'id' => 'ver_retry', 'status' => 'ready', 'isCurrentProduction' => true ) ); } - if ( str_contains( $url, '/finalize' ) ) { - return response( 202, array( 'id' => 'ver_retry', 'status' => 'finalizing' ) ); + if ( str_contains( $url, '/uploads/resume' ) ) { + return response( 200, array( 'upload' => null ) ); } return response( 201, @@ -1131,12 +1135,17 @@ static function () use ( &$retry_uploads ): array { $retry_requests, static fn ( array $request ): bool => 'POST' === $request[1] && str_ends_with( $request[0], '/versions' ) ); +$retry_confirms = array_filter( + $retry_requests, + static fn ( array $request ): bool => str_contains( $request[0], '/versions/ver_retry/uploads/resume' ) +); $retry_finalizes = array_filter( $retry_requests, - static fn ( array $request ): bool => str_contains( $request[0], '/versions/ver_retry/finalize?async=1' ) + static fn ( array $request ): bool => str_contains( $request[0], '/finalize' ) ); check( 1 === count( $retry_creates ), 'a retried publish never declares a second version' ); -check( 1 === count( $retry_finalizes ), 'a finished upload asks Spacefast to finalize once' ); +check( 1 === count( $retry_confirms ), 'a finished upload confirms the missing set once before waiting' ); +check( 0 === count( $retry_finalizes ), 'the plugin never calls finalize, which its scopes do not grant' ); Spacefast_Static_Publisher::reset(); $expired_uploads = 0; @@ -1146,16 +1155,14 @@ static function ( string $url, array $args ) use ( &$expired_resumes, $retry_tar if ( 'GET' === $args['method'] ) { return response( 200, array( 'id' => 'ver_expired', 'status' => 'ready', 'isCurrentProduction' => true ) ); } - if ( str_contains( $url, '/finalize' ) ) { - return response( 202, array( 'id' => 'ver_expired', 'status' => 'finalizing' ) ); - } $upload = array( 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), 'targets' => array( $retry_target ), ); if ( str_contains( $url, '/uploads/resume' ) ) { $expired_resumes++; - return 1 === $expired_resumes ? response( 503 ) : response( 200, array( 'upload' => $upload ) ); + if ( 1 === $expired_resumes ) return response( 503 ); + return response( 200, array( 'upload' => 2 === $expired_resumes ? $upload : null ) ); } return response( 201, array( 'versionId' => 'ver_expired', 'upload' => $upload ) ); }, @@ -1171,6 +1178,36 @@ static function () use ( &$expired_uploads ): array { $expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); check( 2 === $expired_uploads && 1 === $expired_step['uploaded'], 'the refreshed target uploads' ); +Spacefast_Static_Publisher::reset(); +$missing_uploads = 0; +$missing_resumes = 0; +$missing_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( &$missing_resumes, $retry_target ): array { + if ( 'GET' === $args['method'] ) { + return response( 200, array( 'id' => 'ver_missing', 'status' => 'ready', 'isCurrentProduction' => true ) ); + } + $upload = array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ); + if ( str_contains( $url, '/uploads/resume' ) ) { + $missing_resumes++; + return response( 200, array( 'upload' => 1 === $missing_resumes ? $upload : null ) ); + } + return response( 201, array( 'versionId' => 'ver_missing', 'upload' => $upload ) ); + }, + static function () use ( &$missing_uploads ): array { + $missing_uploads++; + return response( 204 ); + } +); +for ( $i = 0; $i < 6; $i++ ) { + $missing_step = Spacefast_Static_Publisher::step( $archive, $missing_client ); + if ( $missing_step['done'] ) break; +} +check( true === $missing_step['done'] && 2 === $missing_uploads, 'a file the server still lacks is uploaded again before waiting' ); +check( 2 === $missing_resumes, 'the completion check runs again after the missing file lands' ); + Spacefast_Static_Publisher::reset(); $rejecting_client = new Spacefast_Client( static function ( string $url, array $args ) use ( $retry_target ): array { From 9f1943049ee918f715ecba6168744b21d79e7051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 2 Oct 2026 17:49:06 +0000 Subject: [PATCH 4/5] Back off between transient publish retries Simply Static runs background steps back to back, so five retries of a fast 503 or connection error were spent within a second. Schedule each retry with an exponential delay (2s doubling, capped at 60s, eight attempts, about four minutes), and let steps inside the wait hold for up to a second without calling the API. --- includes/class-spacefast-static-publisher.php | 31 +++++++++++++------ readme.txt | 2 +- tests/behavior.php | 12 +++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index ca94d0e..a98739f 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -4,10 +4,12 @@ final class Spacefast_Static_Publisher { const OPTION = 'spacefast_wordpress_publish_state'; - // Consecutive transient failures tolerated before a publish gives up. Each - // retry is one background step, so a brief outage no longer restarts the - // whole export as a new version. - const MAX_TRANSIENT_RETRIES = 5; + // Consecutive transient failures tolerated before a publish gives up, with + // an exponential wait between them (2s doubling, capped at a minute, about + // four minutes in all). Simply Static runs background steps back to back, + // so without the wait every retry would land within the same second. + const MAX_TRANSIENT_RETRIES = 8; + const MAX_RETRY_DELAY_SECONDS = 60; /** * Advance one bounded step of a Simply Static publish. @@ -26,11 +28,21 @@ public static function step( $client = $client ?? new Spacefast_Client(); $state = get_option( self::OPTION, array() ); $state = is_array( $state ) ? $state : array(); - if ( - (string) ( $state['archive_dir'] ?? '' ) === $root - && (string) ( $state['publish_mode'] ?? '' ) === $publish_mode - && 'finalizing' === ( $state['phase'] ?? '' ) - ) { + $resuming = (string) ( $state['archive_dir'] ?? '' ) === $root + && (string) ( $state['publish_mode'] ?? '' ) === $publish_mode; + $wait = (int) ( $state['retry_at'] ?? 0 ) - time(); + if ( $resuming && $wait > 0 ) { + // Hold the background step briefly instead of spinning it. + sleep( min( $wait, 1 ) ); + return array( + 'done' => false, + 'version_id' => (string) ( $state['version_id'] ?? '' ), + 'uploaded' => (int) ( $state['uploaded'] ?? 0 ), + 'total' => (int) ( $state['total'] ?? 0 ), + 'status' => 'finalizing' === ( $state['phase'] ?? '' ) ? 'finalizing' : 'uploading', + ); + } + if ( $resuming && 'finalizing' === ( $state['phase'] ?? '' ) ) { return self::poll_version( $state, $client ); } @@ -358,6 +370,7 @@ private static function retry_or_throw( array $state, array $result, string $sta throw new RuntimeException( $result['message'] ); } $state['retries'] = $retries; + $state['retry_at'] = time() + min( self::MAX_RETRY_DELAY_SECONDS, 2 ** $retries ); update_option( self::OPTION, $state, false ); return array( 'done' => false, diff --git a/readme.txt b/readme.txt index 8de9749..a4ceb22 100644 --- a/readme.txt +++ b/readme.txt @@ -32,7 +32,7 @@ OAuth access is limited to the Team you authorize and the mode you choose. WordP == Changelog == = 0.5.8 = -* Retry transient upload, upload-refresh, and finalize failures in place instead of restarting the whole export as a new version. +* Retry transient upload and upload-refresh failures in place, with exponential backoff for about four minutes, instead of restarting the whole export as a new version. * Once every file has uploaded, ask Spacefast which files it still lacks, upload any it names, and let that check trigger activation instead of depending on the upload-completion signal alone. = 0.5.7 = diff --git a/tests/behavior.php b/tests/behavior.php index 6fb4a3d..105d2a4 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -220,6 +220,12 @@ function response( int $status, array $data = array() ): array { ); } +function skip_publish_retry_wait(): void { + $state = get_option( Spacefast_Static_Publisher::OPTION, array() ); + $state['retry_at'] = 0; + update_option( Spacefast_Static_Publisher::OPTION, $state ); +} + function paginated_response( array $data, ?string $next_cursor = null ): array { return array( 'response' => array( 'code' => 200 ), @@ -1125,6 +1131,11 @@ static function () use ( &$retry_uploads ): array { $retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); check( false === $retry_step['done'] && 0 === $retry_step['uploaded'], 'a transient upload failure keeps the publish running' ); check( 'ver_retry' === $retry_step['version_id'], 'a transient upload failure keeps the same Spacefast version' ); +$retry_state = get_option( Spacefast_Static_Publisher::OPTION, array() ); +check( $retry_state['retry_at'] > time(), 'a transient failure schedules its retry instead of firing it at once' ); +$retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); +check( 1 === $retry_uploads && 0 === $retry_step['uploaded'], 'a step inside the retry wait sends nothing' ); +skip_publish_retry_wait(); $retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); check( 1 === $retry_step['uploaded'] && 2 === $retry_uploads, 'the next step retries the same upload target' ); $retry_step = Spacefast_Static_Publisher::step( $archive, $retry_client ); @@ -1173,6 +1184,7 @@ static function () use ( &$expired_uploads ): array { ); $expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); check( false === $expired_step['done'] && 1 === $expired_resumes, 'an expired upload session asks for fresh targets' ); +skip_publish_retry_wait(); $expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); check( 2 === $expired_resumes && 1 === $expired_uploads, 'a failed refresh is retried before any stale target is sent again' ); $expired_step = Spacefast_Static_Publisher::step( $archive, $expired_client ); From 1cb3ddd281db4b3b8ca8270a5e7842ceac3a1706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 2 Oct 2026 17:50:36 +0000 Subject: [PATCH 5/5] Bound the completion check's re-upload rounds A runtime ledger that keeps reporting a file as missing after a 2xx PUT sent the publish back to uploading forever: begin_finalizing resets the poll cap and every refresh extends the draft TTL. Give up after three rounds. --- includes/class-spacefast-static-publisher.php | 8 +++++++ tests/behavior.php | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index a98739f..0e24c12 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -10,6 +10,9 @@ final class Spacefast_Static_Publisher { // so without the wait every retry would land within the same second. const MAX_TRANSIENT_RETRIES = 8; const MAX_RETRY_DELAY_SECONDS = 60; + // Upload rounds the completion check may request before the publish gives + // up on files the runtime keeps reporting as missing. + const MAX_COMPLETION_ROUNDS = 3; /** * Advance one bounded step of a Simply Static publish. @@ -297,6 +300,11 @@ private static function poll_version( array $state, Spacefast_Client $client ): } $missing = $refresh['ok'] ? ( $refresh['data']['upload'] ?? null ) : null; if ( is_array( $missing ) && ! empty( $missing['targets'] ) ) { + $rounds = (int) ( $state['completion_rounds'] ?? 0 ) + 1; + if ( $rounds > self::MAX_COMPLETION_ROUNDS ) { + throw new RuntimeException( 'Spacefast kept reporting uploaded files as missing.' ); + } + $state['completion_rounds'] = $rounds; $state['phase'] = 'uploading'; $state['upload'] = $missing; $state['next_target'] = 0; diff --git a/tests/behavior.php b/tests/behavior.php index 105d2a4..468f448 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -1220,6 +1220,30 @@ static function () use ( &$missing_uploads ): array { check( true === $missing_step['done'] && 2 === $missing_uploads, 'a file the server still lacks is uploaded again before waiting' ); check( 2 === $missing_resumes, 'the completion check runs again after the missing file lands' ); +Spacefast_Static_Publisher::reset(); +$stuck_client = new Spacefast_Client( + static function ( string $url, array $args ) use ( $retry_target ): array { + $upload = array( + 'summary' => array( 'upload' => 1, 'reused' => 0, 'ignored' => 0 ), + 'targets' => array( $retry_target ), + ); + if ( str_contains( $url, '/uploads/resume' ) ) { + return response( 200, array( 'upload' => $upload ) ); + } + return response( 201, array( 'versionId' => 'ver_stuck', 'upload' => $upload ) ); + }, + static fn (): array => response( 204 ) +); +$stuck = false; +try { + for ( $i = 0; $i < 20; $i++ ) { + Spacefast_Static_Publisher::step( $archive, $stuck_client ); + } +} catch ( RuntimeException $error ) { + $stuck = 'Spacefast kept reporting uploaded files as missing.' === $error->getMessage(); +} +check( $stuck, 'a file the runtime never records ends the publish after bounded upload rounds' ); + Spacefast_Static_Publisher::reset(); $rejecting_client = new Spacefast_Client( static function ( string $url, array $args ) use ( $retry_target ): array {