mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* feat(remote-config): add health checker and API source failover components Standalone pieces for health-check-gated API source failover, ported from purchases-android. Nothing consumes them yet, so released behavior is unchanged: - SourceHealthChecker: probes <source>/v1/health/connectivity; only a 2xx is healthy. Concurrent checks per source share one request and results are cached for 10s. - APISourceFailover: the decision layer. After a failed request, the current source is health-checked: a 2xx health response means the source is healthy and the original error should surface instead of switching hosts; otherwise the source is reported unhealthy and the next one takes over. Malformed source URLs are reported unhealthy and skipped. - RemoteConfigSourceProvider: the API failover list restarts from the top 10 minutes after it first advances, so the SDK periodically retries the primary source (and re-arms an exhausted list) instead of sticking on a backup forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add new files to xcodeproj * refactor: address API source failover review feedback - APISourceFailover takes only the usesRemoteConfigAPISources flag it reads instead of the whole DangerousSettings (safe as a plain value: the settings chain is immutable per SDK instance). - Require a scheme and host when parsing source URLs: URL(string:) alone accepts scheme-less or relative strings on modern Foundation, which would build requests that can only fail instead of skipping and reporting the source as intended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
128 lines
5.7 KiB
Swift
128 lines
5.7 KiB
Swift
//
|
|
// APISourceFailover.swift
|
|
// RevenueCat
|
|
//
|
|
// Created by Toni Rico on 27/07/2026.
|
|
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
|
|
|
|
import Foundation
|
|
|
|
protocol APISourceFailoverType: AnyObject, Sendable {
|
|
|
|
/// The source a request to `path` should target, or `nil` to keep using the path's default host.
|
|
func currentSource(for path: HTTPRequestPath, isFallbackAttempt: Bool) -> APISourceFailover.ResolvedSource?
|
|
|
|
/// Decides what to do after a request to `source` failed in a way that may indicate a source
|
|
/// outage, health-checking the source to tell an outage apart from anything else.
|
|
func onRequestFailure(_ source: APISourceFailover.ResolvedSource,
|
|
completion: @escaping @Sendable (APISourceFailover.FailureDecision) -> Void)
|
|
|
|
}
|
|
|
|
/// Decides which API source a request should target and whether a failed request should fail over to
|
|
/// the next source. Only failures that point at the host (connection-level failures and 5xx
|
|
/// responses, never device-connectivity errors or 4xx — the caller gates on
|
|
/// `NetworkError.isAllowedToRetryWithFallbackHost`) can trigger a failover, and only after a health
|
|
/// check against the current source fails: a 2xx health response means the source is healthy (the
|
|
/// request failure was something else, e.g. endpoint-specific), so the original error surfaces
|
|
/// without switching hosts. A source whose health check returns non-2xx or cannot complete is
|
|
/// reported unhealthy and the next one takes over.
|
|
final class APISourceFailover: APISourceFailoverType {
|
|
|
|
/// A source handle plus its parsed base URL; only parseable sources are ever handed out.
|
|
struct ResolvedSource {
|
|
|
|
let handle: RemoteConfigSourceHandle
|
|
let url: URL
|
|
|
|
}
|
|
|
|
enum FailureDecision {
|
|
|
|
/// The current source failed its health check; retry the request on the associated source.
|
|
case retryNextSource(ResolvedSource)
|
|
|
|
/// The current source is healthy, so failing over is pointless: surface the original error.
|
|
case sourceHealthy
|
|
|
|
/// Every source has been reported unhealthy; surface the original error.
|
|
case sourcesExhausted
|
|
|
|
}
|
|
|
|
private let usesRemoteConfigAPISources: Bool
|
|
private let sourceProvider: RemoteConfigSourceProviderType
|
|
private let healthChecker: SourceHealthCheckerType
|
|
|
|
/// - Parameter usesRemoteConfigAPISources: the `usesRemoteConfigAPISources` dangerous setting.
|
|
/// Taken as a plain value because the whole settings chain is immutable per SDK instance.
|
|
init(usesRemoteConfigAPISources: Bool,
|
|
sourceProvider: RemoteConfigSourceProviderType,
|
|
healthChecker: SourceHealthCheckerType) {
|
|
self.usesRemoteConfigAPISources = usesRemoteConfigAPISources
|
|
self.sourceProvider = sourceProvider
|
|
self.healthChecker = healthChecker
|
|
}
|
|
|
|
/// The source a request to `path` should target, or `nil` to keep using the path's default host.
|
|
///
|
|
/// API sources apply only when: the `usesRemoteConfigAPISources` dangerous setting is enabled, no
|
|
/// proxy is configured (a proxy pins every request to itself), this is not an endpoint
|
|
/// fallback-host attempt, the path opts in via `usesAPISources`, and `SystemInfo.apiBaseURL`
|
|
/// still holds its default (an override pins the host, e.g. in tests).
|
|
func currentSource(for path: HTTPRequestPath, isFallbackAttempt: Bool) -> ResolvedSource? {
|
|
guard self.usesRemoteConfigAPISources,
|
|
SystemInfo.proxyURL == nil,
|
|
!isFallbackAttempt,
|
|
path.usesAPISources,
|
|
SystemInfo.apiBaseURL == SystemInfo.defaultApiBaseURL else {
|
|
return nil
|
|
}
|
|
return self.currentResolvedSource()
|
|
}
|
|
|
|
func onRequestFailure(_ source: ResolvedSource,
|
|
completion: @escaping @Sendable (FailureDecision) -> Void) {
|
|
self.healthChecker.checkHealth(ofSourceBaseURL: source.url) { isHealthy in
|
|
if isHealthy {
|
|
Logger.debug(Strings.network.api_source_healthy_despite_failure(host: source.handle.url))
|
|
completion(.sourceHealthy)
|
|
return
|
|
}
|
|
self.sourceProvider.reportUnhealthy(source.handle)
|
|
completion(self.currentResolvedSource().map(FailureDecision.retryNextSource) ?? .sourcesExhausted)
|
|
}
|
|
}
|
|
|
|
/// The provider's current source with its URL parsed. Sources with malformed URLs are reported
|
|
/// unhealthy and skipped, so they participate in failover instead of silently pinning requests to
|
|
/// the default host. The walk ends when the provider runs out of sources.
|
|
private func currentResolvedSource() -> ResolvedSource? {
|
|
while let handle = self.sourceProvider.currentAPISource() {
|
|
if let url = Self.parseAbsoluteURL(handle.url) {
|
|
return ResolvedSource(handle: handle, url: url)
|
|
}
|
|
Logger.warn(Strings.network.skipping_malformed_api_source_url(url: handle.url))
|
|
self.sourceProvider.reportUnhealthy(handle)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Parses `string` as an absolute base URL. `URL(string:)` alone is too lenient (on modern
|
|
/// Foundation almost any string parses, e.g. scheme-less hosts or relative paths), so a scheme
|
|
/// and a host are additionally required — anything less would build requests that can only fail.
|
|
private static func parseAbsoluteURL(_ string: String) -> URL? {
|
|
guard let url = URL(string: string),
|
|
url.scheme != nil,
|
|
url.host != nil else {
|
|
return nil
|
|
}
|
|
return url
|
|
}
|
|
|
|
}
|
|
|
|
// @unchecked because the compiler can't verify it: all dependencies are themselves thread-safe and
|
|
// this class holds no mutable state.
|
|
extension APISourceFailover: @unchecked Sendable {}
|