Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions platforms/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,21 @@ A successful background preload normally transitions from `.loading` to `.ready`
| `.expired` | The cached preload exceeded its lifetime before it could be used. |
| `.failed(reason:)` | An HTTP, navigation, or web-content failure occurred while preloading. |

By default, a 429 response with a valid `Retry-After` header produces a `.throttled` failure.
Checkout Kit suppresses further preload requests until the server-provided delay elapses; it does
not retry automatically. Presentation is never suppressed.

To manage preload backoff in your application instead, use the `.passthrough` throttle policy:

```swift
ShopifyCheckoutKit.configure {
$0.preloading.throttlePolicy = .passthrough
}
```

Under `.passthrough`, the failure is `.httpError(statusCode: 429, retryAfter:)` and Checkout Kit
does not suppress subsequent preload requests.

`preload` returns `nil` when preloading is disabled.

Checkout Kit can reuse a matching preloaded checkout when `present` is called later:
Expand Down Expand Up @@ -259,6 +274,7 @@ ShopifyCheckoutKit.configure {
| `closeButtonTintColor` | `nil` | Optional tint for the close button. |
| `logLevel` | `.warn` | SDK logging verbosity. Threshold-ordered `.debug` → `.warn` → `.error` → `.none`; use `.debug` during integration. |
| `preloading.enabled` | `true` | Enables best-effort checkout preloading before presentation. |
| `preloading.throttlePolicy` | `.managed` | Enforces server-provided preload backoff. Use `.passthrough` to receive the HTTP failure without suppressing preload requests. |
| `allowedMessageOrigins` | `[]` | Origins trusted to send incoming checkout messages. Empty trusts every origin (open by default). See [Incoming message origin validation](#incoming-message-origin-validation). |

To localize the title, add `shopify_checkout_kit_title` to your app's `Localizable.xcstrings`.
Expand Down Expand Up @@ -354,9 +370,10 @@ Kit-owned link delegations such as `window.open` are offered to your connected p
### Error handling

A checkout lifecycle failure is delivered as a `CheckoutError` to `checkoutDidFail(error:)`
or `.onFail`. It has a stable `code`, diagnostic `message`, optional `httpStatusCode`, and an
optional native `underlyingError`. Use the stable code for recovery and analytics. Use diagnostic
text and underlying errors only for debugging and logging.
or `.onFail`. It has a stable `code`, diagnostic `message`, optional `httpStatusCode`, optional
server-provided `retryAfter` delay in seconds, and an optional native `underlyingError`. Use the
stable code for recovery and analytics. Use diagnostic text and underlying errors only for
debugging and logging.

| `CheckoutErrorCode` | Meaning | Suggested app action |
| --- | --- | --- |
Expand All @@ -365,7 +382,7 @@ text and underlying errors only for debugging and logging.
| `.cartExpired` | The cart or checkout session is no longer available. | Create a new cart and retry. |
| `.cartCompleted` | The cart has already completed checkout. | Clear or create a new cart. |
| `.invalidCart` | The cart cannot continue checkout. | Create a new cart and retry. |
| `.httpError` | Checkout returned an HTTP error response. `httpStatusCode` is available. | Inspect `httpStatusCode`; retry only when it makes sense for your app. |
| `.httpError` | Checkout returned an HTTP error response. `httpStatusCode` and, when supplied by the server, `retryAfter` are available. | Inspect `httpStatusCode`; do not retry before `retryAfter`, and retry only when it makes sense for your app. |
| `.networkError` | Checkout navigation failed before an HTTP response was available. | Offer a retry when connectivity is available. |
| `.webContentProcessTerminated` | WebKit terminated the content process. | Let the buyer explicitly retry; Checkout Kit does not reload automatically. |
| `.sdkError` | An internal Checkout Kit error has occurred (e.g. a protocol message could not be decoded). | Log diagnostic context and offer a browser fallback. |
Expand Down
15 changes: 11 additions & 4 deletions platforms/swift/Sources/ShopifyCheckoutKit/CheckoutError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ public enum CheckoutErrorCode: String, Codable, CaseIterable, Sendable {
/// or ``ShopifyCheckout/onFail(_:)``.
///
/// Use ``code`` for application behavior. Use ``message`` and ``underlyingError`` only for debugging
/// and logging. ``httpStatusCode`` is present only when an HTTP response caused failure. Your app owns
/// recovery actions such as retrying, recreating a cart, authenticating a buyer, and reopening checkout.
/// and logging. ``httpStatusCode`` is present only when an HTTP response caused failure. ``retryAfter``
/// contains the server-provided delay when one is available. Your app owns recovery actions such as
/// retrying, recreating a cart, authenticating a buyer, and reopening checkout.
public struct CheckoutError: LocalizedError {
/// Stable code for this failure.
public let code: CheckoutErrorCode
Expand All @@ -52,6 +53,9 @@ public struct CheckoutError: LocalizedError {
/// HTTP status for an HTTP-response failure, otherwise `nil`.
public let httpStatusCode: Int?

/// Server-provided delay, in seconds, before another request should be attempted.
public let retryAfter: TimeInterval?

/// Native diagnostic cause, when one is available. This value is not guaranteed to be Sendable.
public let underlyingError: (any Error)?

Expand All @@ -60,11 +64,13 @@ public struct CheckoutError: LocalizedError {
code: CheckoutErrorCode,
message: String,
httpStatusCode: Int? = nil,
retryAfter: TimeInterval? = nil,
underlyingError: (any Error)? = nil
) {
self.code = code
self.message = message
self.httpStatusCode = httpStatusCode
self.retryAfter = retryAfter
self.underlyingError = underlyingError
}

Expand All @@ -79,11 +85,12 @@ public struct CheckoutError: LocalizedError {
}

extension CheckoutError {
internal static func http(statusCode: Int, message: String) -> CheckoutError {
internal static func http(statusCode: Int, message: String, retryAfter: TimeInterval? = nil) -> CheckoutError {
CheckoutError(
code: statusCode == 410 ? .cartExpired : .httpError,
message: message,
httpStatusCode: statusCode
httpStatusCode: statusCode,
retryAfter: retryAfter
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ struct PreloadKey: Hashable {

@MainActor
final class PreloadCache {
static let throttledMessage = "Preload throttled until the server-provided Retry-After delay elapses."

private struct Entry {
let key: PreloadKey
let view: CheckoutWebView
Expand Down Expand Up @@ -40,6 +42,7 @@ final class PreloadCache {
private var entry: Entry?
private var keepAliveTimer: Timer?
private var expiryTimer: Timer?
private var throttleDeadline: Date?

private(set) var state: PreloadState = .idle

Expand Down Expand Up @@ -79,6 +82,26 @@ final class PreloadCache {
observer?.receive(newState)
}

func beginThrottle(for delay: TimeInterval) {
throttleDeadline = Date().addingTimeInterval(delay)
evict(with: .failed(reason: .throttled, message: Self.throttledMessage))
}

func isThrottleActive(at date: Date = Date()) -> Bool {
guard let throttleDeadline else {
return false
}
guard date < throttleDeadline else {
self.throttleDeadline = nil
return false
}
return true
}

func clearThrottle() {
throttleDeadline = nil
}

/// Evicts the cached view, then notifies observers of the resulting `state`.
/// Clearing before notifying ensures a preload started re-entrantly from the
/// callback is not wiped by this invalidation.
Expand Down Expand Up @@ -406,6 +429,16 @@ class CheckoutWebView: WKWebView {
return
}

if ShopifyCheckoutKit.configuration.preloading.throttlePolicy == .managed,
preloadCache.isThrottleActive()
{
preloadCache.transition(to: .failed(
reason: .throttled,
message: PreloadCache.throttledMessage
))
return
}

let key = PreloadKey(url: url, entryPoint: entryPoint)
guard !preloadCache.hasEntry(for: key) else {
OSLogger.shared.debug("Preload cache already has matching entry")
Expand Down Expand Up @@ -827,6 +860,7 @@ extension CheckoutWebView: WKNavigationDelegate {

func handleResponse(_ response: HTTPURLResponse) -> WKNavigationResponsePolicy {
let statusCode = response.statusCode
let retryAfter = RetryAfter.seconds(from: response)
let errorMessageForStatusCode = HTTPURLResponse.localizedString(
forStatusCode: statusCode
)
Expand All @@ -836,15 +870,28 @@ extension CheckoutWebView: WKNavigationDelegate {
}

if statusCode >= 400 {
handleCachedViewFailure(
.httpError(statusCode: statusCode),
message: "HTTP response returned status code \(statusCode)."
)
let message = "HTTP response returned status code \(statusCode)."
if isPreloadBackgrounded,
statusCode == 429,
let retryAfter,
ShopifyCheckoutKit.configuration.preloading.throttlePolicy == .managed
{
CheckoutWebView.preloadCache.beginThrottle(for: retryAfter)
} else {
handleCachedViewFailure(
.httpError(statusCode: statusCode, retryAfter: retryAfter),
message: message
)
}

OSLogger.shared.debug("Handling response for URL: \(LogSafeURL.string(response.url)), status code: \(statusCode)")

viewDelegate?.checkoutViewDidFailWithError(
error: CheckoutError.http(statusCode: statusCode, message: errorMessageForStatusCode)
error: CheckoutError.http(
statusCode: statusCode,
message: errorMessageForStatusCode,
retryAfter: retryAfter
)
)

return .cancel
Expand Down
12 changes: 12 additions & 0 deletions platforms/swift/Sources/ShopifyCheckoutKit/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ extension Configuration {

extension Configuration {
public struct Preloading: Sendable {
/// Controls how Checkout Kit handles preload throttling responses.
public enum ThrottlePolicy: Equatable, Sendable {
/// Respect the server's `Retry-After` value and suppress preload requests until it elapses.
case managed

/// Surface the HTTP failure without suppressing subsequent preload requests.
case passthrough
}

public var enabled: Bool = true

/// The policy used when a preload receives a throttling response.
public var throttlePolicy: ThrottlePolicy = .managed
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ public enum PreloadState: Equatable {

/// Reason a preload cache entry was not available.
public enum FailureReason: Equatable {
/// The preload was throttled and Checkout Kit is suppressing further preload requests
/// until the server-provided `Retry-After` delay elapses.
case throttled

/// The preload received an HTTP response that prevented it from loading.
case httpError(statusCode: Int)
///
/// Under the `.passthrough` throttle policy, `retryAfter` is the server-provided delay,
/// in seconds, when a throttling response includes a valid `Retry-After` header.
case httpError(statusCode: Int, retryAfter: TimeInterval? = nil)

/// Preload navigation failed.
case navigationFailed
Expand Down
38 changes: 38 additions & 0 deletions platforms/swift/Sources/ShopifyCheckoutKit/RetryAfter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

enum RetryAfter {
private static let dateFormats = [
"EEE, dd MMM yyyy HH:mm:ss zzz",
"EEEE, dd-MMM-yy HH:mm:ss zzz",
"EEE MMM d HH:mm:ss yyyy"
]

static func seconds(from response: HTTPURLResponse, now: Date = Date()) -> TimeInterval? {
seconds(from: response.value(forHTTPHeaderField: "Retry-After"), now: now)
}

static func seconds(from value: String?, now: Date = Date()) -> TimeInterval? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}

if let delay = TimeInterval(value), delay >= 0 {
return delay
}

for format in dateFormats {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = format
formatter.isLenient = false

if let date = formatter.date(from: value) {
return ceil(max(0, date.timeIntervalSince(now)))
}
}

return nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,31 @@ class CheckoutWebViewTests: XCTestCase {
}
}

func test429ResponseIncludesRetryAfterOnPresentationError() throws {
try view.load(checkout: XCTUnwrap(URL(string: "https://shopify1.shopify.com/checkouts/cn/123")))
let link = try XCTUnwrap(view.url)
let didFailWithErrorExpectation = expectation(description: "checkoutViewDidFailWithError was called")
mockDelegate.didFailWithErrorExpectation = didFailWithErrorExpectation
view.viewDelegate = mockDelegate

let response = try XCTUnwrap(
HTTPURLResponse(
url: link,
statusCode: 429,
httpVersion: nil,
headerFields: ["Retry-After": "120"]
)
)

XCTAssertEqual(view.handleResponse(response), .cancel)
wait(for: [didFailWithErrorExpectation], timeout: 3)

let error = try XCTUnwrap(mockDelegate.errorReceived)
XCTAssertEqual(error.code, .httpError)
XCTAssertEqual(error.httpStatusCode, 429)
XCTAssertEqual(error.retryAfter, 120)
}

func testNormalresponseOnNonCheckoutURLCodeDelegation() throws {
let link = try XCTUnwrap(URL(string: "https://shopify.com/resource_url"))
let didFailWithErrorExpectation = expectation(description: "checkoutViewDidFailWithError was not called")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ class ConfigurationTests: XCTestCase {
XCTAssertTrue(ShopifyCheckoutKit.configuration.preloading.enabled)
}

func testPreloadingThrottlePolicyDefaultsToManaged() {
XCTAssertEqual(ShopifyCheckoutKit.configuration.preloading.throttlePolicy, .managed)
}

func testAppearanceDefaultsToStorefront() {
XCTAssertEqual(ShopifyCheckoutKit.configuration.appearance, .storefront)
}
Expand Down
Loading
Loading