Skip to content
Open
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
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@

- Swift 5.7+
- iOS SDK 13.0+
- The SDK is not compatible with checkout.liquid. The Shopify Store must be migrated for extensibility

## Getting Started

Expand Down Expand Up @@ -479,7 +478,6 @@ func shouldRecoverFromError(error: CheckoutError) {

| Type | Description | Recommendation |
| --------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `.configurationError(code: .checkoutLiquidNotAvailable)` | `checkout.liquid` is not supported. | Please migrate to checkout extensibility. |
| `.checkoutUnavailable(message: "Forbidden")` | Access to checkout is forbidden. | This error is unrecoverable. |
| `.checkoutUnavailable(message: "Internal Server Error")` | An internal server error occurred. | This error will be ephemeral. Try again shortly. |
| `.checkoutUnavailable(message: "Storefront password required")` | Access to checkout is password restricted. | We are working on ways to enable the Checkout Kit for usage with password protected stores. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -603,38 +603,19 @@ extension CartViewController: CheckoutDelegate {
}
}

func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) {
var errorMessage = ""

/// Internal Checkout SDK error
if case let .sdkError(underlying, _) = error {
errorMessage = "\(underlying.localizedDescription)"
}

/// Checkout unavailable error
if case let .checkoutUnavailable(message, code, _) = error {
errorMessage = message
func checkoutDidFail(error: CheckoutError) {
if case let .unavailable(message, code, _) = error {
handleCheckoutUnavailable(message, code)
}

/// Storefront configuration error
if case let .configurationError(message, _, _) = error {
errorMessage = message
}

/// Checkout has expired, re-create cart to fetch a new checkout URL
if case let .checkoutExpired(message, _, _) = error {
errorMessage = message
}

print(errorMessage, "Recoverable: \(error.isRecoverable)")
print(error.message, "Recoverable: \(error.isRecoverable)")

if !error.isRecoverable {
handleUnrecoverableError(errorMessage)
handleUnrecoverableError(error.message)
}
}

private func handleCheckoutUnavailable(_ message: String, _ code: CheckoutUnavailable) {
private func handleCheckoutUnavailable(_ message: String, _ code: CheckoutError.CheckoutUnavailable) {
switch code {
case let .clientError(clientErrorCode):
print("[CheckoutUnavailable] (checkoutError)", message, clientErrorCode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ class ApplePayViewController: WalletController, PayController {
ShopifyAcceleratedCheckouts.logger.error(
"[startPayment] Failed to setup cart: \(error)"
)
await onCheckoutFail?(.sdkError(underlying: error, recoverable: false))
await onCheckoutFail?(.internal(underlying: error, recoverable: false))
}

do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class ShopPayViewController: WalletController {
}
try await present(url: url, delegate: self)
} catch {
let error = CheckoutError.sdkError(underlying: error)
let error = CheckoutError.internal(underlying: error)
ShopifyAcceleratedCheckouts.logger.error("[present] Failed to create cart: \(error)")
eventHandlers.checkoutDidFail?(error)
}
Expand Down
116 changes: 51 additions & 65 deletions Sources/ShopifyCheckoutSheetKit/CheckoutError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,89 +23,75 @@

import Foundation

public enum CheckoutErrorCode: String, Codable {
case storefrontPasswordRequired = "storefront_password_required"
case checkoutLiquidNotMigrated = "checkout_liquid_not_migrated"
case cartExpired = "cart_expired"
case cartCompleted = "cart_completed"
case invalidCart = "invalid_cart"
case unknown

public static func from(_ code: String?) -> CheckoutErrorCode {
let fallback = CheckoutErrorCode.unknown

guard let errorCode = code else {
return fallback
}

return CheckoutErrorCode(rawValue: errorCode) ?? fallback
}
}

public enum CheckoutUnavailable {
case clientError(code: CheckoutErrorCode)
case httpError(statusCode: Int)
}

/// A type representing Shopify Checkout specific errors.
/// "recoverable" indicates that though the request has failed, it should be retried in a fallback browser experience.
public enum CheckoutError: Swift.Error {
/// `CheckoutError` represents scenarios where Shopify Checkout may error
///
/// Each error relates to a different portion of Web Shopify Checkout, except `.sdk` which is an internal swift error
/// When the error is not `.sdk` it is useful to first confirm where the issue exists in your Storefront
/// within a browser, to exclude Checkout Kit from the investigation
///
/// Every event has a "recoverable" property that indicates this error may be recoverable when retried in a fallback browser experience
/// This may have a degraded experience, implement CheckoutDelegate.shouldRecoverFromError to opt out
public enum CheckoutError: Error {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this error class is CheckoutError I thought it may make sense to remove the repeated/inconsistent "checkout" and "error" in the cases

/// Issued when an internal error within Shopify Checkout SDK
/// In event of an sdkError you could use the stacktrace to inform you of how to proceed,
/// if the issue persists, it is recommended to open a bug report in http://github.com/Shopify/checkout-sheet-kit-swift
case sdkError(underlying: Swift.Error, recoverable: Bool = true)
/// if the issue persists, it is recommended to open a bug report in:
/// http://github.com/Shopify/checkout-sheet-kit-swift/issues
case `internal`(underlying: Error, recoverable: Bool = true)

/// Issued when the storefront configuration has caused an error.
/// Note that the Checkout Sheet Kit only supports stores migrated for extensibility.
case configurationError(message: String, code: CheckoutErrorCode, recoverable: Bool = false)
case misconfiguration(message: String, code: ErrorCode, recoverable: Bool = false)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposal: rename configuration -> misconfiguration
Felt closer aligned to the naming of "expired" and "unavailable" (the target of the noun is whats happened?)

.sdk still feels like an outlier in that sense though

Copy link
Contributor

@markmur markmur Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.sdk.internal maybe?

misconfiguration looks good


/// Issued when checkout has encountered a unrecoverable error (for example server side error)
/// if the issue persists, it is recommended to open a bug report in http://github.com/Shopify/checkout-sheet-kit-swift
case checkoutUnavailable(message: String, code: CheckoutUnavailable, recoverable: Bool)
/// if the issue persists, it is recommended to open a bug report:
/// http://github.com/Shopify/checkout-sheet-kit-swift/issues
case unavailable(message: String, code: CheckoutUnavailable, recoverable: Bool)

/// Issued when checkout is no longer available and will no longer be available with the checkout url supplied.
/// This may happen when the user has paused on checkout for a long period (hours) and then attempted to proceed again with the same checkout url
/// In event of checkoutExpired, a new checkout url will need to be generated
case checkoutExpired(message: String, code: CheckoutErrorCode, recoverable: Bool = false)
case expired(message: String, code: ErrorCode, recoverable: Bool = false)

public var isRecoverable: Bool {
switch self {
case let .checkoutExpired(_, _, recoverable),
let .checkoutUnavailable(_, _, recoverable),
let .configurationError(_, _, recoverable),
let .sdkError(_, recoverable):
case let .expired(_, _, recoverable),
let .unavailable(_, _, recoverable),
let .misconfiguration(_, _, recoverable),
let .internal(_, recoverable):
return recoverable
}
}
}

public enum CheckoutErrorGroup: String, Codable {
/// An authentication error
case authentication
/// A shop configuration error
case configuration
/// A terminal checkout error which cannot be handled
case unrecoverable
/// A checkout-related error, such as failure to receive a receipt or progress through checkout
case checkout
/// The checkout session has expired and is no longer available
case expired
/// The error sent by checkout is not supported
case unsupported
}
public var message: String {
switch self {
case let .internal(underlying, _): return underlying.localizedDescription
case let .expired(message, _, _): return message
case let .unavailable(message, _, _): return message
case let .misconfiguration(message, _, _): return message
}
}

public struct CheckoutErrorEvent: Codable {
public let group: CheckoutErrorGroup
public let code: String?
public let flowType: String?
public let reason: String?
public let type: String?
public enum ErrorCode: String, Codable {
/// misconfiguration: recoverable:false
case payloadExpired = "PAYLOAD_EXPIRED"
case invalidPayload = "INVALID_PAYLOAD"
case invalidSignature = "INVALID_SIGNATURE"
case notAuthorized = "NOT_AUTHORIZED"
case customerAccountRequired = "CUSTOMER_ACCOUNT_REQUIRED"
case storefrontPasswordRequired = "STOREFRONT_PASSWORD_REQUIRED"

/// unavailable: recoverable:true
case killswitchEnabled = "KILLSWITCH_ENABLED"
case unrecoverableFailure = "UNRECOVERABLE_FAILURE"
Comment on lines +82 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recoverable: true here seems wrong?

case policyViolation = "POLICY_VIOLATION"
case vaultedPaymentError = "VAULTED_PAYMENT_ERROR"

/// expired: recoverable:false
case cartCompleted = "CART_COMPLETED"
case invalidCart = "INVALID_CART"
}

public init(group: CheckoutErrorGroup, code: String? = nil, flowType: String? = nil, reason: String? = nil, type: String? = nil) {
self.group = group
self.code = code
self.flowType = flowType
self.reason = reason
self.type = type
public enum CheckoutUnavailable {
case clientError(code: ErrorCode)
case httpError(statusCode: Int)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,13 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import Foundation
import WebKit

/// Parameters for error events - array of error events
internal struct CheckoutErrorParams: Decodable {
let errors: [CheckoutErrorEvent]

init(from decoder: Decoder) throws {
// The params is an array directly
let container = try decoder.singleValueContainer()
errors = try container.decode([CheckoutErrorEvent].self)
}
}

/// Request for checkout error events
internal final class CheckoutErrorRequest: BaseRPCRequest<CheckoutErrorParams, EmptyResponse> {
override static var method: String { "error" }

/// Convenience getter to access the first error
var firstError: CheckoutErrorEvent? {
return params.errors.first
public struct CheckoutErrorEvent: CheckoutNotification {
public static let method = "checkout.error"
public let code: CheckoutError.ErrorCode
public let message: String

public init(code: CheckoutError.ErrorCode, message: String) {
self.code = code
self.message = message
}
}
Loading