From 0b4adb4e4570197ebb63e5ba55ab21ec8c7e9207 Mon Sep 17 00:00:00 2001 From: "Gerges, Gabriella" Date: Mon, 27 Apr 2026 16:26:48 -0300 Subject: [PATCH 1/6] chore: classify definitive vs transient API errors Auth/configuration errors at HTTP 400/401/403 won't recover from a retry, while network/server errors usually do. Splitting them lets cache-first init decide whether to keep the cached config usable on background-refresh failures and whether explicit identity changes should invalidate the new user's cache. Adds APIError.isDefinitiveError as the single source of truth for that distinction. Inspired by: OpenFeature ADR 0009. Made-with: Cursor --- DevCycle/Networking/DevCycleService.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/DevCycle/Networking/DevCycleService.swift b/DevCycle/Networking/DevCycleService.swift index 8591bae..5e0f429 100644 --- a/DevCycle/Networking/DevCycleService.swift +++ b/DevCycle/Networking/DevCycleService.swift @@ -37,6 +37,15 @@ enum APIError: Error { return ["api"] } } + + var isDefinitiveError: Bool { + switch self { + case .StatusResponse(let status, _): + return status == 400 || status == 401 || status == 403 + case .NoResponse: + return false + } + } } struct NetworkingConstants { From 638fadf12b0495660e42d747e6d57e53f64f3054 Mon Sep 17 00:00:00 2001 From: "Gerges, Gabriella" Date: Thu, 30 Apr 2026 14:25:38 -0300 Subject: [PATCH 2/6] feat: add per-user cache clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheService can already save and read a per-user config; the missing piece is invalidation. Adds CacheServiceProtocol.clearConfigForUser(user:) and its implementation so the client can drop a specific user's persisted config without touching anyone else's entries — used by the upcoming identifyUser/resetUser definitive-error paths. Made-with: Cursor --- DevCycle/Models/Cache.swift | 6 ++++++ DevCycleTests/Networking/DevCycleServiceTests.swift | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/DevCycle/Models/Cache.swift b/DevCycle/Models/Cache.swift index d2a6d77..56542c3 100644 --- a/DevCycle/Models/Cache.swift +++ b/DevCycle/Models/Cache.swift @@ -12,6 +12,7 @@ protocol CacheServiceProtocol { func clearAnonUserId() func saveConfig(user: DevCycleUser, configToSave: Data?) func getConfig(user: DevCycleUser) -> UserConfig? + func clearConfigForUser(user: DevCycleUser) func getOrCreateAnonUserId() -> String func migrateLegacyCache() } @@ -58,6 +59,11 @@ class CacheService: CacheServiceProtocol { defaults.set(expiryDate, forKey: "\(key)\(CacheKeys.expiryDateSuffix)") } + func clearConfigForUser(user: DevCycleUser) { + let key = getConfigKeyPrefix(user: user) + cleanupCacheEntry(key: key) + } + func getConfig(user: DevCycleUser) -> UserConfig? { let key = getConfigKeyPrefix(user: user) diff --git a/DevCycleTests/Networking/DevCycleServiceTests.swift b/DevCycleTests/Networking/DevCycleServiceTests.swift index 6915c86..1706713 100644 --- a/DevCycleTests/Networking/DevCycleServiceTests.swift +++ b/DevCycleTests/Networking/DevCycleServiceTests.swift @@ -155,6 +155,10 @@ extension DevCycleServiceTests { return nil } + func clearConfigForUser(user: DevCycleUser) { + // TODO: update implementation for tests + } + func getOrCreateAnonUserId() -> String { return "mock-anon-id" } From cb4f15dc4fe95eed8c8102e60ffecb7aaa3080e8 Mon Sep 17 00:00:00 2001 From: "Gerges, Gabriella" Date: Mon, 27 Apr 2026 16:28:25 -0300 Subject: [PATCH 3/6] feat: cache-first initialization, background refresh, and onConfigUpdated observer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-start was previously gated on a network round trip — every launch paid full latency before any flag could be read. With this change, when a usable config is already persisted for the current user, setup() resolves immediately from cache and the network refresh runs in the background; on cache miss, the prior network-first behavior is preserved. - isConfigCached flag + hasUsableCachedConfig() public accessor - deliverInitializationComplete() helper standardizes setup completion on the main queue and drains queued config-completion handlers exactly once - performBackgroundRefresh() splits error handling: definitive (400/401/403) keeps cached values usable and notifies observers so the app can react; transient errors keep the cache silently - onConfigUpdated(_:) observer API with first-event replay buffer so handlers attached after setup() has already kicked off a background refresh still receive that first event (registration race) Inspired by: OpenFeature ADR 0009. Made-with: Cursor --- DevCycle/DevCycleClient.swift | 223 ++++++++++++++++++++++++++-------- 1 file changed, 174 insertions(+), 49 deletions(-) diff --git a/DevCycle/DevCycleClient.swift b/DevCycle/DevCycleClient.swift index b2d4097..95d8e4c 100644 --- a/DevCycle/DevCycleClient.swift +++ b/DevCycle/DevCycleClient.swift @@ -28,6 +28,7 @@ public typealias ClientInitializedHandler = (Error?) -> Void public typealias IdentifyCompletedHandler = (Error?, [String: Variable]?) -> Void public typealias FlushCompletedHandler = (Error?) -> Void public typealias CloseCompletedHandler = () -> Void +public typealias ConfigUpdatedHandler = (Error?) -> Void public class DevCycleClient { var sdkKey: String? @@ -37,7 +38,12 @@ public class DevCycleClient { var options: DevCycleOptions? var configCompletionHandlers: [ClientInitializedHandler] = [] var initialized: Bool = false + private var isConfigCached: Bool = false var eventQueue: EventQueue = EventQueue() + private let configUpdateQueue = DispatchQueue(label: "com.devcycle.ConfigUpdateQueue") + private var configUpdatedCallbacks: [ConfigUpdatedHandler] = [] + private var hasPendingConfigUpdate: Bool = false + private var pendingConfigUpdateError: Error? private let defaultFlushInterval: Int = 10000 private var flushEventsInterval: Double = 10.0 private var enableEdgeDB: Bool = false @@ -64,9 +70,8 @@ public class DevCycleClient { return } - // Only create new cache service if configCacheTTL is specified - if let configCacheTTL = self.options?.configCacheTTL { - self.cacheService = CacheService(configCacheTTL: configCacheTTL) + if let options = self.options { + self.cacheService = CacheService(configCacheTTL: options.configCacheTTL) } self.config = DVCConfig(sdkKey: sdkKey, user: user) @@ -135,9 +140,9 @@ public class DevCycleClient { #endif } - /** - Setup client with the DevCycleService and the callback - */ + /// On a cache hit, returns synchronously from the persisted config and refreshes + /// in the background (observe via `onConfigUpdated(_:)`). On a cache miss, falls + /// back to the network-first path. func setup(service: DevCycleServiceProtocol, callback: ClientInitializedHandler? = nil) { guard let user = self.user else { callback?(ClientError.MissingSDKKeyOrUser) @@ -145,56 +150,41 @@ public class DevCycleClient { } self.service = service - let _ = self.useCachedConfigForUser(user: user) + let cacheHit = self.useCachedConfigForUser(user: user) - self.service?.getConfig( - user: user, enableEdgeDB: self.enableEdgeDB, extraParams: nil, - completion: { [weak self] config, error in - guard let self = self else { return } + if cacheHit { + self.deliverInitializationComplete(error: nil, callback: callback) + self.performBackgroundRefresh() + } else { + self.service?.getConfig( + user: user, enableEdgeDB: self.enableEdgeDB, extraParams: nil, + completion: { [weak self] config, error in + guard let self = self else { return } - var finalError: Error? = error + var finalError: Error? = error - if let error = error { - Log.error("Error getting config: \(error)", tags: ["setup"]) + if let error = error { + Log.error("Error getting config: \(error)", tags: ["setup"]) - // If network failed but we have a cached config, don't return error - if self.config?.getUserConfig() != nil { - Log.info("Using cached config due to network error") - finalError = nil + if self.config?.getUserConfig() != nil { + Log.info("Using cached config due to network error") + finalError = nil + } + } else if let config = config { + Log.debug("Config: \(config)", tags: ["setup"]) + self.updateUserConfig(config) + } else { + Log.error("No config returned for setup", tags: ["setup"]) + finalError = ClientError.ConfigFetchFailed } - } else if let config = config { - Log.debug("Config: \(config)", tags: ["setup"]) - self.updateUserConfig(config) - } else { - Log.error("No config returned for setup", tags: ["setup"]) - finalError = ClientError.ConfigFetchFailed - } - if let config = config, - self.checkIfEdgeDBEnabled(config: config, enableEdgeDB: self.enableEdgeDB) - { - if !user.isAnonymous { - self.service?.saveEntity( - user: user, - completion: { data, response, error in - if error != nil { - Log.error( - "Error saving user entity for \(user). Error: \(String(describing: error))" - ) - } else { - Log.info("Saved user entity") - } - }) + if let config = config { + self.syncUserToEdgeDBIfEnabled(user: user, config: config) } - } - for handler in self.configCompletionHandlers { - handler(finalError) - } - callback?(finalError) - self.initialized = true - self.configCompletionHandlers = [] - }) + self.deliverInitializationComplete(error: finalError, callback: callback) + }) + } self.flushTimer = Timer.scheduledTimer( withTimeInterval: TimeInterval(self.flushEventsInterval), @@ -213,6 +203,20 @@ public class DevCycleClient { } } + private func syncUserToEdgeDBIfEnabled(user: DevCycleUser, config: UserConfig) { + guard !user.isAnonymous, + checkIfEdgeDBEnabled(config: config, enableEdgeDB: self.enableEdgeDB) + else { return } + + self.service?.saveEntity(user: user) { _, _, error in + if let error = error { + Log.error("Error saving user entity for \(user). Error: \(error)") + } else { + Log.info("Saved user entity") + } + } + } + func setSDKKey(_ sdkKey: String) { self.sdkKey = sdkKey } @@ -234,8 +238,12 @@ public class DevCycleClient { guard let self = self else { return } if let error = error { Log.error("Error getting config: \(error)", tags: ["refetchConfig"]) + if self.isDefinitiveError(error) { + self.notifyConfigUpdated(error: error) + } } else if let config = config { self.updateUserConfig(config) + self.notifyConfigUpdated(error: nil) } else { Log.error("No config returned for refetchConfig", tags: ["refetchConfig"]) } @@ -243,10 +251,70 @@ public class DevCycleClient { } } + private func deliverInitializationComplete( + error: Error?, + callback: ClientInitializedHandler? + ) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.initialized = true + // Snapshot then clear so a handler that calls setup() again can't re-fire itself. + let handlers = self.configCompletionHandlers + self.configCompletionHandlers = [] + for handler in handlers { + handler(error) + } + callback?(error) + } + } + + private func performBackgroundRefresh() { + guard !self.closed, let user = self.lastIdentifiedUser else { return } + self.service?.getConfig(user: user, enableEdgeDB: self.enableEdgeDB, extraParams: nil) { + [weak self] config, error in + guard let self = self, !self.closed else { return } + + guard user === self.lastIdentifiedUser else { + Log.warn( + "Background refresh result is for stale user context, ignoring", + tags: ["backgroundRefresh"]) + return + } + + if let error = error { + if self.isDefinitiveError(error) { + // Keep cached values usable on definitive errors; only TTL evicts the cache. + Log.error( + "Background refresh failed with definitive error, keeping cached config and notifying observers: \(error)", + tags: ["backgroundRefresh"]) + self.notifyConfigUpdated(error: error) + } else { + Log.warn( + "Background refresh failed with transient error, keeping cached config: \(error)", + tags: ["backgroundRefresh"]) + } + } else if let config = config { + self.updateUserConfig(config) + self.syncUserToEdgeDBIfEnabled(user: user, config: config) + self.notifyConfigUpdated(error: nil) + } else { + Log.warn( + "Background refresh returned nil config with no error", + tags: ["backgroundRefresh"]) + } + } + } + + private func isDefinitiveError(_ error: Error) -> Bool { + guard let apiError = error as? APIError else { return false } + return apiError.isDefinitiveError + } + private func updateUserConfig(_ config: UserConfig) { let oldSSEURL = self.config?.userConfig?.sse?.url self.config?.setUserConfig(config: config) - + self.isConfigCached = false + if let newSSEURL = config.sse?.url, self.options?.disableRealtimeUpdates != true, oldSSEURL != newSSEURL || self.sseConnection == nil @@ -567,6 +635,47 @@ public class DevCycleClient { return self.config?.getUserConfig()?.variables ?? [:] } + /// `true` while the in-memory config is from the persisted cache and no successful refresh has replaced it yet. + public func hasUsableCachedConfig() -> Bool { + return self.config?.getUserConfig() != nil && self.isConfigCached + } + + /// Invoked (main queue) on a successful refresh or a definitive error; transient errors are not delivered. + /// A refresh completing before any handler is registered is buffered and replayed once to the first registrant. + public func onConfigUpdated(_ callback: @escaping ConfigUpdatedHandler) { + var pendingError: Error? + var hasPending = false + configUpdateQueue.sync { + configUpdatedCallbacks.append(callback) + if hasPendingConfigUpdate { + pendingError = pendingConfigUpdateError + hasPending = true + hasPendingConfigUpdate = false + pendingConfigUpdateError = nil + } + } + if hasPending { + let errorToDeliver = pendingError + DispatchQueue.main.async { callback(errorToDeliver) } + } + } + + private func notifyConfigUpdated(error: Error? = nil) { + var callbacksSnapshot: [ConfigUpdatedHandler] = [] + configUpdateQueue.sync { + if configUpdatedCallbacks.isEmpty { + hasPendingConfigUpdate = true + pendingConfigUpdateError = error + return + } + callbacksSnapshot = configUpdatedCallbacks + } + guard !callbacksSnapshot.isEmpty else { return } + DispatchQueue.main.async { + for cb in callbacksSnapshot { cb(error) } + } + } + public func track(_ event: DevCycleEvent) { if self.closed { Log.error("DevCycleClient is closed, cannot log new events.") @@ -620,6 +729,11 @@ public class DevCycleClient { } Log.info("Closing DevCycleClient and flushing remaining events.") self.closed = true + configUpdateQueue.sync { + self.configUpdatedCallbacks.removeAll() + self.hasPendingConfigUpdate = false + self.pendingConfigUpdateError = nil + } self.flushTimer?.invalidate() self.flushEvents(callback: { error in callback?() @@ -735,9 +849,20 @@ public class DevCycleClient { let cachedConfig = cacheService.getConfig(user: user) { self.config?.setUserConfig(config: cachedConfig) + self.isConfigCached = true Log.debug("Loaded config from cache for user_id \(String(describing: user.userId))") + + // Bring up SSE from the cached URL; updateUserConfig() reconnects if the refresh changes it. + if cachedConfig.sse?.url != nil, + self.options?.disableRealtimeUpdates != true, + self.sseConnection == nil + { + self.setupSSEConnection() + } + return true } + self.isConfigCached = false return false } } From 8b7458da30e38bdf1f6475fb288432c5e594585e Mon Sep 17 00:00:00 2001 From: "Gerges, Gabriella" Date: Mon, 27 Apr 2026 16:28:39 -0300 Subject: [PATCH 4/6] feat: handle definitive errors on identifyUser/resetUser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity changes are user-initiated and definitive — silently serving another user's stale data on auth/config failure is the wrong default. This is the deliberate inverse of the background-refresh path, which keeps cached values usable on transient errors. - identifyUser: on definitive error (HTTP 400/401/403), clear the new user's persisted config and restore lastIdentifiedUser to the previous user. previousUser is captured at entry so any error path can roll back cleanly. - identifyUser: on transient error with a usable cached config, kick off a background refresh so the new user's data eventually reaches freshness without blocking the caller. - resetUser: on definitive error, clear the new anonymous user's cache, restore the previous anon user ID, and restore lastIdentifiedUser. Brings resetUser to parity with identifyUser. Inspired by: OpenFeature ADR 0009. Made-with: Cursor --- DevCycle/DevCycleClient.swift | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/DevCycle/DevCycleClient.swift b/DevCycle/DevCycleClient.swift index 95d8e4c..2495762 100644 --- a/DevCycle/DevCycleClient.swift +++ b/DevCycle/DevCycleClient.swift @@ -510,6 +510,7 @@ public class DevCycleClient { updateUser = user } + let previousUser = self.lastIdentifiedUser self.lastIdentifiedUser = user self.service?.getConfig( @@ -522,8 +523,16 @@ public class DevCycleClient { "Error getting config: \(error) for user_id \(String(describing: updateUser.userId))", tags: ["identify"]) - // Try to use cached config for the new user - // If we have a cached config, proceed without error + if self.isDefinitiveError(error) { + self.cacheService.clearConfigForUser(user: updateUser) + self.lastIdentifiedUser = previousUser + Log.error( + "Definitive error on identifyUser, clearing cache and restoring previous user: \(error)", + tags: ["identify"]) + callback?(error, nil) + return + } + if self.useCachedConfigForUser(user: updateUser), self.config?.getUserConfig() != nil { @@ -531,10 +540,11 @@ public class DevCycleClient { "Using cached config for identifyUser due to network error: \(error)", tags: ["identify"]) self.user = user + self.performBackgroundRefresh() callback?(nil, self.config?.getUserConfig()?.variables) return } else { - // No cached config available, return error and don't change client state + self.lastIdentifiedUser = previousUser Log.error( "Error getting config for identifyUser: \(error)", tags: ["identify"]) callback?(error, nil) @@ -577,6 +587,7 @@ public class DevCycleClient { public func resetUser(callback: IdentifyCompletedHandler? = nil) throws { self.flushEvents() + let previousUser = self.lastIdentifiedUser let cachedAnonUserId = self.cacheService.getAnonUserId() self.cacheService.clearAnonUserId() let anonUser = try DevCycleUser.builder().isAnonymous(true).build() @@ -590,7 +601,18 @@ public class DevCycleClient { if let error = error { Log.error("Error getting config for resetUser: \(error)", tags: ["reset"]) - // Restore previous anonymous user ID on error and don't change client state + if self.isDefinitiveError(error) { + self.cacheService.clearConfigForUser(user: anonUser) + if let previousAnonUserId = cachedAnonUserId { + self.cacheService.setAnonUserId(anonUserId: previousAnonUserId) + } + self.lastIdentifiedUser = previousUser + Log.error( + "Definitive error on resetUser, clearing anonymous user cache and restoring previous user: \(error)", + tags: ["reset"]) + callback?(error, nil) + return + } if let previousAnonUserId = cachedAnonUserId { self.cacheService.setAnonUserId(anonUserId: previousAnonUserId) } From 132b1c60ba88a8d428f0dac2016b8f2260d4aebf Mon Sep 17 00:00:00 2001 From: "Gerges, Gabriella" Date: Mon, 27 Apr 2026 16:28:52 -0300 Subject: [PATCH 5/6] test: cache-first init, observer, and user-change scenarios Adds DevCycleClientTests coverage for the cache-first initialization path: - Cache-hit fast path resolves before network completion - Definitive auth error on background refresh keeps cached values usable in-session and emits onConfigUpdated(error:) - Transient error keeps cache silently with no event - identifyUser definitive error clears the new user's cache and restores the previous user - onConfigUpdated fires whether registered before setup() or after the refresh has already landed (registration-race regression) Also stabilizes DevCycleUserTest.testConfigCacheTTLRespected for loaded CI runners (100ms TTL was flaky; bumped to 2s). Made-with: Cursor --- .../Models/DevCycleClientTests.swift | 388 ++++++++++++++++++ DevCycleTests/Models/DevCycleUserTest.swift | 12 +- 2 files changed, 392 insertions(+), 8 deletions(-) diff --git a/DevCycleTests/Models/DevCycleClientTests.swift b/DevCycleTests/Models/DevCycleClientTests.swift index 382da22..283be2e 100644 --- a/DevCycleTests/Models/DevCycleClientTests.swift +++ b/DevCycleTests/Models/DevCycleClientTests.swift @@ -965,15 +965,395 @@ class DevCycleClientTest: XCTestCase { defaults.removeObject(forKey: myUserCacheKey) defaults.removeObject(forKey: newUserCacheKey) } + + // MARK: - Cache-first initialization tests + + func testSetupUsesCacheHitFastPathAndCallsCallbackAsynchronously() { + let callbackExpectation = XCTestExpectation(description: "onInitialized fires after build returns") + var buildReturned = false + + let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) + let authErrorService = MockAuthErrorService() + + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( + authErrorService + ).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + + client.setup(service: authErrorService, callback: { _ in + XCTAssertTrue(buildReturned, "Callback should fire after setup() returns") + XCTAssertTrue(client.initialized, "Client should be initialized on cache hit") + callbackExpectation.fulfill() + }) + buildReturned = true + + wait(for: [callbackExpectation], timeout: 5.0) + client.close(callback: nil) + } + + func testCacheHitEstablishesSSEConnectionFromCachedSSEURL() { + let setupExpectation = XCTestExpectation(description: "setup completes via cache") + + // Regression: SSE used to wait for a successful background refresh, + // so offline cold-starts had cached values but no realtime updates. + let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) + let failedService = MockFailedConnectionService() + + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( + failedService + ).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + XCTAssertNil(client.sseConnection, "Precondition: no SSE connection before setup") + + client.setup(service: failedService, callback: { error in + XCTAssertNil(error, "Setup should succeed via cached config") + XCTAssertNotNil( + client.sseConnection, + "SSE connection should be established immediately on cache hit, even if the background refresh fails" + ) + setupExpectation.fulfill() + }) + + wait(for: [setupExpectation], timeout: 5.0) + client.close(callback: nil) + } + + func testBackgroundRefreshWithAuthErrorKeepsCachedValuesUsable() { + let refreshExpectation = XCTestExpectation(description: "onConfigUpdated fires with error after auth failure") + + let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) + let authErrorService = MockAuthErrorService() + + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( + authErrorService + ).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + + client.onConfigUpdated { error in + XCTAssertNotNil(error, "onConfigUpdated should receive an error on auth failure") + XCTAssertFalse(mockCacheService.clearConfigForUserCalled, "Persisted cache must not be cleared on background auth error") + XCTAssertTrue(client.hasUsableCachedConfig(), "hasUsableCachedConfig should remain true after auth error") + let boolVar = client.variable(key: "bool-var", defaultValue: false) + XCTAssertEqual(boolVar.value, true, "Cached value must still be served after a background auth error") + XCTAssertFalse(boolVar.isDefaulted, "Variable must not fall back to default after a background auth error") + refreshExpectation.fulfill() + } + + client.setup(service: authErrorService, callback: { error in + XCTAssertNil(error, "Setup should succeed via cached config") + }) + + wait(for: [refreshExpectation], timeout: 5.0) + client.close(callback: nil) + } + + func testBackgroundRefreshWithTransientErrorKeepsCache() { + let setupExpectation = XCTestExpectation(description: "setup completes via cache") + let noRefreshExpectation = XCTestExpectation(description: "onConfigUpdated not called on transient error") + noRefreshExpectation.isInverted = true + + let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) + let failedService = MockFailedConnectionService() + + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( + failedService + ).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + + client.onConfigUpdated { _ in + noRefreshExpectation.fulfill() + } + + client.setup(service: failedService, callback: { error in + XCTAssertNil(error, "Setup should succeed via cached config") + XCTAssertFalse(mockCacheService.clearConfigForUserCalled, "Cache should not be cleared on transient error") + setupExpectation.fulfill() + }) + + wait(for: [setupExpectation, noRefreshExpectation], timeout: 2.0) + client.close(callback: nil) + } + + func testIdentifyUserWithAuthErrorClearsNewUserCacheAndRestoresPreviousUser() { + let setupExpectation = XCTestExpectation(description: "setup completes") + let identifyExpectation = XCTestExpectation(description: "identifyUser returns auth error") + + let sequencedService = MockSequencedService(userConfig: self.userConfig) + + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( + sequencedService + ).build(onInitialized: nil) + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + + client.setup(service: sequencedService, callback: { _ in + setupExpectation.fulfill() + }) + wait(for: [setupExpectation], timeout: 5.0) + + let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) + client.cacheService = mockCacheService + + do { + let newUser = try DevCycleUser.builder().userId("new_user").build() + try client.identifyUser(user: newUser, callback: { error, variables in + XCTAssertNotNil(error, "identifyUser should return error on auth failure") + XCTAssertNil(variables, "identifyUser should not return variables on auth failure") + XCTAssertEqual(client.lastIdentifiedUser?.userId, self.user.userId, "Previous user should be restored") + XCTAssertTrue(mockCacheService.clearConfigForUserCalled, "New user cache should be cleared") + XCTAssertEqual(mockCacheService.clearedUser?.userId, "new_user") + identifyExpectation.fulfill() + }) + } catch { + XCTFail("identifyUser should not throw: \(error)") + identifyExpectation.fulfill() + } + + wait(for: [identifyExpectation], timeout: 5.0) + client.close(callback: nil) + } + + func testOnConfigUpdatedNotifiedOnBackgroundRefreshSuccess() { + let setupExpectation = XCTestExpectation(description: "setup completes via cache") + let refreshExpectation = XCTestExpectation(description: "onConfigUpdated fires with no error on success") + + let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) + let successService = MockService(userConfig: self.userConfig) + + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( + successService + ).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + + client.onConfigUpdated { error in + XCTAssertNil(error, "onConfigUpdated should receive no error on successful refresh") + XCTAssertFalse(client.hasUsableCachedConfig(), "isConfigCached should be false after fresh fetch") + refreshExpectation.fulfill() + } + + client.setup(service: successService, callback: { error in + XCTAssertNil(error) + setupExpectation.fulfill() + }) + + wait(for: [setupExpectation, refreshExpectation], timeout: 5.0) + client.close(callback: nil) + } + + func testOnConfigUpdatedReceivesEventWhenRegisteredAfterRefreshCompletes() { + let setupExpectation = XCTestExpectation(description: "setup completes via cache") + let refreshExpectation = XCTestExpectation(description: "buffered onConfigUpdated event is replayed to a late registrant") + + let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) + let successService = MockService(userConfig: self.userConfig) + + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( + successService + ).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + + client.setup(service: successService, callback: { error in + XCTAssertNil(error) + setupExpectation.fulfill() + }) + wait(for: [setupExpectation], timeout: 5.0) + + // Let the refresh complete before any handler is registered, so we exercise + // the replay path used by callers that attach onConfigUpdated after setup(). + let settle = XCTestExpectation(description: "refresh has had time to complete") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { settle.fulfill() } + wait(for: [settle], timeout: 1.0) + + client.onConfigUpdated { error in + XCTAssertNil(error, "Buffered onConfigUpdated event should be replayed to a late registrant") + refreshExpectation.fulfill() + } + + wait(for: [refreshExpectation], timeout: 5.0) + client.close(callback: nil) + } + + func testRefetchConfigNotifiesConfigUpdatedObservers() { + // SSE-driven refetches must reach onConfigUpdated, otherwise observers + // miss realtime updates. + let client = try! self.builder.user(self.user).sdkKey("my_sdk_key").build( + onInitialized: nil) + client.initialized = true + client.lastIdentifiedUser = self.user + + let observerExpectation = XCTestExpectation( + description: "onConfigUpdated fires after refetchConfig success") + client.onConfigUpdated { error in + XCTAssertNil(error, "refetchConfig success should pass nil error") + observerExpectation.fulfill() + } + + client.refetchConfig(sse: true, lastModified: 123, etag: "etag") + wait(for: [observerExpectation], timeout: 2.0) + client.close(callback: nil) + } + + func testRefetchConfigNotifiesObserversOnDefinitiveError() { + // SSE-driven 401/403 must reach onConfigUpdated so observers can react + // to a revoked SDK key, matching the policy in performBackgroundRefresh. + let authErrorService = MockAuthErrorService() + let client = try! self.builder.user(self.user).sdkKey("my_sdk_key") + .service(authErrorService).build(onInitialized: nil) + client.initialized = true + client.lastIdentifiedUser = self.user + + let observerExpectation = XCTestExpectation( + description: "onConfigUpdated fires with error after refetchConfig auth failure") + client.onConfigUpdated { error in + XCTAssertNotNil(error, "refetchConfig definitive error should pass error to observers") + observerExpectation.fulfill() + } + + client.refetchConfig(sse: true, lastModified: 123, etag: "etag") + wait(for: [observerExpectation], timeout: 2.0) + client.close(callback: nil) + } + + func testBackgroundRefreshSyncsUserToEdgeDBWhenEnabled() { + // Regression: cache-hit skips the initial fetch, so EdgeDB sync must run on bg refresh. + let setupExpectation = XCTestExpectation(description: "setup completes via cache") + let saveEntityExpectation = XCTestExpectation(description: "saveEntity called on background refresh") + + let edgeDBConfig = makeEdgeDBEnabledConfig() + let mockCacheService = MockCacheServiceWithConfig(userConfig: edgeDBConfig) + let buildService = MockFailedConnectionService() + let successService = MockService(userConfig: edgeDBConfig) + + let options = DevCycleOptions.builder().enableEdgeDB(true).build() + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key") + .options(options).service(buildService).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: self.user) + + client.setup(service: successService, callback: { error in + XCTAssertNil(error) + setupExpectation.fulfill() + }) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertEqual(successService.saveEntityCallCount, 1, "saveEntity must run after a successful background refresh when EdgeDB is enabled") + XCTAssertEqual(successService.userForSaveEntity?.userId, self.user.userId) + saveEntityExpectation.fulfill() + } + + wait(for: [setupExpectation, saveEntityExpectation], timeout: 5.0) + client.close(callback: nil) + } + + func testBackgroundRefreshSkipsEdgeDBForAnonymousUser() { + // EdgeDB only persists identified users; anonymous users must never be synced. + let setupExpectation = XCTestExpectation(description: "setup completes via cache") + let noSaveExpectation = XCTestExpectation(description: "saveEntity not called for anon user") + + let edgeDBConfig = makeEdgeDBEnabledConfig() + let mockCacheService = MockCacheServiceWithConfig(userConfig: edgeDBConfig) + let buildService = MockFailedConnectionService() + let successService = MockService(userConfig: edgeDBConfig) + let anonUser = try! DevCycleUser.builder().isAnonymous(true).build() + + let options = DevCycleOptions.builder().enableEdgeDB(true).build() + let client = try! self.builder.user(anonUser).sdkKey("dvc_mobile_my_sdk_key") + .options(options).service(buildService).build(onInitialized: nil) + client.cacheService = mockCacheService + client.config = DVCConfig(sdkKey: "dvc_mobile_my_sdk_key", user: anonUser) + + client.setup(service: successService, callback: { _ in + setupExpectation.fulfill() + }) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertEqual(successService.saveEntityCallCount, 0, "Anonymous users must not be synced to EdgeDB") + noSaveExpectation.fulfill() + } + + wait(for: [setupExpectation, noSaveExpectation], timeout: 5.0) + client.close(callback: nil) + } + + // MARK: - Helpers + + private func makeEdgeDBEnabledConfig() -> UserConfig { + let data = getConfigData(name: "test_config") + var dict = try! JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as! [String: Any] + var project = dict["project"] as! [String: Any] + project["settings"] = ["edgeDB": ["enabled": true]] + dict["project"] = project + return try! UserConfig(from: dict) + } } extension DevCycleClientTest { + private class MockAuthErrorService: DevCycleServiceProtocol { + func getConfig( + user: DevCycleUser, enableEdgeDB: Bool, extraParams: RequestParams?, + completion: @escaping ConfigCompletionHandler + ) { + DispatchQueue.main.async { + completion((nil, APIError.StatusResponse(status: 401, message: "Unauthorized"))) + } + } + func publishEvents(events: [DevCycleEvent], user: DevCycleUser, completion: @escaping PublishEventsCompletionHandler) { + DispatchQueue.main.async { completion((nil, nil, nil)) } + } + func saveEntity(user: DevCycleUser, completion: @escaping SaveEntityCompletionHandler) { + DispatchQueue.main.async { completion((nil, nil, nil)) } + } + func makeRequest(request: URLRequest, completion: @escaping DevCycle.CompletionHandler) { + DispatchQueue.main.async { completion((nil, nil, nil)) } + } + } + + /// First getConfig succeeds; all subsequent calls return 401. + private class MockSequencedService: DevCycleServiceProtocol { + private let userConfig: UserConfig + private var callCount = 0 + + init(userConfig: UserConfig) { + self.userConfig = userConfig + } + + func getConfig( + user: DevCycleUser, enableEdgeDB: Bool, extraParams: RequestParams?, + completion: @escaping ConfigCompletionHandler + ) { + callCount += 1 + if callCount == 1 { + DispatchQueue.main.async { completion((self.userConfig, nil)) } + } else { + DispatchQueue.main.async { + completion((nil, APIError.StatusResponse(status: 401, message: "Unauthorized"))) + } + } + } + func publishEvents(events: [DevCycleEvent], user: DevCycleUser, completion: @escaping PublishEventsCompletionHandler) { + DispatchQueue.main.async { completion((nil, nil, nil)) } + } + func saveEntity(user: DevCycleUser, completion: @escaping SaveEntityCompletionHandler) { + DispatchQueue.main.async { completion((nil, nil, nil)) } + } + func makeRequest(request: URLRequest, completion: @escaping DevCycle.CompletionHandler) { + DispatchQueue.main.async { completion((nil, nil, nil)) } + } + } + private class MockService: DevCycleServiceProtocol { public var publishCallCount: Int = 0 public var userForGetConfig: DevCycleUser? public var numberOfConfigCalls: Int = 0 public var eventPublishCount: Int = 0 public var userConfig: UserConfig? + public var saveEntityCallCount: Int = 0 + public var userForSaveEntity: DevCycleUser? init(userConfig: UserConfig? = nil) { self.userConfig = userConfig @@ -1006,6 +1386,8 @@ extension DevCycleClientTest { } func saveEntity(user: DevCycleUser, completion: @escaping SaveEntityCompletionHandler) { + self.saveEntityCallCount += 1 + self.userForSaveEntity = user DispatchQueue.main.async { completion((data: nil, urlResponse: nil, error: nil)) } @@ -1087,6 +1469,8 @@ extension DevCycleClientTest { private class MockCacheServiceWithConfig: CacheServiceProtocol { private let userConfig: UserConfig + var clearConfigForUserCalled = false + var clearedUser: DevCycleUser? init(userConfig: UserConfig) { self.userConfig = userConfig @@ -1099,6 +1483,10 @@ extension DevCycleClientTest { func getConfig(user: DevCycleUser) -> UserConfig? { return self.userConfig } + func clearConfigForUser(user: DevCycleUser) { + clearConfigForUserCalled = true + clearedUser = user + } func getOrCreateAnonUserId() -> String { return "mock-anon-id" } diff --git a/DevCycleTests/Models/DevCycleUserTest.swift b/DevCycleTests/Models/DevCycleUserTest.swift index 468e854..635a436 100644 --- a/DevCycleTests/Models/DevCycleUserTest.swift +++ b/DevCycleTests/Models/DevCycleUserTest.swift @@ -200,8 +200,8 @@ class DevCycleUserTest: XCTestCase { } func testConfigCacheTTLRespected() { - // Test with short TTL to verify expiration works - let shortTtlCacheService = CacheService(configCacheTTL: 100) // 100ms TTL + // 2s TTL is generous enough to survive loaded CI runners (100ms was flaky). + let shortTtlCacheService = CacheService(configCacheTTL: 2_000) let user = try! DevCycleUser.builder().userId("test_user").build() let configJson = createConfigJson( @@ -209,21 +209,17 @@ class DevCycleUserTest: XCTestCase { variableValue: "value") let configData = configJson.data(using: .utf8) - // Save config shortTtlCacheService.saveConfig(user: user, configToSave: configData) - // Should be able to retrieve immediately let validConfig = shortTtlCacheService.getConfig(user: user) XCTAssertNotNil(validConfig, "Config should be retrievable immediately after saving") - // Wait for TTL to expire let expectation = self.expectation(description: "Wait for TTL expiration") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { // Wait 200ms + DispatchQueue.main.asyncAfter(deadline: .now() + 2.2) { expectation.fulfill() } - waitForExpectations(timeout: 1.0, handler: nil) + waitForExpectations(timeout: 4.0, handler: nil) - // Try to retrieve after TTL expires (should fail) let expiredConfig = shortTtlCacheService.getConfig(user: user) XCTAssertNil(expiredConfig, "Expired config should not be returned") From fc06ad4ecb7bd1ca3ca9da99e5323c970aaa2b6a Mon Sep 17 00:00:00 2001 From: "Gerges, Gabriella" Date: Wed, 13 May 2026 14:27:31 -0300 Subject: [PATCH 6/6] fix: add simple op for clearConfigForUser and rename isDefinitiveError to isNonRetryableError --- DevCycle/DevCycleClient.swift | 18 +++++++-------- DevCycle/Networking/DevCycleService.swift | 2 +- .../Models/DevCycleClientTests.swift | 22 +++++++++++++------ .../Networking/DevCycleServiceTests.swift | 3 ++- 4 files changed, 27 insertions(+), 18 deletions(-) mode change 100644 => 100755 DevCycle/Networking/DevCycleService.swift mode change 100644 => 100755 DevCycleTests/Models/DevCycleClientTests.swift diff --git a/DevCycle/DevCycleClient.swift b/DevCycle/DevCycleClient.swift index 2495762..cfcc16c 100644 --- a/DevCycle/DevCycleClient.swift +++ b/DevCycle/DevCycleClient.swift @@ -238,7 +238,7 @@ public class DevCycleClient { guard let self = self else { return } if let error = error { Log.error("Error getting config: \(error)", tags: ["refetchConfig"]) - if self.isDefinitiveError(error) { + if self.isNonRetryableError(error) { self.notifyConfigUpdated(error: error) } } else if let config = config { @@ -282,10 +282,10 @@ public class DevCycleClient { } if let error = error { - if self.isDefinitiveError(error) { - // Keep cached values usable on definitive errors; only TTL evicts the cache. + if self.isNonRetryableError(error) { + // Keep cached values usable on non-retryable errors; only TTL evicts the cache. Log.error( - "Background refresh failed with definitive error, keeping cached config and notifying observers: \(error)", + "Background refresh failed with non-retryable error, keeping cached config and notifying observers: \(error)", tags: ["backgroundRefresh"]) self.notifyConfigUpdated(error: error) } else { @@ -305,9 +305,9 @@ public class DevCycleClient { } } - private func isDefinitiveError(_ error: Error) -> Bool { + private func isNonRetryableError(_ error: Error) -> Bool { guard let apiError = error as? APIError else { return false } - return apiError.isDefinitiveError + return apiError.isNonRetryableError } private func updateUserConfig(_ config: UserConfig) { @@ -523,11 +523,11 @@ public class DevCycleClient { "Error getting config: \(error) for user_id \(String(describing: updateUser.userId))", tags: ["identify"]) - if self.isDefinitiveError(error) { + if self.isNonRetryableError(error) { self.cacheService.clearConfigForUser(user: updateUser) self.lastIdentifiedUser = previousUser Log.error( - "Definitive error on identifyUser, clearing cache and restoring previous user: \(error)", + "Non-retryable error on identifyUser, clearing cache and restoring previous user: \(error)", tags: ["identify"]) callback?(error, nil) return @@ -601,7 +601,7 @@ public class DevCycleClient { if let error = error { Log.error("Error getting config for resetUser: \(error)", tags: ["reset"]) - if self.isDefinitiveError(error) { + if self.isNonRetryableError(error) { self.cacheService.clearConfigForUser(user: anonUser) if let previousAnonUserId = cachedAnonUserId { self.cacheService.setAnonUserId(anonUserId: previousAnonUserId) diff --git a/DevCycle/Networking/DevCycleService.swift b/DevCycle/Networking/DevCycleService.swift old mode 100644 new mode 100755 index 5e0f429..1853ffe --- a/DevCycle/Networking/DevCycleService.swift +++ b/DevCycle/Networking/DevCycleService.swift @@ -38,7 +38,7 @@ enum APIError: Error { } } - var isDefinitiveError: Bool { + var isNonRetryableError: Bool { switch self { case .StatusResponse(let status, _): return status == 400 || status == 401 || status == 403 diff --git a/DevCycleTests/Models/DevCycleClientTests.swift b/DevCycleTests/Models/DevCycleClientTests.swift old mode 100644 new mode 100755 index 283be2e..0483661 --- a/DevCycleTests/Models/DevCycleClientTests.swift +++ b/DevCycleTests/Models/DevCycleClientTests.swift @@ -1146,11 +1146,20 @@ class DevCycleClientTest: XCTestCase { func testOnConfigUpdatedReceivesEventWhenRegisteredAfterRefreshCompletes() { let setupExpectation = XCTestExpectation(description: "setup completes via cache") + let backgroundRefreshExpectation = XCTestExpectation( + description: "background getConfig completed (mock service)") let refreshExpectation = XCTestExpectation(description: "buffered onConfigUpdated event is replayed to a late registrant") let mockCacheService = MockCacheServiceWithConfig(userConfig: self.userConfig) let successService = MockService(userConfig: self.userConfig) + successService.onGetConfigFinished = { [weak successService] in + guard let successService = successService, + successService.numberOfConfigCalls == 2 + else { return } + backgroundRefreshExpectation.fulfill() + } + let client = try! self.builder.user(self.user).sdkKey("dvc_mobile_my_sdk_key").service( successService ).build(onInitialized: nil) @@ -1161,13 +1170,9 @@ class DevCycleClientTest: XCTestCase { XCTAssertNil(error) setupExpectation.fulfill() }) - wait(for: [setupExpectation], timeout: 5.0) - - // Let the refresh complete before any handler is registered, so we exercise - // the replay path used by callers that attach onConfigUpdated after setup(). - let settle = XCTestExpectation(description: "refresh has had time to complete") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { settle.fulfill() } - wait(for: [settle], timeout: 1.0) + // Wait until init and the cache-driven background refresh have both finished on the main queue, + // so onConfigUpdated registration exercises the buffered replay path + wait(for: [setupExpectation, backgroundRefreshExpectation], timeout: 5.0) client.onConfigUpdated { error in XCTAssertNil(error, "Buffered onConfigUpdated event should be replayed to a late registrant") @@ -1354,6 +1359,8 @@ extension DevCycleClientTest { public var userConfig: UserConfig? public var saveEntityCallCount: Int = 0 public var userForSaveEntity: DevCycleUser? + /// Invoked on the main queue immediately after each `getConfig` completion returns (for deterministic tests). + public var onGetConfigFinished: (() -> Void)? init(userConfig: UserConfig? = nil) { self.userConfig = userConfig @@ -1370,6 +1377,7 @@ extension DevCycleClientTest { DispatchQueue.main.async { completion((self.userConfig, nil)) + self.onGetConfigFinished?() } } diff --git a/DevCycleTests/Networking/DevCycleServiceTests.swift b/DevCycleTests/Networking/DevCycleServiceTests.swift index 1706713..0c04300 100644 --- a/DevCycleTests/Networking/DevCycleServiceTests.swift +++ b/DevCycleTests/Networking/DevCycleServiceTests.swift @@ -136,6 +136,7 @@ class DevCycleServiceTests: XCTestCase { extension DevCycleServiceTests { class MockCacheService: CacheServiceProtocol { var saveConfigCalled = false + var clearConfigForUserCalled = false func setAnonUserId(anonUserId: String) { // TODO: update implementation for tests @@ -156,7 +157,7 @@ extension DevCycleServiceTests { } func clearConfigForUser(user: DevCycleUser) { - // TODO: update implementation for tests + clearConfigForUserCalled = true } func getOrCreateAnonUserId() -> String {