Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add root error info to public error #4680

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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: 2 additions & 0 deletions Sources/Error Handling/ErrorDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ extension NSError.UserInfoKey {
static let attributeErrorsResponse: NSError.UserInfoKey = "attributes_error_response"
static let statusCode: NSError.UserInfoKey = "rc_response_status_code"
static let obfuscatedEmail: NSError.UserInfoKey = "rc_obfuscated_email"
static let rootError: NSError.UserInfoKey = "rc_root_error"

static let readableErrorCode: NSError.UserInfoKey = "readable_error_code"
static let backendErrorCode: NSError.UserInfoKey = "rc_backend_error_code"
Expand All @@ -35,6 +36,7 @@ enum ErrorDetails {
static let attributeErrorsResponseKey = NSError.UserInfoKey.attributeErrorsResponse as String
static let statusCodeKey = NSError.UserInfoKey.statusCode as String
static let obfuscatedEmailKey = NSError.UserInfoKey.obfuscatedEmail as String
static let rootErrorKey = NSError.UserInfoKey.rootError as String

static let readableErrorCodeKey = NSError.UserInfoKey.readableErrorCode as String
static let extraContextKey = NSError.UserInfoKey.extraContext as String
Expand Down
91 changes: 90 additions & 1 deletion Sources/Error Handling/PurchasesError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// Created by Nacho Soto on 8/31/22.

import Foundation
import StoreKit

/// An error returned by a `RevenueCat` public API.
public typealias PublicError = NSError
Expand All @@ -38,8 +39,56 @@ extension PurchasesError {
/// let error = ErrorUtils.unknownError().asPublicError
/// let errorCode = error as? ErrorCode
/// ```
///
/// Info about the root error can be accessed in userInfo.
/// Example:
/// ```
/// let error = ErrorUtils.unknownError().asPublicError
/// let rootErrorInfo = error.userInfo["rc_root_error"] as? [String: Any]
/// let rootErrorCode = rootErrorInfo?["code"] as? Int
/// let rootErrorDomain = rootErrorInfo?["domain"] as? String
/// let rootErrorLocalizedDescription = rootErrorInfo?["localizedDescription"] as? String
/// ```
///
/// If the root error comes from StoreKit, some extra info will be added to the root error.
/// Example:
/// ```
/// let error = ErrorUtils.unknownError().asPublicError
/// let rootErrorInfo = error.userInfo["rc_root_error"] as? [String: Any]
/// let storeKitErrorInfo = rootErrorInfo?["storeKitError"] as? [String: Any]
/// let storeKitErrorDescription = storeKitErrorInfo?["description"] as? String
/// // If it's a SKError:
/// let skErrorCode = storeKitErrorInfo?["skErrorCode"] as? Int
/// // If it's a StoreKitError.networkError:
/// let urlErrorCode = storeKitErrorInfo?["urlErrorCode"] as? Int
/// let urlErrorFailingUrl = storeKitErrorInfo?["urlErrorFailingUrl"] as? String
/// // If it's a StoreKitError.systemError:
/// let systemErrorDescription = storeKitErrorInfo?["systemErrorDescription"] as? Int
/// ```
var asPublicError: PublicError {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

My main concern with this approach is whether we only expose PublicError everywhere and/or if we use this method somewhere else... Need to research it a bit more.

Copy link
Contributor

Choose a reason for hiding this comment

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

I know it's a draft, but it would be great to list the keys we'll include in userInfo so people can just read it from here without going through the code

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm so I included it in the docs for the asPublicError, but that's actually internal... I'm trying to figure out a better way to explain this, but maybe this is something we should add to the docs, for people that care to get the root error.

return NSError(domain: Self.errorDomain, code: self.errorCode, userInfo: self.userInfo)
let rootError: Error = self.rootError(from: self)
let rootNSError = rootError as NSError
var rootErrorInfo: [String: Any] = [
"code": rootNSError.code,
"domain": rootNSError.domain,
"localizedDescription": rootNSError.localizedDescription
]
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) {
if let storeKitErrorInfo = self.getStoreKitErrorInfoIfAny(error: rootError) {
rootErrorInfo = rootErrorInfo.merging(["storeKitError": storeKitErrorInfo])
}
}
let userInfoToUse = self.userInfo.merging([ErrorDetails.rootErrorKey: rootErrorInfo])
return NSError(domain: Self.errorDomain, code: self.errorCode, userInfo: userInfoToUse)
}

private func rootError(from error: Error) -> Error {
let nsError = error as NSError
if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? Error {
return rootError(from: underlyingError)
} else {
return error
}
}

}
Expand All @@ -65,3 +114,43 @@ extension PurchasesError {
}

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
private extension PurchasesError {

func getStoreKitErrorInfoIfAny(error: Error) -> [String: Any]? {
if let skError = error as? SKError {
return [
"skErrorCode": skError.code.rawValue,
"description": skError.code.trackingDescription
]
} else if let storeKitError = error as? StoreKitError {
let resultMap: [String: Any] = ["description": storeKitError.trackingDescription]
switch storeKitError {
case .unknown,
.userCancelled,
.notAvailableInStorefront,
.notEntitled:
return resultMap
case let .networkError(urlError):
return resultMap.merging([
"urlErrorCode": urlError.errorCode,
"urlErrorFailingUrl": urlError.failureURLString ?? "missing_value"
])
case let .systemError(systemError):
return resultMap.merging([
"systemErrorDescription": systemError.localizedDescription
])

@unknown default:
Logger.warn(Strings.storeKit.unknown_storekit_error(storeKitError))
return resultMap
}
} else if let storeKitError = error as? StoreKit.Product.PurchaseError {
return ["description": storeKitError.trackingDescription]
} else {
return nil
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess it would be useful to log what other error type we might be missing? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, in this case I'm assuming it's because the root error is not a store kit error (which may happen in several situations). I could add a log here, but I think the situation may happen relatively often... Note that this method is only to add extra data to the userInfo map in case it's actually a StoreKit error.

}
}

}
5 changes: 5 additions & 0 deletions Sources/Logging/Strings/StoreKitStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ enum StoreKitStrings {

case error_displaying_store_message(Error)

case unknown_storekit_error(Error)

}

extension StoreKitStrings: LogMessage {
Expand Down Expand Up @@ -215,6 +217,9 @@ extension StoreKitStrings: LogMessage {

case let .error_displaying_store_message(error):
return "Error displaying StoreKit message: '\(error)'"

case let .unknown_storekit_error(error):
return "Unknown StoreKit error. Error: '\(error.localizedDescription)'"
}
}

Expand Down
114 changes: 112 additions & 2 deletions Tests/UnitTests/Purchasing/ErrorUtilsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,116 @@ class ErrorUtilsTests: TestCase {
}
}

func testPublicErrorsContainRootError() throws {
let underlyingError = ErrorUtils.offlineConnectionError().asPublicError

func throwing() throws {
throw ErrorUtils.customerInfoError(error: underlyingError).asPublicError
}

do {
try throwing()
fail("Expected error")
} catch let error as NSError {
expect(error).to(matchError(ErrorCode.customerInfoError))
let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any]
expect(rootErrorInfo).notTo(beNil())
expect(rootErrorInfo!["code"] as? Int) == 35
expect(rootErrorInfo!["domain"] as? String) == "RevenueCat.ErrorCode"
expect(rootErrorInfo!["localizedDescription"] as? String)
== "Error performing request because the internet connection appears to be offline."
expect(rootErrorInfo?.keys.count) == 3
}
}

func testPublicErrorsRootErrorContainsSKErrorInfo() throws {
let underlyingError = SKError(SKError.Code.paymentInvalid, userInfo: [:])

func throwing() throws {
throw ErrorUtils.purchaseInvalidError(error: underlyingError).asPublicError
}

do {
try throwing()
fail("Expected error")
} catch let error as NSError {
let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any]
expect(rootErrorInfo).notTo(beNil())
expect(rootErrorInfo!["code"] as? Int) == 3
expect(rootErrorInfo!["domain"] as? String) == "SKErrorDomain"
expect(rootErrorInfo!["localizedDescription"] as? String)
== "The operation couldn’t be completed. (SKErrorDomain error 3.)"
let storeKitError = rootErrorInfo!["storeKitError"] as? [String: Any]
expect(rootErrorInfo?.keys.count) == 4
expect(storeKitError).notTo(beNil())
expect(storeKitError!["skErrorCode"] as? Int) == 3
expect(storeKitError!["description"] as? String) == "payment_invalid"
expect(storeKitError?.keys.count) == 2

}
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
func testPublicErrorsRootErrorContainsStoreKitErrorInfo() throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

let underlyingError = StoreKitError.systemError(NSError(domain: "StoreKitSystemError", code: 1234))

func throwing() throws {
throw ErrorUtils.purchaseInvalidError(error: underlyingError).asPublicError
}

do {
try throwing()
fail("Expected error")
} catch let error as NSError {
let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any]
expect(rootErrorInfo).notTo(beNil())
expect(rootErrorInfo!["code"] as? Int) == 1
expect(rootErrorInfo!["domain"] as? String) == "StoreKit.StoreKitError"
expect(rootErrorInfo!["localizedDescription"] as? String)
== "The operation couldn’t be completed. (StoreKitSystemError error 1234.)"
let storeKitError = rootErrorInfo!["storeKitError"] as? [String: Any]
expect(rootErrorInfo?.keys.count) == 4
expect(storeKitError).notTo(beNil())
expect(storeKitError!["description"] as? String)
== "system_error_Error Domain=StoreKitSystemError Code=1234 \"(null)\""
expect(storeKitError!["systemErrorDescription"] as? String)
== "The operation couldn’t be completed. (StoreKitSystemError error 1234.)"
expect(storeKitError?.keys.count) == 2

}
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
func testPublicErrorsRootErrorContainsStoreKitProductPurchaseErrorInfo() throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

let underlyingError = StoreKit.Product.PurchaseError.productUnavailable

func throwing() throws {
throw ErrorUtils.purchaseInvalidError(error: underlyingError).asPublicError
}

do {
try throwing()
fail("Expected error")
} catch let error as NSError {
let rootErrorInfo = error.userInfo[ErrorDetails.rootErrorKey] as? [String: Any]
expect(rootErrorInfo).notTo(beNil())
expect(rootErrorInfo!["code"] as? Int) == 1
expect(rootErrorInfo!["domain"] as? String) == "StoreKit.Product.PurchaseError"
expect(rootErrorInfo!["localizedDescription"] as? String)
== "Item Unavailable"
let storeKitError = rootErrorInfo!["storeKitError"] as? [String: Any]
expect(rootErrorInfo?.keys.count) == 4
expect(storeKitError).notTo(beNil())
expect(storeKitError!["description"] as? String)
== "product_unavailable"
expect(storeKitError?.keys.count) == 1
}
}

func testPurchasesErrorWithUntypedErrorCode() throws {
let error: ErrorCode = .apiEndpointBlockedError

Expand All @@ -92,10 +202,10 @@ class ErrorUtilsTests: TestCase {
func testPurchasesErrorWithUntypedPublicError() throws {
let error: PublicError = ErrorUtils.configurationError().asPublicError
let purchasesError = ErrorUtils.purchasesError(withUntypedError: error)
let userInfo = try XCTUnwrap(purchasesError.userInfo as? [String: String])
let userInfoDescription = purchasesError.userInfo.description

expect(error).to(matchError(purchasesError))
expect(userInfo) == error.userInfo as? [String: String]
expect(userInfoDescription) == error.userInfo.description
}

func testPurchasesErrorWithUntypedPurchasesError() throws {
Expand Down