@@ -46,6 +46,36 @@ class Atmosphere {
4646 */
4747 private const META_PUBLISH_ATTEMPTS = '_atmosphere_publish_attempts ' ;
4848
49+ /**
50+ * Post meta key tracking how many delayed retries a failed
51+ * publish/update cron worker has scheduled for the post.
52+ *
53+ * Deleted on success, on a permanent (non-retryable) failure, and
54+ * when the retry ladder is exhausted, so the next fresh save always
55+ * starts with a full retry budget.
56+ *
57+ * @var string
58+ */
59+ private const META_PUBLISH_RETRIES = '_atmosphere_publish_retries ' ;
60+
61+ /**
62+ * Backoff ladder for transient publish/update failures, in seconds.
63+ *
64+ * `wp_schedule_single_event()` is one-shot: without a re-queue, a
65+ * single PDS 5xx / rate limit / network blip permanently drops the
66+ * post from Bluesky with no operator-visible trace. One entry per
67+ * retry — three attempts spread over ~21 minutes rides out PDS
68+ * restarts and rate-limit windows without hammering a struggling
69+ * server.
70+ *
71+ * @var int[]
72+ */
73+ private const PUBLISH_RETRY_DELAYS = array (
74+ MINUTE_IN_SECONDS ,
75+ 5 * MINUTE_IN_SECONDS ,
76+ 15 * MINUTE_IN_SECONDS ,
77+ );
78+
4979 /**
5080 * Post meta marker set when remote records were removed because a
5181 * previously public post left public visibility.
@@ -679,6 +709,16 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po
679709 try {
680710 \do_action ( 'atmosphere_publishing ' , $ post );
681711
712+ /*
713+ * A status transition is fresh user intent, so it starts a
714+ * new retry budget. Without this, a counter stranded by a
715+ * dead retry event (disconnect cleared the queue, the post
716+ * was trashed mid-ladder, a cron event was lost) would
717+ * silently shrink — or zero out — the ladder of the next
718+ * publish attempt.
719+ */
720+ \delete_post_meta ( $ post ->ID , self ::META_PUBLISH_RETRIES );
721+
682722 if ( $ is_publishable ) {
683723 \wp_clear_scheduled_hook ( 'atmosphere_delete_post ' , array ( $ post ->ID ) );
684724 }
@@ -1457,6 +1497,7 @@ static function ( int $post_id ): void {
14571497 ? Publisher::update_post ( $ post )
14581498 : Publisher::publish_post ( $ post );
14591499 self ::log_cron_error ( 'publish_post ' , $ post_id , $ result );
1500+ self ::maybe_schedule_publish_retry ( 'atmosphere_publish_post ' , $ post_id , $ result );
14601501 if ( ! \is_wp_error ( $ result ) ) {
14611502 self ::clear_visibility_cleanup_marker ( $ post );
14621503 }
@@ -1481,6 +1522,7 @@ static function ( int $post_id ): void {
14811522 ? Publisher::update_post ( $ post )
14821523 : Publisher::publish_post ( $ post );
14831524 self ::log_cron_error ( 'update_post ' , $ post_id , $ result );
1525+ self ::maybe_schedule_publish_retry ( 'atmosphere_update_post ' , $ post_id , $ result );
14841526 if ( ! \is_wp_error ( $ result ) ) {
14851527 self ::clear_visibility_cleanup_marker ( $ post );
14861528 }
@@ -1851,6 +1893,142 @@ public static function log_reconcile_cleanup_error( int $post_id, $result ): voi
18511893 self ::log_cron_error ( 'reconcile_cleanup ' , $ post_id , $ result );
18521894 }
18531895
1896+ /**
1897+ * Re-queue a failed publish/update cron worker with backoff.
1898+ *
1899+ * Mirrors the comment parent-defer pattern ({@see self::defer_for_parent()}):
1900+ * a per-object attempt counter in meta, a bounded ladder, and a
1901+ * one-shot re-schedule of the same hook + args. The worker re-checks
1902+ * post state when the retry fires, so a post that was unpublished or
1903+ * disabled in the meantime routes to cleanup instead of publishing.
1904+ *
1905+ * Success and permanent failures clear the counter so the next
1906+ * fresh save starts with a full retry budget.
1907+ *
1908+ * @param string $hook Cron hook to re-schedule (`atmosphere_publish_post` or `atmosphere_update_post`).
1909+ * @param int $post_id Post ID the worker ran for.
1910+ * @param mixed $result Publisher result: array on success, `WP_Error` on failure.
1911+ */
1912+ private static function maybe_schedule_publish_retry ( string $ hook , int $ post_id , $ result ): void {
1913+ if ( ! \is_wp_error ( $ result ) || ! self ::is_transient_publish_error ( $ result ) ) {
1914+ \delete_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES );
1915+ return ;
1916+ }
1917+
1918+ /**
1919+ * Filters the backoff ladder for transient publish/update failures.
1920+ *
1921+ * One entry per retry, in seconds — the ladder's length IS the
1922+ * retry budget. Return an empty array to disable retries, or a
1923+ * longer array to raise the budget (the same knob covers both,
1924+ * so the delay schedule and the attempt cap cannot contradict
1925+ * each other).
1926+ *
1927+ * @since unreleased
1928+ *
1929+ * @param int[] $delays Retry delays in seconds. Default 60, 300, 900.
1930+ */
1931+ $ delays = \apply_filters ( 'atmosphere_publish_retry_delays ' , self ::PUBLISH_RETRY_DELAYS );
1932+ $ delays = \array_values (
1933+ \array_filter (
1934+ \array_map ( 'intval ' , (array ) $ delays ),
1935+ static fn ( int $ delay ): bool => $ delay > 0
1936+ )
1937+ );
1938+
1939+ $ attempts = (int ) \get_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES , true );
1940+
1941+ if ( $ attempts >= \count ( $ delays ) ) {
1942+ /*
1943+ * Ladder exhausted. Clear the counter so a future fresh save
1944+ * gets a new budget, and leave a breadcrumb — this is the
1945+ * point where a post has definitively failed to reach the
1946+ * PDS despite retries. No breadcrumb when the filter
1947+ * disabled retries outright: "giving up after 0 retries"
1948+ * would misread as a failure of the ladder the operator
1949+ * deliberately switched off.
1950+ */
1951+ \delete_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES );
1952+
1953+ if ( ! empty ( $ delays ) ) {
1954+ debug_log (
1955+ \sprintf (
1956+ '%s %d: giving up after %d retries (%s) ' ,
1957+ $ hook ,
1958+ $ post_id ,
1959+ $ attempts ,
1960+ $ result ->get_error_code ()
1961+ )
1962+ );
1963+ }
1964+ return ;
1965+ }
1966+
1967+ \update_post_meta ( $ post_id , self ::META_PUBLISH_RETRIES , $ attempts + 1 );
1968+ \wp_schedule_single_event (
1969+ \time () + $ delays [ $ attempts ],
1970+ $ hook ,
1971+ array ( $ post_id )
1972+ );
1973+ }
1974+
1975+ /**
1976+ * Whether a publish failure is worth retrying.
1977+ *
1978+ * Retry-by-default with a bounded ladder: a wrongly-retried
1979+ * permanent error costs at most three extra requests, while a
1980+ * wrongly-dropped transient error silently loses the post. Only
1981+ * failures that are deterministic — locally-generated preconditions
1982+ * or a PDS 4xx that will reject the identical payload again — are
1983+ * excluded.
1984+ *
1985+ * @param \WP_Error $error Failure returned by the Publisher.
1986+ * @return bool True when a retry has a chance of succeeding.
1987+ */
1988+ private static function is_transient_publish_error ( \WP_Error $ error ): bool {
1989+ $ permanent_codes = array (
1990+ 'atmosphere_post_not_publishable ' ,
1991+ 'atmosphere_not_connected ' ,
1992+ 'atmosphere_needs_reauth ' ,
1993+ 'atmosphere_missing_tid ' ,
1994+ 'atmosphere_invalid_pre_apply_writes_return ' ,
1995+ 'atmosphere_invalid_pre_apply_writes_response ' ,
1996+ 'atmosphere_invalid_pre_upload_blob_return ' ,
1997+ 'atmosphere_decrypt ' ,
1998+ 'atmosphere_did_mismatch ' ,
1999+
2000+ /*
2001+ * Never retry a failed thread rollback: the orphan manifest
2002+ * records live partial records on the PDS, and a retried
2003+ * publish would mint fresh TIDs next to them — a duplicate,
2004+ * user-visible copy of the post. This state needs operator
2005+ * attention (see Post::META_ORPHAN_RECORDS), not another
2006+ * attempt.
2007+ */
2008+ 'atmosphere_thread_rollback_failed ' ,
2009+ );
2010+
2011+ if ( \in_array ( $ error ->get_error_code (), $ permanent_codes , true ) ) {
2012+ return false ;
2013+ }
2014+
2015+ $ data = $ error ->get_error_data ();
2016+ $ status = \is_array ( $ data ) && isset ( $ data ['status ' ] ) ? (int ) $ data ['status ' ] : 0 ;
2017+
2018+ /*
2019+ * No status means the request never completed (DNS, TLS,
2020+ * timeout) — the classic transient class. 408/429/5xx are the
2021+ * server-side equivalents. Any other 4xx is a deterministic
2022+ * rejection of this exact payload and would fail identically
2023+ * on every attempt.
2024+ */
2025+ if ( 0 === $ status ) {
2026+ return true ;
2027+ }
2028+
2029+ return 408 === $ status || 429 === $ status || $ status >= 500 ;
2030+ }
2031+
18542032 /**
18552033 * Roll back a successful publish if the comment became ineligible
18562034 * during the in-flight applyWrites.
0 commit comments