mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* Re-tier HTTP request timeouts around per-source memory Replaces the single global "recent timeout" slot in `HTTPRequestTimeoutManager` with a per-host map (`host -> lastTimeoutDate`, 10-minute per-entry expiry, pruned on access) and re-tiers the request timeouts: - Main-source, endpoint with no fallback-URL support: 15s base / 5s reduced - Main-source, endpoint with fallback-URL support: 5s base / 2s reduced - Fallback-host and proxied requests: flat 30s (never consult the memory) A main-source request to host H uses the reduced tier iff H has a non-expired timeout entry. Timeouts are now recorded for any non-fallback request (dropping the previous fallback-support-only condition), keyed by attempt host; a successful main-source response clears only that host's entry. Renames the `timeoutOnMainBackendForFallbackSupportedEndpoint` result to `mainSourceTimedOut`. Co-authored-by: Cursor <cursoragent@cursor.com> * Define flat timeout tier as a Timeout constant Co-authored-by: Cursor <cursoragent@cursor.com> * Add exhaustive multi-host tests for timeout manager Co-authored-by: Cursor <cursoragent@cursor.com> * Honor developer networkTimeout via internal NetworkTimeout enum Introduce an internal NetworkTimeout enum (.default/.custom) threaded from Configuration down to HTTPRequestTimeoutManager. When a developer sets networkTimeout, it replaces the built-in base/flat tiers while the reduced fail-fast tiers stay fixed. Co-authored-by: Cursor <cursoragent@cursor.com> * Add NetworkTimeout.swift to RevenueCat.xcodeproj The file was only picked up by the SPM/Tuist directory globs, so the hand-maintained Xcode project build failed to resolve the type. Co-authored-by: Cursor <cursoragent@cursor.com> * Gate re-tiered no-fallback timeouts behind usesRemoteConfigAPISources The aggressive no-fallback fail-fast timeouts and their per-host timeout recording now apply to main-API requests only when the usesRemoteConfigAPISources dangerous setting is enabled. With it disabled (the default) the legacy network timeout is preserved. The gating is a per-call timeout(...) parameter so blob-source downloads can opt into the re-tiered timeouts independently. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the legacy flat timeout when re-tiered timeouts are off, and share one manager The fallback-host and proxied tiers returned the new 30s flat timeout regardless of `usesRemoteConfigAPISources`, halving the legacy 60s (or custom) network timeout for proxy users and every fallback-host request. They now honor `reTieredTimeoutsEnabled` like the no-fallback tier does, so only the fallback-supported tiers behave the same either way. `Backend` also built one timeout manager per HTTPClient, so the main and remote-config lanes kept independent per-host memories despite talking to the same hosts. They now share one, and `HTTPClient` requires both the manager and the network timeout so this wiring can't be dropped silently. Co-authored-by: Cursor <cursoragent@cursor.com> * Clear a host's fail-fast memory as soon as it answers The memory exists to remember how quickly a host responds, so a non-error response is evidence the host recovered even when decoding or verifying it fails afterwards. Deciding off the raw status code instead of the parsed result keeps those failures from leaving a responsive host stuck on the reduced timeout tier. Co-authored-by: Cursor <cursoragent@cursor.com> * Name the requestTimeoutManager in the fail-fast comment Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten comments on the Backend-shared failover and timeout manager Name the instances the comments describe so it's clear what is shared. Co-authored-by: Cursor <cursoragent@cursor.com> * Record API source timeouts before health checks * Test shared timeout state across backend lanes * Normalize URL timeout classification across platforms * Remove unused RemoteConfigAPI test hook * Skip HTTP stubs lane tests on watchOS * Treat the remote config fallback path as a fallback host request `FallbackPath` bakes the fallback host into its `serverHostURL`, but `fallbackUrlIndex` is nil on those requests, so `isFallbackURLRequest` was false and they were classified as main-source: with API sources enabled they drew the aggressive 15s/5s no-fallback tiers instead of the flat fallback timeout, their timeouts armed the per-host fail-fast memory for the fallback host, and their successes cleared the main source's entry. Add `isFallbackHostPath` so a path can declare that it targets a fallback host on its own, and use it alongside the index when picking the tier and updating the memory. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Rick van der Linden <rick.vanderlinden@revenuecat.com>
587 lines
17 KiB
Swift
587 lines
17 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
|
|
//
|
|
// HTTPRequestPath.swift
|
|
//
|
|
// Created by Nacho Soto on 8/8/23.
|
|
|
|
// swiftlint:disable file_length
|
|
|
|
import Foundation
|
|
|
|
protocol HTTPRequestPath {
|
|
|
|
/// The base URL for requests to this path.
|
|
static var serverHostURL: URL { get }
|
|
|
|
/// The fallback URLs to use when the main server is down.
|
|
///
|
|
/// Not all endpoints have a fallback URL, but some do.
|
|
var fallbackUrls: [URL] { get }
|
|
|
|
/// Whether requests to this path are authenticated.
|
|
var authenticated: Bool { get }
|
|
|
|
/// Whether requests to this path can be cached using `ETagManager`.
|
|
var shouldSendEtag: Bool { get }
|
|
|
|
/// Whether the endpoint will perform signature verification.
|
|
var supportsSignatureVerification: Bool { get }
|
|
|
|
/// Whether endpoint requires a nonce for signature verification.
|
|
var needsNonceForSigning: Bool { get }
|
|
|
|
/// The name of the endpoint.
|
|
var name: String { get }
|
|
|
|
/// The full relative path for this endpoint.
|
|
var relativePath: String { get }
|
|
|
|
/// The fallback relative path for this endpoint, if any.
|
|
var fallbackRelativePath: String? { get }
|
|
|
|
/// Whether this path resolves its base host from the API source provider (the main-API host
|
|
/// list with failover) rather than the static `serverHostURL`.
|
|
var usesAPISources: Bool { get }
|
|
|
|
/// Whether this path's `serverHostURL` is a fallback host rather than the main API host.
|
|
///
|
|
/// Requests to such a path are fallback attempts from the very first try, even though
|
|
/// `HTTPClient`'s own fallback walk never started, so they must not be treated as main-source
|
|
/// requests when picking a timeout or updating the per-host fail-fast memory.
|
|
var isFallbackHostPath: Bool { get }
|
|
|
|
/// Additional headers specific to this endpoint.
|
|
var additionalHeaders: HTTPRequest.Headers { get }
|
|
|
|
/// Provides endpoint-specific inputs for response signature verification.
|
|
var responseSignatureContextProvider: ResponseSignatureContextProvider { get }
|
|
}
|
|
|
|
/// Provides endpoint-specific inputs for backend response signature verification.
|
|
///
|
|
/// Most endpoints use the raw response body and request body. Specialized endpoints can override
|
|
/// either input.
|
|
protocol ResponseSignatureContextProvider {
|
|
|
|
/// Returns the response bytes that should be verified against the backend signature.
|
|
///
|
|
/// This may throw when deriving the signed payload requires validating response structure first.
|
|
func responsePayloadForSignature(from body: Data?, statusCode: HTTPStatusCode) throws -> Data?
|
|
|
|
/// Returns the request body component to include in signature parameters, if any.
|
|
func requestBodyForSignature(for request: HTTPRequest) -> HTTPRequestBody?
|
|
|
|
}
|
|
|
|
struct DefaultResponseSignatureContextProvider: ResponseSignatureContextProvider {
|
|
|
|
func responsePayloadForSignature(from body: Data?, statusCode: HTTPStatusCode) throws -> Data? {
|
|
return body
|
|
}
|
|
|
|
func requestBodyForSignature(for request: HTTPRequest) -> HTTPRequestBody? {
|
|
return request.requestBody
|
|
}
|
|
|
|
}
|
|
|
|
extension HTTPRequestPath {
|
|
|
|
var fallbackUrls: [URL] {
|
|
return []
|
|
}
|
|
|
|
var supportsFallbackURLs: Bool {
|
|
!fallbackUrls.isEmpty
|
|
}
|
|
|
|
var fallbackRelativePath: String? {
|
|
return nil
|
|
}
|
|
|
|
var usesAPISources: Bool {
|
|
return false
|
|
}
|
|
|
|
var isFallbackHostPath: Bool {
|
|
return false
|
|
}
|
|
|
|
var additionalHeaders: HTTPRequest.Headers {
|
|
return [:]
|
|
}
|
|
|
|
var responseSignatureContextProvider: ResponseSignatureContextProvider {
|
|
return DefaultResponseSignatureContextProvider()
|
|
}
|
|
|
|
var url: URL? { return self.url(proxyURL: nil) }
|
|
|
|
func url(proxyURL: URL? = nil, apiSourceURL: URL? = nil, fallbackUrlIndex: Int? = nil) -> URL? {
|
|
let baseURL: URL
|
|
if let proxyURL {
|
|
// When a Proxy URL is set, we don't support API sources or fallback URLs
|
|
guard fallbackUrlIndex == nil, apiSourceURL == nil else {
|
|
// This is to safe guard against a potential infinite loop if the caller mistakenly
|
|
// passes a proxyURL together with an apiSourceURL or a fallbackUrlIndex.
|
|
return nil
|
|
}
|
|
baseURL = proxyURL
|
|
} else if let fallbackUrlIndex {
|
|
return self.fallbackUrls[safe: fallbackUrlIndex]
|
|
} else if let apiSourceURL {
|
|
baseURL = apiSourceURL
|
|
} else {
|
|
baseURL = Self.serverHostURL
|
|
}
|
|
return URL(string: self.relativePath, relativeTo: baseURL)
|
|
}
|
|
}
|
|
|
|
// MARK: - Main paths
|
|
|
|
extension HTTPRequest {
|
|
|
|
enum Path: Hashable {
|
|
|
|
case getCustomerInfo(appUserID: String)
|
|
case getOfferings(appUserID: String)
|
|
case getIntroEligibility(appUserID: String)
|
|
case logIn
|
|
case postAttributionData(appUserID: String)
|
|
case postOfferForSigning
|
|
case postReceiptData
|
|
case postSubscriberAttributes(appUserID: String)
|
|
case postAdServicesToken(appUserID: String)
|
|
case health
|
|
case appHealthReport(appUserID: String)
|
|
case appHealthReportAvailability(appUserID: String)
|
|
case getProductEntitlementMapping
|
|
case getCustomerCenterConfig(appUserID: String)
|
|
case getVirtualCurrencies(appUserID: String)
|
|
case postRedeemWebPurchase
|
|
case postCreateTicket
|
|
case isPurchaseAllowedByRestoreBehavior(appUserID: String)
|
|
case rewardVerificationStatus(appUserID: String, clientTransactionID: String)
|
|
case remoteConfig(domain: String)
|
|
|
|
}
|
|
|
|
enum FallbackPath: Hashable {
|
|
|
|
case remoteConfig(domain: String)
|
|
|
|
}
|
|
|
|
enum FeatureEventsPath: Hashable {
|
|
|
|
case postEvents
|
|
|
|
}
|
|
|
|
enum DiagnosticsPath: Hashable {
|
|
|
|
case postDiagnostics
|
|
|
|
}
|
|
|
|
enum WebBillingPath: Hashable {
|
|
|
|
case getWebOfferingProducts(appUserID: String)
|
|
case getWebBillingProducts(userId: String, productIds: Set<String>)
|
|
|
|
}
|
|
|
|
enum AdPath: Hashable {
|
|
|
|
case postEvents
|
|
|
|
}
|
|
|
|
}
|
|
|
|
extension HTTPRequest.Path: HTTPRequestPath {
|
|
|
|
static var serverHostURL: URL {
|
|
SystemInfo.apiBaseURL
|
|
}
|
|
|
|
var usesAPISources: Bool {
|
|
return true
|
|
}
|
|
|
|
private static let fallbackServerHostURLs = [
|
|
URL(string: "https://api-production.8-lives-cat.io")
|
|
]
|
|
|
|
var fallbackRelativePath: String? {
|
|
switch self {
|
|
case .getOfferings:
|
|
return "/v1/offerings"
|
|
case .getProductEntitlementMapping:
|
|
return "/v1/product_entitlement_mapping"
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var fallbackUrls: [URL] {
|
|
guard let fallbackRelativePath = self.fallbackRelativePath else {
|
|
return []
|
|
}
|
|
|
|
return Self.fallbackServerHostURLs.compactMap { baseURL in
|
|
guard let baseURL = baseURL,
|
|
let fallbackUrl = URL(string: fallbackRelativePath, relativeTo: baseURL) else {
|
|
let errorMessage = "Invalid fallback URL configuration for path: \(self.name)"
|
|
assertionFailure(errorMessage)
|
|
Logger.error(errorMessage)
|
|
return nil
|
|
}
|
|
return fallbackUrl
|
|
}
|
|
}
|
|
|
|
var authenticated: Bool {
|
|
switch self {
|
|
case .getCustomerInfo,
|
|
.getOfferings,
|
|
.getIntroEligibility,
|
|
.logIn,
|
|
.postAttributionData,
|
|
.postOfferForSigning,
|
|
.postReceiptData,
|
|
.postSubscriberAttributes,
|
|
.postAdServicesToken,
|
|
.postRedeemWebPurchase,
|
|
.getProductEntitlementMapping,
|
|
.getCustomerCenterConfig,
|
|
.getVirtualCurrencies,
|
|
.appHealthReport,
|
|
.postCreateTicket,
|
|
.isPurchaseAllowedByRestoreBehavior,
|
|
.rewardVerificationStatus,
|
|
.remoteConfig:
|
|
return true
|
|
|
|
case .health,
|
|
.appHealthReportAvailability:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var shouldSendEtag: Bool {
|
|
switch self {
|
|
case .getCustomerInfo,
|
|
.getOfferings,
|
|
.getIntroEligibility,
|
|
.logIn,
|
|
.postAttributionData,
|
|
.postOfferForSigning,
|
|
.postReceiptData,
|
|
.postSubscriberAttributes,
|
|
.postAdServicesToken,
|
|
.postRedeemWebPurchase,
|
|
.getProductEntitlementMapping,
|
|
.getCustomerCenterConfig,
|
|
.getVirtualCurrencies,
|
|
.appHealthReport,
|
|
.postCreateTicket,
|
|
.isPurchaseAllowedByRestoreBehavior,
|
|
.rewardVerificationStatus:
|
|
return true
|
|
case .remoteConfig,
|
|
.health,
|
|
.appHealthReportAvailability:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var supportsSignatureVerification: Bool {
|
|
switch self {
|
|
case .getCustomerInfo,
|
|
.logIn,
|
|
.postReceiptData,
|
|
.health,
|
|
.getOfferings,
|
|
.getProductEntitlementMapping,
|
|
.getVirtualCurrencies,
|
|
.appHealthReport,
|
|
.appHealthReportAvailability,
|
|
.isPurchaseAllowedByRestoreBehavior,
|
|
.remoteConfig,
|
|
.rewardVerificationStatus:
|
|
return true
|
|
case .getIntroEligibility,
|
|
.postSubscriberAttributes,
|
|
.postAttributionData,
|
|
.postAdServicesToken,
|
|
.postOfferForSigning,
|
|
.postRedeemWebPurchase,
|
|
.getCustomerCenterConfig,
|
|
.postCreateTicket:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var needsNonceForSigning: Bool {
|
|
switch self {
|
|
case .getCustomerInfo,
|
|
.logIn,
|
|
.postReceiptData,
|
|
.getVirtualCurrencies,
|
|
.health,
|
|
.appHealthReportAvailability,
|
|
.isPurchaseAllowedByRestoreBehavior,
|
|
.remoteConfig,
|
|
.rewardVerificationStatus:
|
|
return true
|
|
case .getOfferings,
|
|
.getIntroEligibility,
|
|
.postSubscriberAttributes,
|
|
.postAttributionData,
|
|
.postAdServicesToken,
|
|
.postOfferForSigning,
|
|
.postRedeemWebPurchase,
|
|
.getProductEntitlementMapping,
|
|
.getCustomerCenterConfig,
|
|
.appHealthReport,
|
|
.postCreateTicket:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var responseSignatureContextProvider: ResponseSignatureContextProvider {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return RemoteConfigSignatureContextProvider()
|
|
default:
|
|
return DefaultResponseSignatureContextProvider()
|
|
}
|
|
}
|
|
|
|
var relativePath: String {
|
|
return "/v1/\(self.pathComponent)"
|
|
}
|
|
|
|
var pathComponent: String {
|
|
switch self {
|
|
case let .getCustomerInfo(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))"
|
|
|
|
case let .getOfferings(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/offerings"
|
|
|
|
case let .getIntroEligibility(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/intro_eligibility"
|
|
|
|
case let .appHealthReport(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/health_report"
|
|
|
|
case let .appHealthReportAvailability(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/health_report_availability"
|
|
|
|
case .logIn:
|
|
return "subscribers/identify"
|
|
|
|
case let .postAttributionData(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/attribution"
|
|
|
|
case let .postAdServicesToken(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/adservices_attribution"
|
|
|
|
case .postOfferForSigning:
|
|
return "offers"
|
|
|
|
case .postReceiptData:
|
|
return "receipts"
|
|
|
|
case let .postSubscriberAttributes(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/attributes"
|
|
|
|
case .health:
|
|
return "health"
|
|
|
|
case .getProductEntitlementMapping:
|
|
return "product_entitlement_mapping"
|
|
|
|
case let .getCustomerCenterConfig(appUserID):
|
|
return "customercenter/\(Self.escape(appUserID))"
|
|
|
|
case .postRedeemWebPurchase:
|
|
return "subscribers/redeem_purchase"
|
|
|
|
case let .getVirtualCurrencies(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/virtual_currencies"
|
|
|
|
case .postCreateTicket:
|
|
return "customercenter/support/create-ticket"
|
|
case let .isPurchaseAllowedByRestoreBehavior(appUserID):
|
|
return "subscribers/\(Self.escape(appUserID))/restore/eligibility"
|
|
|
|
case let .rewardVerificationStatus(appUserID, clientTransactionID):
|
|
return "subscribers/\(Self.escape(appUserID))/ads/reward_verifications/\(Self.escape(clientTransactionID))"
|
|
|
|
case let .remoteConfig(domain):
|
|
return "config/\(Self.escape(domain))"
|
|
}
|
|
}
|
|
|
|
var name: String {
|
|
switch self {
|
|
case .getCustomerInfo:
|
|
return "get_customer"
|
|
|
|
case .getOfferings:
|
|
return "get_offerings"
|
|
|
|
case .getIntroEligibility:
|
|
return "get_intro_eligibility"
|
|
|
|
case .logIn:
|
|
return "log_in"
|
|
|
|
case .postAttributionData:
|
|
return "post_attribution"
|
|
|
|
case .postAdServicesToken:
|
|
return "post_adservices_token"
|
|
|
|
case .postOfferForSigning:
|
|
return "post_offer_for_signing"
|
|
|
|
case .postReceiptData:
|
|
return "post_receipt"
|
|
|
|
case .postSubscriberAttributes:
|
|
return "post_attributes"
|
|
|
|
case .health:
|
|
return "post_health"
|
|
|
|
case .getProductEntitlementMapping:
|
|
return "get_product_entitlement_mapping"
|
|
|
|
case .getCustomerCenterConfig:
|
|
return "customer_center"
|
|
|
|
case .postRedeemWebPurchase:
|
|
return "post_redeem_web_purchase"
|
|
|
|
case .appHealthReport:
|
|
return "get_app_health_report"
|
|
|
|
case .getVirtualCurrencies:
|
|
return "get_virtual_currencies"
|
|
|
|
case .appHealthReportAvailability:
|
|
return "get_app_health_report_availability"
|
|
|
|
case .postCreateTicket:
|
|
return "post_create_ticket"
|
|
case .isPurchaseAllowedByRestoreBehavior:
|
|
return "post_restore_eligibility"
|
|
|
|
case .rewardVerificationStatus:
|
|
return "get_reward_verification_status"
|
|
|
|
case .remoteConfig:
|
|
return "remote_config"
|
|
}
|
|
}
|
|
|
|
var additionalHeaders: HTTPRequest.Headers {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return [
|
|
HTTPClient.RequestHeader.accept.rawValue: HTTPClient.rcContainerFormatAcceptHeaderValue,
|
|
HTTPClient.RequestHeader.acceptRCElementEncoding.rawValue:
|
|
HTTPClient.rcContainerFormatElementEncodingHeaderValue
|
|
]
|
|
default:
|
|
return [:]
|
|
}
|
|
}
|
|
|
|
private static func escape(_ appUserID: String) -> String {
|
|
return appUserID.trimmedAndEscaped
|
|
}
|
|
}
|
|
|
|
extension HTTPRequest.FallbackPath: HTTPRequestPath {
|
|
|
|
// swiftlint:disable:next force_unwrapping
|
|
static let serverHostURL = URL(string: "https://api-production.8-lives-cat.io")!
|
|
|
|
var isFallbackHostPath: Bool {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return true
|
|
}
|
|
}
|
|
|
|
var authenticated: Bool {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return true
|
|
}
|
|
}
|
|
|
|
var shouldSendEtag: Bool {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return true
|
|
}
|
|
}
|
|
|
|
var supportsSignatureVerification: Bool {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return true
|
|
}
|
|
}
|
|
|
|
var needsNonceForSigning: Bool {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var responseSignatureContextProvider: ResponseSignatureContextProvider {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return FallbackSignatureContextProvider()
|
|
}
|
|
}
|
|
|
|
var relativePath: String {
|
|
switch self {
|
|
case let .remoteConfig(domain):
|
|
return "/v1/config/\(domain.trimmedAndEscaped)"
|
|
}
|
|
}
|
|
|
|
var name: String {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return "remote_config_fallback"
|
|
}
|
|
}
|
|
|
|
var additionalHeaders: HTTPRequest.Headers {
|
|
switch self {
|
|
case .remoteConfig:
|
|
return [:]
|
|
}
|
|
}
|
|
|
|
}
|