Files
purchases-ios/Sources/Networking/APISourceFailover.swift
T
Toni RicoandClaude Fable 5 a268c9b286 feat(remote-config): add health checker and API source failover components (#7293)
* 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>
2026-07-27 17:33:58 +02:00

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