Commit 22e5c9523cf for woocommerce

commit 22e5c9523cffbe63b764bfc7d4230468d5a4a528
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Aug 31 18:33:24 2026 +0300

    Retry failed WC Tracker snapshots on the next scheduled run (#68120)

    * fix: Retry failed tracker snapshots on the next scheduled run

    WC_Tracker stamped woocommerce_tracker_last_send before posting the
    snapshot and posted with blocking => false, so the response was never
    inspected. Any transient network or server failure silently lost that
    week's snapshot, because the daily tracker action found last_send fresh
    and skipped sending for the rest of the week.

    Make the request blocking with a 10 second timeout and record last_send
    only once the snapshot is accepted with a 2xx status. A transient
    failure (transport error, 408/425/429, 5xx) now leaves the snapshot
    pending, and the existing daily tracker action retries it on its next
    run. Consecutive failures are counted in woocommerce_tracker_send_failures
    and the snapshot is abandoned after three, or immediately when the server
    rejects it with another 4xx, so a persistent outage does not retry
    forever. Failures are logged under the woocommerce-tracker source without
    the payload.

    The request now waits for the response, bounded by the timeout, where it
    previously did not wait for one; callers that run it synchronously from
    an admin request should expect that.

    No public signature, hook or scheduling change is needed: the daily
    Action Scheduler action already provides the retry cadence. The PHPStan
    baseline entry for the old request arguments no longer matches and is
    removed rather than replaced, as the false return of wp_json_encode is
    now handled explicitly.

    Refs #38574

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: Keep the tracker override guard and clean up retry state

    Recording woocommerce_tracker_last_send only on a successful delivery
    weakened the one hour guard between override sends: after a failed
    attempt, a second click within the hour posted the snapshot again. The
    guard exists specifically to stop double-click duplicates, and the
    tracking service does not deduplicate on its side.

    Record woocommerce_tracker_last_attempt before every send and make the
    override guard use the later of the attempt time and the recorded send
    time, so overlapping or repeated override sends are suppressed exactly
    as before while the weekly gate still keys on successful delivery.

    Also log the posted snapshot size with each failure and name the case
    where the service rejects the snapshot as too large (HTTP 413), since
    that is a persistent failure that retrying cannot fix, and discard the
    consecutive failure counter when usage tracking is turned off so it
    does not carry over to a later opt-in.

    Refs #38574

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: Record tracker encode failures and respect opt-out mid-request

    A snapshot that wp_json_encode() cannot encode, for example because a
    woocommerce_tracker_data filter injected INF or NAN, returned before
    any bookkeeping. With the send time only recorded on success, such a
    snapshot was rebuilt and dropped on every daily run with no log entry
    and no cap. Route it through the shared failure path as a non-retryable
    failure so it is logged once and skipped until the next interval.

    Also stop creating the consecutive failure counter when usage tracking
    was turned off while a request was in flight, so opting out does not
    leave retry state behind.

    Refs #38574

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: Bound tracker retries for any truthy usage-tracking value

    `WC_Tracker::record_send_failure()` persisted the consecutive-failure
    counter only when `woocommerce_allow_tracking` held the literal string
    `'yes'`, while every other tracking-on decision in core resolves that
    option through `wc_string_to_bool()`.

    `wc_string_to_bool()` also accepts `1`, `'1'` and `'true'`, and the
    Admin Options REST endpoint carries `woocommerce_allow_tracking` on
    its frozen legacy allowlist while passing the raw decoded JSON value
    straight to `update_option()`. A body of
    `{"woocommerce_allow_tracking": true}` therefore stores `'1'`. On such
    a site the send still ran, but the counter was never written: the
    failure count recomputed from zero on every run, so
    `MAX_CONSECUTIVE_SEND_FAILURES` was never reached and no send time was
    recorded for a retryable failure. The daily action then rebuilt and
    re-posted the full snapshot every day for the whole duration of an
    outage, unbounded — defeating the retry cap this branch exists to add.

    Resolve the option the way the rest of core does. The accompanying
    test runs three consecutive retryable failures end to end without
    seeding the counter, over both `'yes'` and `'1'`; every pre-existing
    give-up test seeded the counter first and so never reached this
    branch.

    Refs #38574

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test: Stop the tracker pending-snapshot test straddling a second

    `test_retryable_failure_keeps_snapshot_pending()` evaluated
    `strtotime( '-2 weeks' )` twice: once to seed
    `woocommerce_tracker_last_send`, and again in the assertion that the
    option is unchanged.

    `send_tracking_data()` runs between those two calls, and it builds the
    whole snapshot through `get_tracking_data()` — a multi-query aggregate
    pass. Whenever that work straddles a second boundary the two
    timestamps differ by one and the assertion fails, so the test command
    this branch documents did not pass reliably. Repeating the method ten
    times over its six data sets reproduced three failures in sixty
    executions, every one an off-by-one at that assertion.

    Capture the timestamp once and assert against the captured value. The
    other new tests already compare against `time()` through
    `assertEqualsWithDelta( ..., 5 )`, so this is the only method with the
    double-evaluation shape.

    Refs #38574

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: Record the tracker attempt time before building the snapshot

    The one-hour guard between override sends returns early on the later
    of `woocommerce_tracker_last_send` and
    `woocommerce_tracker_last_attempt`, but the `last_attempt` write it
    depends on happened after the snapshot was built.

    Before this branch, the equivalent write was the statement
    immediately after the guard, so the read-check-write window was one
    statement wide. Moving it below `get_tracking_data()` put an entire
    multi-query aggregate pass inside that window. Two override calls
    racing — a double-submitted form, or two tabs finishing the connect
    flow together — could both read the same stale timestamp, both pass
    the guard, and both post. That is exactly the duplicate the guard was
    added to prevent.

    Write the attempt time before building the snapshot, restoring the
    original window width. This narrows the race rather than closing it:
    two genuinely concurrent requests can still both pass, and the cost
    of that is a duplicate snapshot the tracking service tolerates. The
    encode-failure path now also stamps the attempt time, which is
    harmless — it gives up and records a send time regardless, and the
    guard reads the later of the two.

    Refs #38574

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * docs: Correct the record_send_result() send time docblock

    The docblock said the send time is recorded only once the snapshot is
    accepted, but `record_send_failure()` also writes
    `woocommerce_tracker_last_send` whenever it gives up — on a 413, an
    encode failure, or the third consecutive failure.

    That behaviour is deliberate: stamping the send time on abandonment
    re-arms the weekly interval so a snapshot that cannot succeed does not
    retry forever. Only the description was wrong, so correct the wording.

    Refs #38574

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-38574-tracker-retry-snapshots b/plugins/woocommerce/changelog/fix-38574-tracker-retry-snapshots
new file mode 100644
index 00000000000..fd75e9029a0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-38574-tracker-retry-snapshots
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Retry failed usage-tracking snapshot deliveries on the next scheduled run instead of silently dropping them for a week.
diff --git a/plugins/woocommerce/includes/class-wc-tracker.php b/plugins/woocommerce/includes/class-wc-tracker.php
index 97013e96567..63e5f4cb46c 100644
--- a/plugins/woocommerce/includes/class-wc-tracker.php
+++ b/plugins/woocommerce/includes/class-wc-tracker.php
@@ -38,6 +38,15 @@ class WC_Tracker {
 	 */
 	private static $api_url = 'https://tracking.woocommerce.com/v1/';

+	/**
+	 * Consecutive failed deliveries after which the current snapshot is abandoned.
+	 *
+	 * Retries happen on the daily tracker action, so this bounds retrying to a few days.
+	 *
+	 * @var int
+	 */
+	private const MAX_CONSECUTIVE_SEND_FAILURES = 3;
+
 	/**
 	 * Hook into cron event.
 	 */
@@ -69,29 +78,116 @@ class WC_Tracker {
 			}
 		} else {
 			// Make sure there is at least a 1 hour delay between override sends, we don't want duplicate calls due to double clicking links.
-			$last_send = self::get_last_send_time();
-			if ( $last_send && $last_send > strtotime( '-1 hours' ) ) {
+			$last_attempt = max( (int) self::get_last_send_time(), (int) get_option( 'woocommerce_tracker_last_attempt', 0 ) );
+			if ( $last_attempt > strtotime( '-1 hours' ) ) {
 				return;
 			}
 		}

-		// Update time first before sending to ensure it is set.
-		update_option( 'woocommerce_tracker_last_send', time() );
+		// Recorded before building the snapshot so overlapping override sends are still suppressed.
+		update_option( 'woocommerce_tracker_last_attempt', time(), false );

-		$params = self::get_tracking_data();
-		wp_safe_remote_post(
+		$body = wp_json_encode( self::get_tracking_data() );
+		if ( false === $body ) {
+			self::record_send_failure( false, 0, 'json_encode_failure', 0 );
+			return;
+		}
+
+		$response = wp_safe_remote_post(
 			self::$api_url,
 			array(
 				'method'      => 'POST',
-				'timeout'     => 45,
+				'timeout'     => 10,
 				'redirection' => 5,
 				'httpversion' => '1.0',
-				'blocking'    => false,
+				'blocking'    => true,
 				'headers'     => array( 'user-agent' => 'WooCommerceTracker/' . md5( esc_url_raw( home_url( '/' ) ) ) . ';' ),
-				'body'        => wp_json_encode( $params ),
+				'body'        => $body,
 				'cookies'     => array(),
 			)
 		);
+
+		self::record_send_result( $response, strlen( $body ) );
+	}
+
+	/**
+	 * Record the outcome of a delivery attempt.
+	 *
+	 * The send time is recorded after acceptance or permanent abandonment. A transient failure
+	 * remains pending for the next run.
+	 *
+	 * @param array|WP_Error $response   Response from wp_safe_remote_post().
+	 * @param int            $body_bytes Size of the posted snapshot.
+	 */
+	private static function record_send_result( $response, $body_bytes ): void {
+		$status = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
+
+		if ( 200 <= $status && 300 > $status ) {
+			update_option( 'woocommerce_tracker_last_send', time() );
+			delete_option( 'woocommerce_tracker_send_failures' );
+			return;
+		}
+
+		self::record_send_failure( self::is_retryable_status( $status ), $status, is_wp_error( $response ) ? (string) $response->get_error_code() : '', $body_bytes );
+	}
+
+	/**
+	 * Record a failed delivery attempt.
+	 *
+	 * Consecutive retryable failures are counted so a persistent outage does not retry forever;
+	 * a non-retryable failure or the last allowed attempt gives up on the current snapshot.
+	 * Retry state is only kept while tracking is still enabled, since opting out may have
+	 * happened while the request was in flight.
+	 *
+	 * @param bool   $retryable  Whether the next scheduled run should try again.
+	 * @param int    $status     HTTP status code, 0 when no response was received.
+	 * @param string $error_code Error code when no response was received.
+	 * @param int    $body_bytes Size of the snapshot.
+	 */
+	private static function record_send_failure( $retryable, $status, $error_code, $body_bytes ): void {
+		$failures = (int) get_option( 'woocommerce_tracker_send_failures', 0 ) + 1;
+		$give_up  = ! $retryable || self::MAX_CONSECUTIVE_SEND_FAILURES <= $failures;
+
+		if ( $give_up ) {
+			update_option( 'woocommerce_tracker_last_send', time() );
+			delete_option( 'woocommerce_tracker_send_failures' );
+		} elseif ( true === wc_string_to_bool( get_option( 'woocommerce_allow_tracking', 'no' ) ) ) {
+			update_option( 'woocommerce_tracker_send_failures', $failures, false );
+		}
+
+		if ( 413 === $status ) {
+			$message = 'WooCommerce tracker snapshot delivery failed; the snapshot is too large for the service and will not be retried.';
+		} elseif ( 'json_encode_failure' === $error_code ) {
+			$message = 'WooCommerce tracker snapshot could not be encoded and will not be retried.';
+		} elseif ( $give_up ) {
+			$message = 'WooCommerce tracker snapshot delivery failed; giving up until the next interval.';
+		} else {
+			$message = 'WooCommerce tracker snapshot delivery failed; it will be retried on the next run.';
+		}
+
+		wc_get_logger()->warning(
+			$message,
+			array(
+				'source'      => 'woocommerce-tracker',
+				'http_status' => $status,
+				'error_code'  => $error_code,
+				'failures'    => $failures,
+				'body_bytes'  => $body_bytes,
+			)
+		);
+	}
+
+	/**
+	 * Whether a delivery failure with the given status is worth retrying.
+	 *
+	 * Transport failures (status 0), rate limiting, timeouts and server errors are retried;
+	 * other client errors mean the snapshot itself was rejected.
+	 *
+	 * @param int $status HTTP status code, 0 for a transport failure.
+	 * @return bool
+	 */
+	private static function is_retryable_status( $status ) {
+		return 0 === $status || in_array( $status, array( 408, 425, 429 ), true ) || 500 <= $status;
 	}

 	/**
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 0c1ed0361c2..e91dcef4100 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -1805,6 +1805,7 @@ final class WooCommerce {
 		}
 		if ( false === wc_string_to_bool( $value ) ) {
 			as_unschedule_all_actions( 'woocommerce_tracker_send_event_wrapper', array(), 'woocommerce' );
+			delete_option( 'woocommerce_tracker_send_failures' );
 		} else {
 			$this->schedule_tracking_action();
 		}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 728372b7001..f4fefdc105b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -15828,12 +15828,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-tracker.php

-		-
-			message: '#^Parameter \#2 \$args of function wp_safe_remote_post expects array\{method\?\: string, timeout\?\: float, redirection\?\: int, httpversion\?\: string, user\-agent\?\: string, reject_unsafe_urls\?\: bool, blocking\?\: bool, headers\?\: array\|string, \.\.\.\}, array\{method\: ''POST'', timeout\: 45, redirection\: 5, httpversion\: ''1\.0'', blocking\: false, headers\: array\{user\-agent\: non\-falsy\-string\}, body\: non\-empty\-string\|false, cookies\: array\{\}\} given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/class-wc-tracker.php
-
 		-
 			message: '#^Property WC_Shipping_Method\:\:\$enabled \(string\) in isset\(\) is not nullable\.$#'
 			identifier: isset.property
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php b/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php
index d9213e4e30f..08a6fb04a03 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-tracker-test.php
@@ -98,6 +98,329 @@ class WC_Tracker_Test extends \WC_Unit_Test_Case {
 		$this->assertEquals( 'no', $tracking_data['wc_admin_disabled'] );
 	}

+	/**
+	 * @testdox Should send a blocking request with a short timeout so the response can be inspected.
+	 */
+	public function test_send_tracking_data_uses_blocking_request(): void {
+		$request_args = null;
+		$this->fake_tracker_response( 200, $request_args );
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertTrue( $request_args['blocking'], 'The tracker request must be blocking to read the response.' );
+		$this->assertSame( 10, $request_args['timeout'], 'The tracker request timeout should be short.' );
+	}
+
+	/**
+	 * @testdox Should record the send time and clear the failure counter after a successful delivery.
+	 */
+	public function test_successful_send_records_last_send_and_clears_failures(): void {
+		update_option( 'woocommerce_tracker_send_failures', 2 );
+		$this->fake_tracker_response( 204 );
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'A 2xx response should record the send time.' );
+		$this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'A 2xx response should clear the failure counter.' );
+	}
+
+	/**
+	 * @testdox Should not record the send time and should count the failure when delivery fails with a retryable error.
+	 *
+	 * @testWith [500]
+	 *           [503]
+	 *           [429]
+	 *           [408]
+	 *           [425]
+	 *           ["wp_error"]
+	 *
+	 * @param int|string $status HTTP status, or "wp_error" for a transport failure.
+	 */
+	public function test_retryable_failure_keeps_snapshot_pending( $status ): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+		$last_send = strtotime( '-2 weeks' );
+		update_option( 'woocommerce_tracker_last_send', $last_send );
+		$this->fake_tracker_response( $status );
+		$logger = $this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data();
+
+		$this->assertSame( $last_send, (int) get_option( 'woocommerce_tracker_last_send' ), 'A retryable failure must leave the last send time unchanged so the next daily run retries.' );
+		$this->assertSame( 1, (int) get_option( 'woocommerce_tracker_send_failures' ), 'A retryable failure should increment the failure counter.' );
+		$this->assertCount( 1, $logger->warnings, 'A failed delivery should be logged as a warning.' );
+		$this->assertSame( 'woocommerce-tracker', $logger->warnings[0]['source'] );
+	}
+
+	/**
+	 * @testdox Should give up on the snapshot when the server rejects it with a non-retryable status.
+	 *
+	 * @testWith [400]
+	 *           [403]
+	 *           [404]
+	 *           [410]
+	 *
+	 * @param int $status HTTP status.
+	 */
+	public function test_non_retryable_failure_records_last_send( int $status ): void {
+		update_option( 'woocommerce_tracker_send_failures', 1 );
+		$this->fake_tracker_response( $status );
+		$this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'A non-retryable failure should record the send time so the snapshot is not retried.' );
+		$this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+	}
+
+	/**
+	 * @testdox Should give up on the snapshot after the maximum number of consecutive failures.
+	 */
+	public function test_max_consecutive_failures_records_last_send(): void {
+		update_option( 'woocommerce_tracker_send_failures', 2 );
+		$this->fake_tracker_response( 503 );
+		$this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'The third consecutive failure should record the send time.' );
+		$this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+	}
+
+	/**
+	 * @testdox Should bound consecutive failures for any truthy value of the tracking option.
+	 *
+	 * @testWith ["yes"]
+	 *           ["1"]
+	 *
+	 * @param string $allow_tracking Stored value of the tracking option.
+	 */
+	public function test_consecutive_failures_are_bounded_for_any_truthy_tracking_value( string $allow_tracking ): void {
+		update_option( 'woocommerce_allow_tracking', $allow_tracking );
+		$requests = 0;
+		add_filter(
+			'pre_http_request',
+			function () use ( &$requests ) {
+				++$requests;
+				return array( 'response' => array( 'code' => 503 ) );
+			}
+		);
+		$this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data();
+		$this->assertSame( 1, (int) get_option( 'woocommerce_tracker_send_failures' ), 'The first failure should persist a count of one.' );
+
+		WC_Tracker::send_tracking_data();
+		$this->assertSame( 2, (int) get_option( 'woocommerce_tracker_send_failures' ), 'The second failure should persist a count of two.' );
+
+		WC_Tracker::send_tracking_data();
+
+		$this->assertSame( 3, $requests, 'Each daily run should retry while the snapshot is still pending.' );
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'The third consecutive failure should record the send time so the snapshot is abandoned.' );
+		$this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+	}
+
+	/**
+	 * @testdox Should retry on the next daily run after a failed delivery without waiting for the weekly interval.
+	 */
+	public function test_failed_send_is_retried_on_next_run(): void {
+		$requests = 0;
+		add_filter(
+			'pre_http_request',
+			function () use ( &$requests ) {
+				++$requests;
+				return 1 === $requests ? new WP_Error( 'http_request_failed', 'Timed out' ) : array( 'response' => array( 'code' => 200 ) );
+			}
+		);
+		$this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data();
+		WC_Tracker::send_tracking_data();
+
+		$this->assertSame( 2, $requests, 'The second run should retry because the first delivery did not record a send time.' );
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5 );
+	}
+
+	/**
+	 * @testdox Should suppress a second override send within an hour of a failed attempt.
+	 */
+	public function test_override_send_is_suppressed_within_an_hour_of_a_failed_attempt(): void {
+		$request_args = null;
+		$requests     = 0;
+		add_filter(
+			'pre_http_request',
+			function () use ( &$requests ) {
+				++$requests;
+				return new WP_Error( 'http_request_failed', 'Timed out' );
+			}
+		);
+		$this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data( true );
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertSame( 1, $requests, 'A failed override send must still block a second override send within the hour.' );
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_attempt' ), 5, 'Every attempt should record its time.' );
+		$this->assertFalse( get_option( 'woocommerce_tracker_last_send' ), 'A failed attempt must not record a send time.' );
+	}
+
+	/**
+	 * @testdox Should include the payload size in the failure log.
+	 */
+	public function test_failure_log_includes_payload_size(): void {
+		$this->fake_tracker_response( 503 );
+		$logger = $this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertGreaterThan( 0, $logger->warnings[0]['body_bytes'], 'The failure log should report the payload size.' );
+	}
+
+	/**
+	 * @testdox Should report a rejected oversized snapshot distinctly.
+	 */
+	public function test_too_large_snapshot_is_logged_as_such(): void {
+		$this->fake_tracker_response( 413 );
+		$logger = $this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertStringContainsString( 'too large', $logger->messages[0] );
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'An oversized snapshot is not retried.' );
+	}
+
+	/**
+	 * @testdox Should clear the failure counter when tracking is turned off.
+	 */
+	public function test_opting_out_clears_failure_counter(): void {
+		update_option( 'woocommerce_tracker_send_failures', 2 );
+
+		WC()->handle_tracking_setting_change( 'yes', 'no' );
+
+		$this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Opting out should discard pending retry state.' );
+	}
+
+	/**
+	 * @testdox Should treat a snapshot that cannot be encoded as a non-retryable failure.
+	 */
+	public function test_unencodable_snapshot_is_logged_and_not_retried(): void {
+		$requests = 0;
+		add_filter(
+			'pre_http_request',
+			function () use ( &$requests ) {
+				++$requests;
+				return array( 'response' => array( 'code' => 200 ) );
+			}
+		);
+		add_filter(
+			'woocommerce_tracker_data',
+			function ( $data ) {
+				$data['unencodable'] = INF;
+				return $data;
+			}
+		);
+		update_option( 'woocommerce_tracker_send_failures', 1 );
+		$logger = $this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertSame( 0, $requests, 'An unencodable snapshot must not be posted.' );
+		$this->assertSame( 'json_encode_failure', $logger->warnings[0]['error_code'] );
+		$this->assertEqualsWithDelta( time(), (int) get_option( 'woocommerce_tracker_last_send' ), 5, 'An unencodable snapshot should not be retried daily.' );
+		$this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Giving up should clear the failure counter.' );
+	}
+
+	/**
+	 * @testdox Should not record retry state when tracking was turned off while the request was in flight.
+	 */
+	public function test_retry_state_is_not_recorded_after_opt_out_during_request(): void {
+		update_option( 'woocommerce_allow_tracking', 'yes' );
+		add_filter(
+			'pre_http_request',
+			function () {
+				update_option( 'woocommerce_allow_tracking', 'no' );
+				return array( 'response' => array( 'code' => 503 ) );
+			}
+		);
+		$this->expect_tracker_warning();
+
+		WC_Tracker::send_tracking_data( true );
+
+		$this->assertFalse( get_option( 'woocommerce_tracker_send_failures' ), 'Retry state must not be created once tracking is off.' );
+	}
+
+	/**
+	 * Fake the tracker HTTP response.
+	 *
+	 * @param int|string $status       HTTP status code, or "wp_error" for a transport failure.
+	 * @param array|null $request_args Receives the request arguments by reference.
+	 */
+	private function fake_tracker_response( $status, &$request_args = null ): void {
+		add_filter(
+			'pre_http_request',
+			function ( $pre, $args ) use ( $status, &$request_args ) {
+				$request_args = $args;
+				if ( 'wp_error' === $status ) {
+					return new WP_Error( 'http_request_failed', 'Timed out' );
+				}
+				return array(
+					'headers'  => array(),
+					'body'     => '',
+					'response' => array(
+						'code'    => $status,
+						'message' => '',
+					),
+				);
+			},
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Inject a fake logger that records warning contexts.
+	 *
+	 * @return object Fake logger with public `warnings` (contexts) and `messages` arrays.
+	 */
+	private function expect_tracker_warning() {
+		$logger = new class() implements WC_Logger_Interface {
+			/**
+			 * Recorded warning contexts.
+			 *
+			 * @var array
+			 */
+			public $warnings = array();
+
+			/**
+			 * Recorded warning messages.
+			 *
+			 * @var array
+			 */
+			public $messages = array();
+
+			// phpcs:disable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UnusedFunctionParameter.Found
+			public function add( $handle, $message, $level = WC_Log_Levels::NOTICE ) {}
+			public function log( $level, $message, $context = array() ) {}
+			public function emergency( $message, $context = array() ) {}
+			public function alert( $message, $context = array() ) {}
+			public function critical( $message, $context = array() ) {}
+			public function error( $message, $context = array() ) {}
+			public function warning( $message, $context = array() ) {
+				$this->warnings[] = $context;
+				$this->messages[] = $message;
+			}
+			public function notice( $message, $context = array() ) {}
+			public function info( $message, $context = array() ) {}
+			public function debug( $message, $context = array() ) {}
+			// phpcs:enable
+		};
+		add_filter(
+			'woocommerce_logging_class',
+			static function () use ( $logger ) {
+				return $logger;
+			}
+		);
+		return $logger;
+	}
 	/**
 	 * @testDox Test the features compatibility data for plugin tracking data.
 	 */