Commit Graph
8 Commits
Author SHA1 Message Date
Toni RicoandClaude Fable 5 8925524293 feat(remote-config): health-check-gated API source failover in HTTPClient (#7294)
* feat(remote-config): health-check-gated API source failover in HTTPClient

Wires the API source failover into the request pipeline, still behind the
usesRemoteConfigAPISources dangerous setting (internal and defaulting to
false, so released behavior is unchanged):

- Requests resolve their API source once (in start) and carry the handle,
  so a failure can report the exact source unhealthy and ETag/backoff
  retries keep targeting the same host.
- On a failure that points at the host, the source is health-checked
  before failing over: a healthy source surfaces the original error, an
  unhealthy one is reported and the request transparently retries on the
  next source (capped at 5 source attempts per request). The static
  per-endpoint fallback hosts stay the last resort.
- Unlike Android, device-connectivity errors (reusing
  NetworkError.isAllowedToRetryWithFallbackHost) skip the health check
  entirely and surface the original error: switching hosts can't fix a
  device that is offline.
- Both Backend HTTPClients share one APISourceFailover, so they walk one
  source list and share one health-check cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(remote-config): end-to-end API source failover integration tests

Port of purchases-android's BackendAPISourceFailoverIntegrationTest: real
Backend.getCustomerInfo through the production wiring (real HTTPClients,
shared APISourceFailover, real SourceHealthChecker probing over the URL
loading system) and a real RemoteConfigSourceProvider fed by an in-memory
sources topic, with OHHTTPStubs standing in for the source hosts and
recording exact per-host request order. Adds the iOS-only
device-connectivity scenario and a dangerous-setting-off wiring check on
top of Android's seven scenarios.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: model the request's API source as a single state enum

Replaces the three loosely coupled Request fields (apiSource,
apiSourceResolved, apiSourceAttemptCount) with one APISourceState enum
(unresolved / noSource / source(_, attemptCount:)), making inconsistent
combinations unrepresentable. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: adopt the narrowed APISourceFailover initializer

Passes just the usesRemoteConfigAPISources flag at the two construction
sites, following the signature change in the base branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: clarify why static fallback still runs when the source is healthy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(remote-config): raise the API failover restart interval to 2 hours

The api source list now returns to the primary source 2 hours after it
first failed over instead of 10 minutes. Timer tests derive their time
values from the constant so future changes are single-line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(remote-config): cover shared failover and probe-in-flight ordering

Addresses review feedback:
- Both HTTPClient lanes share one APISourceFailover: a failover on the
  main lane advances the source list the remote-config lane resolves.
- A request enqueued while a health check is in flight stays queued
  until the probe resolves and runs after the front-queued failover
  retry.
- Bind the source and attempt count in one pattern match at the
  failover guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: drop the APISourceState.attemptCount accessor

Its only caller pattern-matches instead, surfacing the "must already
target a source" assumption at the one place it matters rather than
papering over impossible states with a 0 default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:25:33 +02:00
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
c4d824d68c feat(remote-config): source API base host from remote-config sources (#7123)
* feat(remote-config): wire manager behind feature flag

* fix(remote-config): only mark refresh fresh after cache write

* feat(remote-config): source API base host from remote-config sources

Resolve the API base host from the shared RemoteConfigSourceProvider (with
the embedded defaults as a floor) instead of the hardcoded apiBaseURL, so
requests can fail over across API hosts. proxyURL and an overridden
apiBaseURL still pin the host and bypass API sources, and per-endpoint
fallback URLs are unchanged.

The provider is created once in the composition root (gated on
remoteConfigEnabled) and injected explicitly into Backend/HTTPClient; nil
preserves the existing SystemInfo.apiBaseURL behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): derive nullable API source provider and manager from remoteConfigEnabled

Create the RemoteConfigSourceProvider (and its shared on-disk cache) up front,
gated on remoteConfigEnabled, and build the RemoteConfigManager from the
provider's nullability after Backend exists so it can reuse backend.remoteConfigAPI.
Keeps a single shared source provider + disk cache across the networking layer
and the manager, and avoids touching the disk cache directory when remote config
is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): drop redundant remote-config wiring comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): drop redundant remote-config manager comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): simplify APISourceProviding doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): always wire API source provider, gate only disk cache

Create the RemoteConfigSourceProvider unconditionally and inject it into
Backend/HTTPClient so main-API host resolution runs even when remote config
is disabled (embedded default sources as the floor). Only the on-disk cache
and the real RemoteConfigManager stay gated on remoteConfigEnabled; the
provider's topic store is now optional (nil means defaults-only).

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): rename APISourceProviding to APISourceProviderType

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(remote-config): resolve web billing host from API sources

The web billing paths are hosted on the same api.revenuecat.com host as the
main API, so they should resolve their base host from the API source provider
too (with failover) instead of the static serverHostURL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): drop redundant web billing API-sources comments

The comments restated what the code and surrounding context already convey.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(remote-config): assert ETag retry stays on the API source host

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): drop APISourceProviderType, use RemoteConfigSourceProviderType directly

The networking layer depended on the narrow APISourceProviderType protocol, an extra
abstraction over RemoteConfigSourceProviderType that added little. Fold currentAPISource()
into RemoteConfigSourceProviderType and have HTTPClient/Backend depend on it directly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(remote-config): wire apiSourceProvider into dedicated remote-config lane

The main-into-branch merge left `HTTPClient.dedicatedRemoteConfig` (and its
call site) without the new `apiSourceProvider` argument, breaking compilation.
Thread the provider through so the dedicated `/config` lane also resolves its
base host from remote-config API sources, matching the main lane.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: gate remote-config API sources behind usesRemoteConfigAPISources

Resolve main-API request hosts from the remote-config API sources only when
the `usesRemoteConfigAPISources` dangerous setting is enabled. When disabled
(the default), requests keep targeting the static `SystemInfo.apiBaseURL`.

Enable the setting in the API-source `HTTPClient` tests and add a test
asserting an injected provider is ignored while the setting is off.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(test): pass apiSourceProvider in BackendRemoteConfigLaneTests HTTPClient init

The lane test constructed HTTPClient without the now-required apiSourceProvider
argument, breaking compilation of the unit test target after merging main.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Rick van der Linden <rick.vanderlinden@revenuecat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 08:56:01 +02:00
Rick 5fe329ff64 feat(remote-config): add APIs for reading topic/blob data from RemoteConfigManager (#7134)
* feat(remote-config): add read facade for blob data

* docs(remote-config): document read facade APIs

* refactor(remote-config): clarify refresh waiter continuations

* docs(remote-config): document manager refresh state

* fix(remote-config): update purchases test manager mock

* fix(remote-config): read facade cache data off caller executor

* fix(remote-config): avoid async work in nimble autoclosures

* fix(remote-config): honor disabled state after topic reads

* fix(remote-config): avoid async unwrap autoclosures

* test(remote-config): fix disabled blob read assertion

* refactor(remote-config): move topic store protocol near disk cache

* fix(remote-config): stop reads after manager closes

* fix(remote-config): avoid repeated read refreshes

* fix(remote-config): guard committed reads against cache clears

* test(remote-config): make stale read tests deterministic
2026-07-06 09:34:12 +02:00
Rick f320e95384 fix(remote-config): rearm exhausted blob sources + in memory knownRefs (#7120)
* fix(remote-config): rearm exhausted blob sources

* refactor(remote-config): clarify blob prefetch filtering

* fix(remote-config): validate cached blob refs on contains

* fix(remote-config): retry blob ref scans after failures
2026-07-03 08:22:14 +02:00
Antonio PallaresandCursor 22644c9a31 feat(remote-config): drive RemoteConfigSourceProvider from a topic store (#7106)
* feat(remote-config): drive RemoteConfigSourceProvider from a topic store

Make RemoteConfigSourceProvider read the `sources` topic lazily from a new
RemoteConfigTopicStoreType seam and rebuild its api/blob failover lists only
when that topic changes (compared via Equatable). New generations are token-
seeded past the previous one so stale reports from before a rebuild are ignored.

iOS counterpart of purchases-android #3677 (using Equatable instead of a
content hash, so the #3676 port is unnecessary).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(remote-config): bootstrap source provider with embedded defaults

While the `sources` topic is absent, RemoteConfigSourceProvider now falls back to
embedded default sources (https://api.revenuecat.com for api,
https://config.revenuecat-static.com/{blob_ref} for blob) so the SDK can reach
config before any config is fetched. A fetched topic replaces the defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): use topic.map for default source fallback

Replace the `topic == nil ? defaults : parseSources(...)` ternaries with
`topic.map { parseSources($0...) } ?? defaults`, letting parseSources take a
non-optional topic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): skip redundant restart when a rebuild occurred

rebuildIfNeeded now returns whether it rebuilt; restart(for:) only resets the
failover when no rebuild happened, since a rebuild already starts the new list
from the top.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): skip reportUnhealthy when a rebuild occurred

A rebuild discards the old generation, so a report tied to it is stale. Mirror
the restart guard and skip reportUnhealthy when rebuildIfNeeded rebuilt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(remote-config): fall back to defaults when the sources topic has no sources

Use the embedded defaults not only when the topic is absent but also when it
carries an empty/unusable source list. Also rename the `builtTopic` property to
`sourcesTopic` and simplify the sources() helper.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(remote-config): drop the embedded default blob source

Blob sources are only useful alongside a fetched config (which carries its own
blob sources), and blob URLs are project-scoped so there is no universal
fallback. Keep the embedded api default. Blob now stays empty until a config is
fetched.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(remote-config): add api.rc-backup.com as a second embedded api default

Ship both api.revenuecat.com and api.rc-backup.com as embedded fallbacks, at
priorities 100_000 and 100_001 (lower number wins) so any source from a fetched
`sources` topic outranks them and they only act as a last resort.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(remote-config): use trailing slash on embedded api default urls

The config endpoint returns api source urls with a trailing slash (e.g.
https://api.revenuecat.com/). Match that canonical form so the embedded
defaults dedupe correctly against config-provided sources.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 14:06:16 +02:00
Antonio PallaresandCursor 0e09c9fc83 refactor(remote-config): prefer lowest priority number for source ordering (#7112)
* refactor(remote-config): prefer lowest priority number for source ordering

Sources are now ordered lowest `priority` first, and dedupe keeps the
occurrence with the lowest priority number (highest priority).

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): clarify duplicate source log message

Note that the kept duplicate is the lowest priority number.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 11:01:07 +00:00
Antonio PallaresandCursor deb6f5b7cc feat(remote-config): add RemoteConfigSourceProvider for source failover (#7093)
* feat(remote-config): add RemoteConfigSourceProvider for source failover

Adds RemoteConfigSourceProvider, which orders config sources via
WeightedSourceSelector and hands out the current healthy endpoint,
falling back to the next one when an endpoint is reported unhealthy.
A per-handout UUID token discards stale unhealthy reports so a single
failing endpoint can't advance the order more than once. Not yet wired
into networking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(remote-config): trim RemoteConfigSourceProvider comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): make RemoteConfigSourceProvider an api/blob address book

Take a RemoteConfigSources object (api + blob), dedup each list by url, and
fail over the two kinds independently via a private SourceFailover wrapping
WeightedSourceSelector. Use the endpoint url as identity (dropping the UUID
token) so stale/concurrent reports are ignored, and build endpoints once
during dedup so reads never re-map. Add RemoteConfigSourceProviderType and
MockRemoteConfigSourceProvider for downstream mocking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): refine RemoteConfigSourceProvider endpoint model

Embed RemoteConfigSource in RemoteConfigEndpoint (forwarding url/priority/
weight) so endpoints are built once and never re-mapped, and compare endpoints
by kind+url. Make restart() per-kind, drop unused WeightedSource/Equatable
conformances and default inits, and collapse duplicate source urls to the
highest-priority occurrence (tie-broken by weight), logging conflicts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): drop redundant init and Equatable conformance

Rely on the synthesized memberwise init for RemoteConfigSources and remove the
unused Equatable conformance on RemoteConfigEndpoint (tests compare urls).

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): clarify source handle naming and strengthen concurrency test

Rename RemoteConfigEndpoint to RemoteConfigSourceHandle and its Kind enum to
Purpose, expose currentAPISource/currentBlobSource, and use a named parameter
in restart(for:). Also rewrite the concurrent test to deterministically assert
that many threads reporting the same source advance the provider exactly once.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): simplify RemoteConfigSourceProvider API

Drop the RemoteConfigSources wrapper in favor of apiSources/blobSources init
params, and replace currentAPISource/currentBlobSource with a single
getCurrent(for:) keyed by purpose, consistent with restart(for:).

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): log conflicting duplicate sources as a warning

Conflicting priority/weight on a shared URL signals misconfigured input, a
degraded state better surfaced via Logger.warn than Logger.debug.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(remote-config): ignore stale unhealthy reports across restart

Identify each handout with an opaque monotonic token that bumps on every
fallback and on restart, instead of using the source url. reportUnhealthy
only advances when the reported handle still carries the current token, so
reports left over from before a restart can no longer skip a source in the
new cycle. The token also subsumes the previous url-based dedup of stale or
concurrent reports within a cycle.

RemoteConfigSource now drives WeightedSourceSelector (it is the WeightedSource),
and SourceFailover stamps the current token onto each handle at handout time.
The handle is reduced to purpose + source + token (+ url accessor) and its
init is fileprivate so handles can only originate from getCurrent.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 12:05:37 +02:00