Skip to content

fix(order-sync): Stop retrying after a terminal 401/403, back off on 429, and tell the merchant - #1

Merged
ackm04 merged 2 commits into
mainfrom
fix/order-sync-terminal-403
Oct 4, 2026
Merged

ackm04 merged 2 commits into
mainfrom
fix/order-sync-terminal-403

Conversation

@ackm04

@ackm04 ackm04 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Summary

One store's API key lacked the orders:write scope. The plugin sent POST /v1/integrations/woocommerce/order-sync about every 3 seconds: 444 refusals (HTTP 403) in 50 minutes, and the merchant was never told.

Nothing in the plugin looped. Action Scheduler does not re-run a callback that returns. Every order trigger (creation, each status change, each admin save) pushed again with a key that could never succeed, because a refusal was treated like a timeout.

New Tack_Sync_Gate applies the temporary/terminal split the TackQuote API uses:

  • Terminal: a 401 or 403 carrying TackQuote's own JSON error body. That means statusCode equal to the HTTP status and a non-empty code (insufficient_scope, SUBSCRIPTION_INACTIVE, an invalid or revoked key).
    • Pushes stop, and a wp-admin notice-error names the missing scope or the billing step (Profile > Billing & Plan).
    • Sync re-probes at most once an hour; saving a different key lifts the block at once.
    • A 429 that carries the scope refusal stays terminal, held until max(Retry-After, 1 h), so the notice does not flicker.
  • Throttled: any other 429 waits for Retry-After (seconds or an HTTP-date), capped at one hour.
  • Temporary: everything else, including a 403 whose body is HTML or a proxy's JSON (a firewall or challenge page).
  • When a block lifts, orders modified since it began are re-queued through the normal worker. This is bounded to 200 orders and 30 days, logs when it truncates, and the sync key skips anything already accepted.
  • Only a 16-character sha256 prefix of the key is stored. uninstall.php removes the new option.

No wire-contract change. The plugin reads fields the API already sends. The server half (scope 403, then 429 with Retry-After, then a daily seller notice) is already live on api.tackquote.com.

Vendor sources:

  • Action Scheduler retry semantics and as_enqueue_async_action (Context7 /woocommerce/action-scheduler);
  • the wc_get_orders date_modified => '>' . ts form (Context7 /woocommerce/woocommerce, docs/features/orders/wc-get-orders.md);
  • WP_Error data and wp_remote_retrieve_header (developer.wordpress.org).

The readme has an Unreleased changelog entry. The version bump and tag are left to the owner.

Gates (php:8.2-cli via docker run --rm, through ~/bin/tack-heavy)

Gate Exit
php -l on every PHP file 0
php tests/run.php on this branch 0 (All checks passed)
Red-first: today's tests against pre-fix api client + order sync 255 (RED: classification and data checks fail)
Red-first: today's tests against pre-fix includes/ entirely 255 (RED)
Mutant: terminal no longer requires statusCode === status 1 (killed)
Mutant: 429 scope refusal treated as a plain throttle 1 (killed)
Mutant: lift() stops announcing the unblock 1 (killed)
Mutant: TERMINAL_REPROBE 3600 -> 3 1 (killed)
Mutant: run_sync no longer consults the gate 1 (killed)
Mutant: key-change hook not registered 1 (killed)

One equivalent mutant was omitted: dropping the json term cannot change an outcome, because statusCode is only non-zero when the body parsed as JSON.

🤖 Generated with Claude Code

ackm04 and others added 2 commits October 5, 2026 00:22
…29, tell the merchant

One store's key lacked orders:write. Every order trigger (creation, each status
change, each admin save) pushed again with the same key and was refused: 444
HTTP 403s in 50 minutes, about one every 3 seconds, and the merchant saw nothing.
Nothing looped on its own (Action Scheduler does not re-run a callback that
returns); a refusal was simply forgotten between triggers and treated like a
timeout.

Tack_Sync_Gate remembers it, using the temporary/terminal split the TackQuote
API applies to the vendors it calls:

- TERMINAL: 401 or 403 carrying TackQuote's JSON error body (insufficient_scope,
  SUBSCRIPTION_INACTIVE, an invalid or revoked key). Pushes stop, a wp-admin
  error notice names the missing scope and the fix, one re-probe is allowed per
  hour (a renewed subscription heals without a key change), and saving a
  different key lifts the block at once.
- THROTTLED: 429 waits for Retry-After (delta-seconds or HTTP-date), capped at
  one hour.
- TEMPORARY: everything else, including a 401/403 whose body is not JSON (a
  firewall or challenge page in front of the API).

The API client now keeps status, code, requiredScopes and Retry-After on the
WP_Error data (third constructor argument, per core). The error code is
unchanged. No wire-contract change: it reads fields the API already sends.
An order skipped while blocked stays unmarked, so its next trigger pushes it.
Only a 16-character sha256 prefix of the key is stored, never the key itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… only TackQuote's own JSON is terminal, re-queue on unblock

From Fable's review of 01e33a9 (APPROVE with changes).

- A 429 carrying insufficient_scope or requiredScopes is TackQuote throttling
  the same scope refusal. It stays terminal, with
  until = max(now + Retry-After, now + TERMINAL_REPROBE), so the wp-admin
  notice no longer flickers off each throttle window.
- Terminal only for TackQuote's own error body: JSON whose statusCode equals
  the HTTP status and which carries a non-empty code. A proxy or WAF answering
  403 with JSON of its own is temporary. The client now carries statusCode.
- Re-queue after unblock. A block keeps `since` (when pushes started being
  held) across re-records. When it lifts (a push succeeds, a different key is
  saved via update_option_tack_quotes_api_key, or a stale-key block is
  dropped), Tack_Sync_Gate fires tack_quotes_order_sync_unblocked.
  Tack_Order_Sync schedules one async job, which runs wc_get_orders
  date_modified '>' since (the documented form, woocommerce
  docs/features/orders/wc-get-orders.md) and queues each order through the
  normal worker. It is bounded to 200 orders and 30 days, logs when it
  truncates, and does nothing when order sync is off.
- Notice copy for a lapsed subscription: "Profile > Billing & Plan".
- Retry-After digits are parsed with preg_match, not ctype_digit.
- The notice links Tack_Settings::PAGE_SLUG directly. The bootstrap always
  loads it, so the dead fallback slug is gone.
- readme.txt: "Unreleased" changelog entry. The version bump and tag are left
  to the owner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ackm04
ackm04 merged commit 23b899c into main Oct 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant