Files
purchases-ios/Sources/Error Handling/BackendError.swift
T
Pol MiroandRevenueCat Git Bot abad547968 AdMob SSV: add @_spi(Internal) poll endpoint on Purchases (#6641)
* Add AdMob SSV poll endpoint and `pollAdMobSSVStatus` SPI

* Trim verbose AdMob SSV transport comments.

Align AdsAPI and cache-key inline documentation with existing repository comment density while preserving behavior.

* Trim pollAdMobSSVStatus SPI documentation.

Reduce prescriptive guidance in the internal API comment while keeping the key cancellation behavior note.

* [skip ci] Generating new test snapshots (#6648)

* [skip ci] Generating new test snapshots (#6647)

* [skip ci] Generating new test snapshots (#6646)

* [skip ci] Generating new test snapshots (#6645)

* [skip ci] Generating new test snapshots (#6644)

* [skip ci] Generating new test snapshots (#6642)

* [skip ci] Generating new test snapshots (#6643)

* Trigger CI for PR 6641 checks.

Add a non-skip commit so required CircleCI statuses are reported on the current head.

* Validate non-empty client transaction ID for AdMob SSV polling.

Guard against empty client transaction IDs in the SPI and backend operation and add focused backend tests for the new error path.

* [skip ci] Generating new test snapshots (#6652)

* [skip ci] Generating new test snapshots (#6653)

* Trigger CI for PR 6641 checks.

Add a non-skip commit so required CircleCI statuses are reported on the current head.

* Enable signature verification for AdMob SSV status path.

Update HTTP request path tests to cover AdMob SSV signature and nonce behavior.

* fix: address review — move AdMob SPI extension to dedicated file

* fix: address review — fail fast when AdMob status mock is unstubbed

* fix: address review — dedupe client transaction validation in backend operation

* fix: address review — use enum for AdMob SSV poll status

* fix: address review — allow extracted Purchases extensions to access backend

* fix: address review — log unknown AdMob SSV statuses

* fix: address review — add Purchases AdMob SSV mapping tests

* fix: address review — map missing client transaction ID to configuration error

* fix: address review — keep backend private by co-locating AdMob extension

* fix: address review — remove unused raw-value conformance

* fix: address review — assert warning log for unknown AdMob status

* fix: address review — surface unknown AdMob SSV status to adapters

* fix: address review — enable eTag support for AdMob SSV status path

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
2026-04-22 15:55:13 +02:00

329 lines
13 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// BackendError.swift
//
// Created by Nacho Soto on 4/7/22.
// swiftlint:disable multiline_parameters
import Foundation
/// An `Error` produced by ``Backend``.
enum BackendError: Error, Equatable {
case networkError(NetworkError)
case missingAppUserID(Source)
case missingClientTransactionID(Source)
case emptySubscriberAttributes(Source)
case missingReceiptFile(URL?, Source)
case missingTransactionProductIdentifier(Source)
case missingCachedCustomerInfo(Source)
case invalidAppleSubscriptionKey(Source)
case unexpectedBackendResponse(UnexpectedBackendResponseError, extraContext: String?, Source)
case invalidWebRedemptionToken
case purchaseBelongsToOtherUser
case expiredWebRedemptionToken(obfuscatedEmail: String)
case unsupportedInUIPreviewMode(Source)
case missingTransactionJWS(Source)
}
extension BackendError {
static func missingAppUserID(
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .missingAppUserID(.init(file: file, function: function, line: line))
}
static func missingClientTransactionID(
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .missingClientTransactionID(.init(file: file, function: function, line: line))
}
static func missingTransactionJWS(
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .missingTransactionJWS(.init(file: file, function: function, line: line))
}
static func emptySubscriberAttributes(
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .emptySubscriberAttributes(.init(file: file, function: function, line: line))
}
static func missingTransactionProductIdentifier(
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .missingTransactionProductIdentifier(.init(file: file, function: function, line: line))
}
static func missingReceiptFile(
_ receiptURL: URL?,
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .missingReceiptFile(receiptURL, .init(file: file, function: function, line: line))
}
static func missingCachedCustomerInfo(
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .missingCachedCustomerInfo(.init(file: file, function: function, line: line))
}
static func unexpectedBackendResponse(
_ error: UnexpectedBackendResponseError,
extraContext: String? = nil,
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .unexpectedBackendResponse(error,
extraContext: extraContext,
.init(file: file, function: function, line: line))
}
static func unsupportedInUIPreviewMode(
file: String = #fileID, function: String = #function, line: UInt = #line
) -> Self {
return .unsupportedInUIPreviewMode(.init(file: file, function: function, line: line))
}
}
extension BackendError: PurchasesErrorConvertible {
var asPurchasesError: PurchasesError {
switch self {
case let .networkError(error):
return error.asPurchasesError
case let .missingAppUserID(source):
return ErrorUtils.missingAppUserIDError(fileName: source.file,
functionName: source.function,
line: source.line)
case let .missingClientTransactionID(source):
return ErrorUtils.configurationError(message: "Missing client transaction ID.",
fileName: source.file,
functionName: source.function,
line: source.line)
case let .missingTransactionJWS(source):
return ErrorUtils.storeProblemError(fileName: source.file,
functionName: source.function,
line: source.line)
case let .emptySubscriberAttributes(source):
return ErrorUtils.emptySubscriberAttributesError(fileName: source.file,
functionName: source.function,
line: source.line)
case let .missingReceiptFile(receiptURL, source):
return ErrorUtils.missingReceiptFileError(receiptURL,
fileName: source.file,
functionName: source.function,
line: source.line)
case let .missingTransactionProductIdentifier(source):
return ErrorUtils.unknownError(
message: Strings.purchase.skpayment_missing_product_identifier.description,
fileName: source.file,
functionName: source.function,
line: source.line
)
case let .missingCachedCustomerInfo(source):
return ErrorUtils.customerInfoError(withMessage: Strings.purchase.missing_cached_customer_info.description,
fileName: source.file,
functionName: source.function,
line: source.line)
case let .unexpectedBackendResponse(error, extraContext, source):
return ErrorUtils.unexpectedBackendResponse(withSubError: error,
extraContext: extraContext,
fileName: source.file,
functionName: source.function,
line: source.line)
case let .invalidAppleSubscriptionKey(source):
return ErrorUtils.configurationError(
message: ErrorCode.invalidAppleSubscriptionKeyError.description,
fileName: source.file,
functionName: source.function,
line: source.line
)
case .invalidWebRedemptionToken:
let code = BackendErrorCode.invalidWebRedemptionToken
return ErrorUtils.backendError(withBackendCode: code,
originalBackendErrorCode: code.rawValue)
case .purchaseBelongsToOtherUser:
let code = BackendErrorCode.purchaseBelongsToOtherUser
return ErrorUtils.backendError(withBackendCode: code,
originalBackendErrorCode: code.rawValue)
case let .expiredWebRedemptionToken(obfuscatedEmail):
let code = BackendErrorCode.expiredWebRedemptionToken
return ErrorUtils.backendError(withBackendCode: code,
originalBackendErrorCode: code.rawValue,
extraUserInfo: [
.obfuscatedEmail: obfuscatedEmail
])
case let .unsupportedInUIPreviewMode(source):
return ErrorUtils.unsupportedInUIPreviewModeError(fileName: source.file,
functionName: source.function,
line: source.line)
}
}
}
extension BackendError: DescribableError { }
extension BackendError {
/// Whether the operation producing this error actually synced the data.
var successfullySynced: Bool {
return self.networkError?.successfullySynced ?? false
}
/// Whether the operation producing this error can be completed.
/// If `false`, the underlying error was fatal.
var finishable: Bool {
return self.networkError?.finishable ?? false
}
/// Whether the error represents a `NetworkError` from the server being down.
var isServerDown: Bool {
return self.networkError?.isServerDown == true
}
private var networkError: NetworkError? {
switch self {
case let .networkError(networkError):
return networkError
case .missingAppUserID,
.missingClientTransactionID,
.emptySubscriberAttributes,
.missingReceiptFile,
.invalidAppleSubscriptionKey,
.missingTransactionProductIdentifier,
.missingCachedCustomerInfo,
.unexpectedBackendResponse,
.invalidWebRedemptionToken,
.purchaseBelongsToOtherUser,
.expiredWebRedemptionToken,
.unsupportedInUIPreviewMode,
.missingTransactionJWS:
return nil
}
}
}
extension BackendError {
var underlyingError: Error? {
switch self {
case let .networkError(error):
return error
case .missingAppUserID,
.missingClientTransactionID,
.emptySubscriberAttributes,
.missingReceiptFile,
.invalidAppleSubscriptionKey,
.missingTransactionProductIdentifier,
.missingCachedCustomerInfo,
.invalidWebRedemptionToken,
.purchaseBelongsToOtherUser,
.expiredWebRedemptionToken,
.unsupportedInUIPreviewMode,
.missingTransactionJWS:
return nil
case let .unexpectedBackendResponse(error, _, _):
return error
}
}
}
extension BackendError {
enum UnexpectedBackendResponseError: Error, Equatable {
/// Login call failed due to a problem with the response.
case loginResponseDecoding
/// Received a bad response after posting an offer- "offers" couldn't be read from response.
case postOfferIdBadResponse
/// Received a bad response after posting an offer- "offers" was totally missing.
case postOfferIdMissingOffersInResponse
/// Received a bad response after posting an offer- there was an issue with the signature.
case postOfferIdSignature
/// getOffer call failed with an invalid response.
case getOfferUnexpectedResponse
/// A call that is supposed to retrieve a CustomerInfo failed because the CustomerInfo in the response was nil.
case customerInfoNil
/// A call that is supposed to retrieve a CustomerInfo failed because the json object couldn't be parsed.
case customerInfoResponseParsing(error: NSError, json: String)
}
}
extension BackendError.UnexpectedBackendResponseError: DescribableError {
var description: String {
switch self {
case .loginResponseDecoding:
return "Unable to decode response returned from login."
case .postOfferIdBadResponse:
return "Unable to decode response returned from posting offer for signing."
case .postOfferIdMissingOffersInResponse:
return "Missing offers from response returned from posting offer for signing."
case .postOfferIdSignature:
return "Signature error encountered in response returned from posting offer for signing."
case .getOfferUnexpectedResponse:
return "Unknown error encountered while getting offerings."
case .customerInfoNil:
return "Unable to instantiate a CustomerInfoResponse, CustomerInfo in response was nil."
case .customerInfoResponseParsing:
return "Unable to instantiate a CustomerInfoResponse due to malformed json."
}
}
}
extension BackendError {
typealias Source = ErrorSource
}
extension BackendError {
/// Whether to fall back to cached offerings in case of this error when fetching offerings.
var shouldFallBackToCachedOfferings: Bool {
switch self {
case .networkError(let networkError):
return networkError.shouldFallBackToCachedOfferings
default:
return true
}
}
}