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>
123 lines
5.0 KiB
Swift
123 lines
5.0 KiB
Swift
//
|
|
// SourceHealthChecker.swift
|
|
// RevenueCat
|
|
//
|
|
// Created by Toni Rico on 27/07/2026.
|
|
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
|
|
|
|
import Foundation
|
|
|
|
protocol SourceHealthCheckerType: AnyObject, Sendable {
|
|
|
|
/// Probes `<sourceBaseURL>/v1/health/connectivity`. Only a 2xx response is healthy; any other
|
|
/// response or a connection failure is not. `completion` may be invoked synchronously (on a
|
|
/// cache hit) or on a background queue.
|
|
func checkHealth(ofSourceBaseURL url: URL, completion: @escaping @Sendable (Bool) -> Void)
|
|
|
|
}
|
|
|
|
/// Checks whether a source is healthy by hitting its `/v1/health/connectivity` endpoint: only a 2xx
|
|
/// response counts as healthy; any other response or a connection failure does not. Checks are
|
|
/// on-demand only (callers probe after a request to the source has already failed, never proactively).
|
|
///
|
|
/// Thread-safe: concurrent checks for the same source share a single request (joiners are completed
|
|
/// with the owner's result), and results are cached briefly so a burst of failing requests fires
|
|
/// one probe.
|
|
final class SourceHealthChecker: SourceHealthCheckerType {
|
|
|
|
private static let healthPath = "v1/health/connectivity"
|
|
private static let resultValidity: TimeInterval = 10
|
|
private static let requestTimeout: TimeInterval = 5
|
|
|
|
private struct CachedResult {
|
|
let timestamp: Date
|
|
let isHealthy: Bool
|
|
}
|
|
|
|
private struct State {
|
|
/// A non-nil entry means a probe for that URL is in flight; joiners append their completions.
|
|
var pendingCompletions: [URL: [@Sendable (Bool) -> Void]] = [:]
|
|
var cachedResults: [URL: CachedResult] = [:]
|
|
}
|
|
|
|
private let state: Atomic<State> = .init(.init())
|
|
private let session: URLSession
|
|
private let dateProvider: DateProvider
|
|
|
|
init(dateProvider: DateProvider = DateProvider()) {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.timeoutIntervalForRequest = Self.requestTimeout
|
|
configuration.timeoutIntervalForResource = Self.requestTimeout
|
|
configuration.urlCache = nil
|
|
self.session = URLSession(configuration: configuration)
|
|
self.dateProvider = dateProvider
|
|
}
|
|
|
|
func checkHealth(ofSourceBaseURL url: URL, completion: @escaping @Sendable (Bool) -> Void) {
|
|
let healthURL = Self.healthURL(forSourceBaseURL: url)
|
|
|
|
enum Action {
|
|
case complete(isHealthy: Bool)
|
|
case joined
|
|
case probe
|
|
}
|
|
|
|
let action: Action = self.state.modify { state in
|
|
if let cached = state.cachedResults[healthURL],
|
|
self.dateProvider.now().timeIntervalSince(cached.timestamp) < Self.resultValidity {
|
|
return .complete(isHealthy: cached.isHealthy)
|
|
}
|
|
if state.pendingCompletions[healthURL] != nil {
|
|
state.pendingCompletions[healthURL]?.append(completion)
|
|
return .joined
|
|
}
|
|
state.pendingCompletions[healthURL] = [completion]
|
|
return .probe
|
|
}
|
|
|
|
switch action {
|
|
case let .complete(isHealthy):
|
|
completion(isHealthy)
|
|
case .joined:
|
|
break
|
|
case .probe:
|
|
self.performProbe(healthURL: healthURL)
|
|
}
|
|
}
|
|
|
|
private func performProbe(healthURL: URL) {
|
|
let task = self.session.dataTask(with: healthURL) { _, response, error in
|
|
let isHealthy: Bool
|
|
if let error = error {
|
|
Logger.verbose(Strings.network.api_source_health_check_failed_to_connect(url: healthURL,
|
|
error: error))
|
|
isHealthy = false
|
|
} else {
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
isHealthy = (200..<300).contains(statusCode)
|
|
Logger.verbose(Strings.network.api_source_health_check_completed(url: healthURL,
|
|
statusCode: statusCode,
|
|
isHealthy: isHealthy))
|
|
}
|
|
|
|
let completions: [@Sendable (Bool) -> Void] = self.state.modify { state in
|
|
state.cachedResults[healthURL] = CachedResult(timestamp: self.dateProvider.now(),
|
|
isHealthy: isHealthy)
|
|
return state.pendingCompletions.removeValue(forKey: healthURL) ?? []
|
|
}
|
|
completions.forEach { $0(isHealthy) }
|
|
}
|
|
task.resume()
|
|
}
|
|
|
|
/// `https://host/` and `https://host` both map to `https://host/v1/health/connectivity`.
|
|
private static func healthURL(forSourceBaseURL url: URL) -> URL {
|
|
return url.appendingPathComponent(Self.healthPath)
|
|
}
|
|
|
|
}
|
|
|
|
// @unchecked because the compiler can't verify the hand-rolled synchronization:
|
|
// all mutable state is guarded by `Atomic`.
|
|
extension SourceHealthChecker: @unchecked Sendable {}
|