Skip to content

Failing test: preload 4xx must not fire fatal checkoutDidFail#567

Draft
asoules wants to merge 1 commit into
mainfrom
asoules/preload-4xx-should-not-fire-fatal-error
Draft

Failing test: preload 4xx must not fire fatal checkoutDidFail#567
asoules wants to merge 1 commit into
mainfrom
asoules/preload-4xx-should-not-fire-fatal-error

Conversation

@asoules
Copy link
Copy Markdown

@asoules asoules commented May 8, 2026

✨ Caution: AI Generated ✨

What's broken

CheckoutWebView.handleResponse (Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift) treats any 4xx (and 5xx) response on a checkout URL as a fatal user-facing checkout error, regardless of whether the request was a speculative preload (Shopify-Purpose: prefetch).

isPreloadRequest is set on the request (line 234) but never consulted in the response-handling path. Its only other use is a telemetry tag in didFinish (line 400). So a preload that returns 4xx/5xx fires viewDelegate?.checkoutViewDidFailWithError(.checkoutUnavailable, recoverable: false) to the host — even though the buyer never opened the sheet and would have been served fine on the next user-initiated load.

This is wrong on its own merits: speculative request failures are not buyer-facing failures.

How it surfaces in production

We hit this during a P1 incident with an enterprise merchant on May 8 2026 (Shopify-internal inv-22687). Sequence:

  1. Shop had checkout_sheet_kit_preload_killswitch = true as a manual assignment (set at some unknown earlier point — kill switches don't auto-expire).
  2. The kill switch returns HTTP 403 Request Forbidden to Sheet Kit prefetches at the edge worker (fast-handler.tsx:579) and the Rails layer (embedded_checkout_concern.rb:18).
  3. The SDK received 403 → invalidated the WebView cache → fired checkoutViewDidFailWithError(.checkoutUnavailable, recoverable: false) to the host's CheckoutDelegate.
  4. Host reaction (retry / re-present / show error) cascaded into the buyer-visible symptom: address-form state never persisted between WebView teardowns, the only address that reached the server was the IP-derived default, and a merchant cart_checkout_validation Function rejected that incomplete destination with a hard "Shipping not available" banner.
  5. Disabling the killswitch resolved it.

The killswitch is the trigger that made it visible, but the same pathology exists for any cause of a 4xx during preload: merchant CSP blocking prefetch, WAF rate-limiting prefetch headers specifically, transient backend errors during the warmup window, etc. Fixing it at the SDK layer makes all of those safe.

What this PR contains

A single failing test (test4xxResponseOnPreloadDoesNotFireFatalError) that pins the desired contract:

  • Preload + 4xx → cache invalidated ✓, navigation cancelled ✓, delegate not notified.

The test fails on main today. No source change in this PR — happy to follow up with the implementation, or for the team to take it.

Suggested fix

Smallest defensible change: early-return at the top of the >= 400 branch in handleResponse, after CheckoutWebView.invalidate():

if statusCode >= 400 {
    // Invalidate cache for any sort of error
    CheckoutWebView.invalidate()

+   // A preload (Shopify-Purpose: prefetch) is speculative — the buyer has
+   // not opened the checkout sheet, the host is just warming the cache.
+   // Surfacing a 4xx/5xx here as fatal forces hosts into error UI or retry
+   // loops for a failure the buyer never saw. The cache is invalidated above
+   // and navigation is cancelled below, so the next user-initiated load will
+   // re-fetch fresh and surface real failures normally.
+   if isPreloadRequest {
+       return .cancel
+   }
+
    OSLogger.shared.debug(...)
    switch statusCode { ... }
    return .cancel
}

15 lines, no behavior change for non-preload requests, all existing 4xx/5xx tests stay green. Existing test coverage:

Test Before fix After fix
test401responseOnCheckoutURLCodeDelegation passes passes (non-preload)
test403responseOnCheckoutURLCodeDelegation passes passes (non-preload)
test404responseOnCheckoutURLCodeDelegation passes passes (non-preload)
test410responseOnCheckoutURLCodeDelegation passes passes (non-preload)
testTreat5XXReponsesAsRecoverable passes passes (non-preload)
test4xxResponseOnPreloadDoesNotFireFatalError (this PR) fails passes

Considered alternatives:

  • Distinguish 4xx vs 5xx for preloads — no real-world reason to. Preload is best-effort; both classes of error should be silent at this layer.
  • New error case .preloadFailed(...) — adds public API surface for something hosts can't act on usefully (the next load might succeed; failure here is not actionable for the buyer).
  • Check Shopify-Purpose: prefetch on the response object — same outcome, more brittle. isPreloadRequest is the source of truth.

Cross-platform follow-up

The same pattern likely exists in Shopify/checkout-sheet-kit-android and Shopify/checkout-sheet-kit-react-native. Will check once this lands.

Documents that handleResponse currently fires
checkoutViewDidFailWithError(.checkoutUnavailable, recoverable: false)
for any 4xx on a checkout URL, even when the request was a speculative
preload (Shopify-Purpose: prefetch). isPreloadRequest is set on the
request but never consulted in the response-handling path.

This test fails on main; fix to follow.
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