Skip to content

Commit

Permalink
Improve error handling (HTTP + Bridge events) (#156)
Browse files Browse the repository at this point in the history
* Parse checkout bridge error

* Ignore unsupported errors

* Improve existing tests, add more

* Bump schema version to 8.1

* Add support for 403 forbidden error

* Add storefrontConfigurationError

* Add error handling guidance to README

* Add test for storefrontConfigurationError

* Add tests for 403, 404

* Trigger checkoutUnavailable for storefrontConfigurationError

* Update README

* Group 400 and 500 errors differently

* Expose error code and httpStatusCode on `.checkoutUnavailable` error (#161)

* Expose code and httpStatusCode on checkoutUnavailable error

* Check reason header for liquid not supported error

* X-Shopify-API-Deprecated-Reason header

* Refactor implementation to annotate recoverable

* Update samples to conform

* Add recoverable property to CheckoutError types

* Remove redundant cache deletion

* Add codes to error messages, replace checkoutLiquidNotMigrated with configurationError

* Remove readme changes
  • Loading branch information
markmur authored Apr 17, 2024
1 parent ec952f6 commit f6bdc2d
Show file tree
Hide file tree
Showing 11 changed files with 451 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -162,26 +162,51 @@ extension CartViewController: CheckoutDelegate {
dismiss(animated: true)
}

func checkoutDidFail(errors: [ShopifyCheckoutSheetKit.CheckoutError]) {
print(#function, errors)
}

func checkoutDidClickContactLink(url: URL) {
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}

func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) {
switch error {
case .sdkError(let underlying):
print(#function, underlying)
forceCloseCheckout("Checkout Unavailable")
case .checkoutExpired(let message): forceCloseCheckout(message)
case .checkoutUnavailable(let message): forceCloseCheckout(message)
case .checkoutLiquidNotMigrated(let message):
print(#function, message)
forceCloseCheckout("Checkout Unavailable")
var errorMessage: String = ""

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

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

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

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

/// Unauthorized checkout
if case .authenticationError(let message, _, _) = error {
errorMessage = message
}

print(#function, error)
forceCloseCheckout(errorMessage)
}

private func handleCheckoutUnavailable(_ message: String, _ code: CheckoutUnavailable) {
switch code {
case .clientError(let clientErrorCode):
print("[CheckoutUnavailable] (checkoutError)", message, clientErrorCode)
case .httpError(let statusCode):
print("[CheckoutUnavailable] (httpError)", statusCode)
}
}

Expand All @@ -196,8 +221,7 @@ extension CartViewController: CheckoutDelegate {
}
}

private func forceCloseCheckout(_ message: String) {
print(#function, message)
private func forceCloseCheckout(_ message: String = "Checkout unavailable") {
dismiss(animated: true)
resetCart()
self.showAlert(message: message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Shopify/mobile-buy-sdk-ios",
"state" : {
"revision" : "e6e85dcf8f9eb95baaa8336ad3d7967ea8c36ade",
"version" : "11.1.0"
"revision" : "3a6ecdce6b9e8f356078fbdc2b738ac32cd18153",
"version" : "11.3.0"
}
},
{
Expand Down
39 changes: 34 additions & 5 deletions Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ enum BridgeError: Swift.Error {
}

enum CheckoutBridge {
static let schemaVersion = "8.0"
static let schemaVersion = "8.1"
static let messageHandler = "mobileCheckoutSdk"
internal static var logger: ProductionLogger = InternalLogger()

Expand Down Expand Up @@ -82,18 +82,30 @@ enum CheckoutBridge {

extension CheckoutBridge {
enum WebEvent: Decodable {
/// Error types
case authenticationError(message: String?, code: CheckoutErrorCode)
case checkoutExpired(message: String?, code: CheckoutErrorCode)
case checkoutUnavailable(message: String?, code: CheckoutErrorCode)
case configurationError(message: String?, code: CheckoutErrorCode)

/// Success
case checkoutComplete(event: CheckoutCompletedEvent)
case checkoutExpired
case checkoutUnavailable

/// Presentational
case checkoutModalToggled(modalVisible: Bool)

/// Eventing
case webPixels(event: PixelEvent?)

/// Generic
case unsupported(String)

enum CodingKeys: String, CodingKey {
case name
case body
}

// swiftlint:disable cyclomatic_complexity
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

Expand All @@ -110,8 +122,24 @@ extension CheckoutBridge {
self = .checkoutComplete(event: emptyCheckoutCompletedEvent)
}
case "error":
// needs to support .checkoutUnavailable by parsing error payload on body
self = .checkoutExpired
let errorDecoder = CheckoutErrorEventDecoder()
let error = errorDecoder.decode(from: container, using: decoder)
let code = CheckoutErrorCode.from(error.code)

switch error.group {
case .configuration:
if code == .customerAccountRequired {
self = .authenticationError(message: error.reason, code: .customerAccountRequired)
} else {
self = .configurationError(message: error.reason, code: code)
}
case .unrecoverable:
self = .checkoutUnavailable(message: error.reason, code: code)
case .expired:
self = .checkoutExpired(message: error.reason, code: CheckoutErrorCode.from(error.code))
default:
self = .unsupported(name)
}
case "checkoutBlockingEvent":
let modalVisible = try container.decode(String.self, forKey: .body)
self = .checkoutModalToggled(modalVisible: Bool(modalVisible)!)
Expand All @@ -123,6 +151,7 @@ extension CheckoutBridge {
self = .unsupported(name)
}
}
// swiftlint:enable cyclomatic_complexity
}
}

Expand Down
91 changes: 87 additions & 4 deletions Sources/ShopifyCheckoutSheetKit/CheckoutError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,106 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import Foundation

public enum CheckoutErrorCode: String, Codable {
case customerAccountRequired = "customer_account_required"
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 = "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 {
/// Issued when checkout has encountered an authentication error.
/// For example; the stonrefront is configured to enforce customer account login.
case authenticationError(message: String, code: CheckoutErrorCode, recoverable: Bool = false)

/// 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)
case sdkError(underlying: Swift.Error, recoverable: Bool = true)

/// Issued when the provided checkout URL results in an error related to shop being on checkout.liquid.
/// The SDK only supports stores migrated for extensibility.
case checkoutLiquidNotMigrated(message: String)
case configurationError(message: String, code: CheckoutErrorCode, recoverable: Bool = false)

/// 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)
case checkoutUnavailable(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)
case checkoutExpired(message: String, code: CheckoutErrorCode, recoverable: Bool = false)
}

internal 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
}

internal struct CheckoutErrorEvent: Codable {
public let group: CheckoutErrorGroup
public let code: String?
public let flowType: String?
public let reason: String?
public let type: String?

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
}
}

internal class CheckoutErrorEventDecoder {
func decode(from container: KeyedDecodingContainer<CheckoutBridge.WebEvent.CodingKeys>, using decoder: Decoder) -> CheckoutErrorEvent {

do {
let messageBody = try container.decode(String.self, forKey: .body)

/// Failure to decode will trigger the catch block
let data = messageBody.data(using: .utf8)

let events = try JSONDecoder().decode([CheckoutErrorEvent].self, from: data!)

/// Failure to find an event in the payload array will trigger the catch block
return events.first!
} catch {
CheckoutBridge.logger.logError(error, "Error decoding checkout error event")
return CheckoutErrorEvent(group: .unsupported, reason: "Decoded error could not be parsed.")
}
}
}
Loading

0 comments on commit f6bdc2d

Please sign in to comment.