diff --git a/Split.xcodeproj/project.pbxproj b/Split.xcodeproj/project.pbxproj index fabbd3605..480a26ea0 100644 --- a/Split.xcodeproj/project.pbxproj +++ b/Split.xcodeproj/project.pbxproj @@ -1100,6 +1100,9 @@ C53EDFB02DD38572000DCDBC /* RestClientCustomDecoderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */; }; C53EDFC42DD3B73A000DCDBC /* RestClientCustomFailureHandlerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFC32DD3B73A000DCDBC /* RestClientCustomFailureHandlerTest.swift */; }; C53EDFC62DD3C53F000DCDBC /* SplitChangesErrorHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFC52DD3C53F000DCDBC /* SplitChangesErrorHandlerTests.swift */; }; + C53EDFC82DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFC72DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift */; }; + C53EDFC92DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFC72DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift */; }; + C53EDFCB2DD3E257000DCDBC /* OutdatedSplitProxyHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53EDFCA2DD3E257000DCDBC /* OutdatedSplitProxyHandlerTests.swift */; }; C53F3C472DCB956900655753 /* SplitsSyncHelperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53F3C462DCB956900655753 /* SplitsSyncHelperTest.swift */; }; C53F3C4F2DCD112400655753 /* RuleBasedSegmentChangeProcessorStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53F3C4E2DCD110700655753 /* RuleBasedSegmentChangeProcessorStub.swift */; }; C58F33732BDAC4AC00D66549 /* split_unsupported_matcher.json in Resources */ = {isa = PBXBuildFile; fileRef = C58F33722BDAC4AC00D66549 /* split_unsupported_matcher.json */; }; @@ -1969,6 +1972,8 @@ C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestClientCustomDecoderTest.swift; sourceTree = ""; }; C53EDFC32DD3B73A000DCDBC /* RestClientCustomFailureHandlerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestClientCustomFailureHandlerTest.swift; sourceTree = ""; }; C53EDFC52DD3C53F000DCDBC /* SplitChangesErrorHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitChangesErrorHandlerTests.swift; sourceTree = ""; }; + C53EDFC72DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutdatedSplitProxyHandler.swift; sourceTree = ""; }; + C53EDFCA2DD3E257000DCDBC /* OutdatedSplitProxyHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutdatedSplitProxyHandlerTests.swift; sourceTree = ""; }; C53F3C462DCB956900655753 /* SplitsSyncHelperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitsSyncHelperTest.swift; sourceTree = ""; }; C53F3C4E2DCD110700655753 /* RuleBasedSegmentChangeProcessorStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegmentChangeProcessorStub.swift; sourceTree = ""; }; C58F33722BDAC4AC00D66549 /* split_unsupported_matcher.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = split_unsupported_matcher.json; sourceTree = ""; }; @@ -2472,6 +2477,7 @@ 3B6DEEEF20EA6AE30067435E /* HttpClient */ = { isa = PBXGroup; children = ( + C53EDFC72DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift */, 3B6DEEF120EA6AE30067435E /* HttpRequest.swift */, 3B6DEEF220EA6AE30067435E /* HttpDataResponse.swift */, 593225FE24A4D8FC00496D8B /* HttpResponse.swift */, @@ -2847,6 +2853,7 @@ 592C6AA6211B6C99002D120C /* SplitTests */ = { isa = PBXGroup; children = ( + C53EDFCA2DD3E257000DCDBC /* OutdatedSplitProxyHandlerTests.swift */, C53EDFC52DD3C53F000DCDBC /* SplitChangesErrorHandlerTests.swift */, C53EDFC32DD3B73A000DCDBC /* RestClientCustomFailureHandlerTest.swift */, C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */, @@ -4308,6 +4315,7 @@ 3B6DEF2F20EA6AE50067435E /* EvaluatorError.swift in Sources */, 95CED0382B45D115005E3C34 /* LocalhostFileDataSource.swift in Sources */, 955892AB25C187EA00F67FBA /* CoreDataContextBuilder.swift in Sources */, + C53EDFC82DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift in Sources */, 95122A0E275822F600D93F90 /* TelemetryStats.swift in Sources */, 3B6DEF2D20EA6AE50067435E /* SplitConstants.swift in Sources */, C539CAB62D88C1F10050C732 /* RuleBasedSegment.swift in Sources */, @@ -4446,6 +4454,7 @@ C53EDFC62DD3C53F000DCDBC /* SplitChangesErrorHandlerTests.swift in Sources */, 9519A91727D6B9EE00278AEC /* AttributesStorageStub.swift in Sources */, 95ABF513293ABEC7006ED016 /* EventsStorageStub.swift in Sources */, + C53EDFCB2DD3E257000DCDBC /* OutdatedSplitProxyHandlerTests.swift in Sources */, 952FA1262A2E255600264AB5 /* FeatureFlagsSynchronizerStub.swift in Sources */, C5E967432D36CB2200112DAC /* GeneralInfoStorageTest.swift in Sources */, 5932260724A5325A00496D8B /* HttpTaskMock.swift in Sources */, @@ -4975,6 +4984,7 @@ 95B02D7628D0BDC20030EC8B /* EventStreamParser.swift in Sources */, 958AD2142CA458C100E3DD43 /* SyncSegmentsUpdateWorker.swift in Sources */, 95B02D7728D0BDC20030EC8B /* JwtTokenParser.swift in Sources */, + C53EDFC92DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift in Sources */, 95B02D7828D0BDC20030EC8B /* DefaultSseNotificationParser.swift in Sources */, 95B02D7928D0BDC20030EC8B /* SyncUpdateWorker.swift in Sources */, 954D4B2829F85EA00042C1D7 /* SplitHttpsAuthenticator.swift in Sources */, diff --git a/Split/Network/HttpClient/OutdatedSplitProxyHandler.swift b/Split/Network/HttpClient/OutdatedSplitProxyHandler.swift new file mode 100644 index 000000000..85041fda5 --- /dev/null +++ b/Split/Network/HttpClient/OutdatedSplitProxyHandler.swift @@ -0,0 +1,156 @@ +// +// OutdatedSplitProxyHandler.swift +// Split +// +// Created on 13/05/2025. +// Copyright © 2025 Split. All rights reserved. +// + +import Foundation + +/// Handles proxy spec fallback and recovery. +/// +/// This class manages the state machine that determines which spec version (latest or legacy) should be used +/// to communicate with the Split Proxy, based on observed proxy compatibility errors. +/// It ensures that the SDK can automatically fall back to a legacy spec when the proxy is outdated, periodically +/// attempt recovery, and return to normal operation if the proxy is upgraded. +/// +/// State Machine: +/// - NONE: Normal operation, using latest spec. Default state. +/// - FALLBACK: Entered when a proxy error is detected with the latest spec. SDK uses legacy spec and omits RB_SINCE param. +/// - RECOVERY: Entered after fallback interval elapses. SDK attempts to use latest spec again. If successful, returns to NONE. +/// +/// Transitions: +/// - NONE --(proxy error w/ latest spec)--> FALLBACK +/// - FALLBACK --(interval elapsed)--> RECOVERY +/// - RECOVERY --(success w/ latest spec)--> NONE +/// - RECOVERY --(proxy error)--> FALLBACK +/// - FALLBACK --(generic 400)--> FALLBACK (error surfaced, no state change) +/// +/// Only an explicit proxy outdated error triggers fallback. Generic 400s do not. +class OutdatedSplitProxyHandler { + + /// Enum representing the proxy handling types. + private enum ProxyHandlingType { + /// No action + case none + /// Switch to previous spec + case fallback + /// Attempt recovery + case recovery + } + + private static let PREVIOUS_SPEC = "1.2" + + private let latestSpec: String + private let previousSpec: String + private let forBackgroundSync: Bool + private let proxyCheckIntervalMillis: Int64 + + private var lastProxyCheckTimestamp: Int64 = 0 + private let generalInfoStorage: GeneralInfoStorage + private var currentProxyHandlingType: ProxyHandlingType = .none + + /// Initializes a new OutdatedSplitProxyHandler with default previous spec. + /// + /// - Parameters: + /// - flagSpec: The latest spec version + /// - forBackgroundSync: Whether this instance is for background sync + /// - generalInfoStorage: The general info storage + /// - proxyCheckIntervalMillis: The custom proxy check interval + convenience init(flagSpec: String, forBackgroundSync: Bool, generalInfoStorage: GeneralInfoStorage, proxyCheckIntervalMillis: Int64) { + self.init(flagSpec: flagSpec, previousSpec: OutdatedSplitProxyHandler.PREVIOUS_SPEC, forBackgroundSync: forBackgroundSync, generalInfoStorage: generalInfoStorage, proxyCheckIntervalMillis: proxyCheckIntervalMillis) + } + + /// Initializes a new OutdatedSplitProxyHandler with custom previous spec. + /// + /// - Parameters: + /// - flagSpec: The latest spec version + /// - previousSpec: The previous spec version + /// - forBackgroundSync: Whether this instance is for background sync + /// - generalInfoStorage: The general info storage + /// - proxyCheckIntervalMillis: The custom proxy check interval + init(flagSpec: String, previousSpec: String, forBackgroundSync: Bool, generalInfoStorage: GeneralInfoStorage, proxyCheckIntervalMillis: Int64) { + self.latestSpec = flagSpec + self.previousSpec = previousSpec + self.forBackgroundSync = forBackgroundSync + self.proxyCheckIntervalMillis = proxyCheckIntervalMillis + self.generalInfoStorage = generalInfoStorage + } + + /// Tracks a proxy error and updates the state machine accordingly. + func trackProxyError() { + if forBackgroundSync { + Logger.i("Background sync fetch; skipping proxy handling") + updateHandlingType(.none) + } else { + updateLastProxyCheckTimestamp(Date.nowMillis()) + updateHandlingType(.fallback) + } + } + + /// Performs a periodic proxy check to attempt recovery. + func performProxyCheck() { + if forBackgroundSync { + updateHandlingType(.none) + return + } + + let lastTimestamp = getLastProxyCheckTimestamp() + + if lastTimestamp == 0 { + updateHandlingType(.none) + } else if Date.nowMillis() - lastTimestamp > proxyCheckIntervalMillis { + Logger.i("Time since last check elapsed. Attempting recovery with latest spec: \(latestSpec)") + updateHandlingType(.recovery) + } else { + Logger.v("Have used proxy fallback mode; time since last check has not elapsed. Using previous spec") + updateHandlingType(.fallback) + } + } + + /// Resets the proxy check timestamp. + func resetProxyCheckTimestamp() { + updateLastProxyCheckTimestamp(0) + } + + /// Returns the current spec version based on the state machine. + /// + /// - Returns: The current spec version + func getCurrentSpec() -> String { + if currentProxyHandlingType == .fallback { + return previousSpec + } + return latestSpec + } + + /// Indicates whether the SDK is in fallback mode. + /// + /// - Returns: true if in fallback mode, false otherwise + func isFallbackMode() -> Bool { + return currentProxyHandlingType == .fallback + } + + /// Indicates whether the SDK is in recovery mode. + /// + /// - Returns: true if in recovery mode, false otherwise + func isRecoveryMode() -> Bool { + return currentProxyHandlingType == .recovery + } + + private func updateHandlingType(_ proxyHandlingType: ProxyHandlingType) { + currentProxyHandlingType = proxyHandlingType + } + + private func getLastProxyCheckTimestamp() -> Int64 { + if lastProxyCheckTimestamp == 0 { + lastProxyCheckTimestamp = generalInfoStorage.getLastProxyUpdateTimestamp() + } + return lastProxyCheckTimestamp + } + + private func updateLastProxyCheckTimestamp(_ newTimestamp: Int64) { + lastProxyCheckTimestamp = newTimestamp + generalInfoStorage.setLastProxyUpdateTimestamp(lastProxyCheckTimestamp) + } +} diff --git a/Split/Storage/GeneralInfo/GeneralInfoDao.swift b/Split/Storage/GeneralInfo/GeneralInfoDao.swift index 16cdb3b77..ad27610a6 100644 --- a/Split/Storage/GeneralInfo/GeneralInfoDao.swift +++ b/Split/Storage/GeneralInfo/GeneralInfoDao.swift @@ -17,6 +17,7 @@ enum GeneralInfo: String { case flagsSpec = "flagsSpec" case rolloutCacheLastClearTimestamp = "rolloutCacheLastClearTimestamp" case ruleBasedSegmentsChangeNumber = "ruleBasedSegmentsChangeNumber" + case lastProxyUpdateTimestamp = "lastProxyCheckTimestamp" } protocol GeneralInfoDao { diff --git a/Split/Storage/GeneralInfo/GeneralInfoStorage.swift b/Split/Storage/GeneralInfo/GeneralInfoStorage.swift index 0283b1077..a728f7d21 100644 --- a/Split/Storage/GeneralInfo/GeneralInfoStorage.swift +++ b/Split/Storage/GeneralInfo/GeneralInfoStorage.swift @@ -13,6 +13,10 @@ protocol GeneralInfoStorage { // Rule based segments methods func getRuleBasedSegmentsChangeNumber() -> Int64 func setRuleBasedSegmentsChangeNumber(changeNumber: Int64) + + // Proxy handling methods + func getLastProxyUpdateTimestamp() -> Int64 + func setLastProxyUpdateTimestamp(_ timestamp: Int64) } class DefaultGeneralInfoStorage: GeneralInfoStorage { @@ -66,4 +70,12 @@ class DefaultGeneralInfoStorage: GeneralInfoStorage { func setRuleBasedSegmentsChangeNumber(changeNumber: Int64) { generalInfoDao.update(info: .ruleBasedSegmentsChangeNumber, longValue: changeNumber) } + + func getLastProxyUpdateTimestamp() -> Int64 { + return generalInfoDao.longValue(info: .lastProxyUpdateTimestamp) ?? 0 + } + + func setLastProxyUpdateTimestamp(_ timestamp: Int64) { + generalInfoDao.update(info: .lastProxyUpdateTimestamp, longValue: timestamp) + } } diff --git a/SplitTests/Fake/Storage/GeneralInfoStorageMock.swift b/SplitTests/Fake/Storage/GeneralInfoStorageMock.swift index 8c454c333..75da1761a 100644 --- a/SplitTests/Fake/Storage/GeneralInfoStorageMock.swift +++ b/SplitTests/Fake/Storage/GeneralInfoStorageMock.swift @@ -8,6 +8,7 @@ class GeneralInfoStorageMock: GeneralInfoStorage { var splitsFilterQueryString: String = "" var flagsSpec = "" var ruleBasedSegmentsChangeNumber: Int64 = -1 + var lastProxyUpdateTimestamp: Int64 = 0 func getUpdateTimestamp() -> Int64 { return updateTimestamp @@ -48,4 +49,12 @@ class GeneralInfoStorageMock: GeneralInfoStorage { func setRuleBasedSegmentsChangeNumber(changeNumber: Int64) { ruleBasedSegmentsChangeNumber = changeNumber } + + func getLastProxyUpdateTimestamp() -> Int64 { + return lastProxyUpdateTimestamp + } + + func setLastProxyUpdateTimestamp(_ timestamp: Int64) { + lastProxyUpdateTimestamp = timestamp + } } diff --git a/SplitTests/OutdatedSplitProxyHandlerTests.swift b/SplitTests/OutdatedSplitProxyHandlerTests.swift new file mode 100644 index 000000000..c0a38db57 --- /dev/null +++ b/SplitTests/OutdatedSplitProxyHandlerTests.swift @@ -0,0 +1,127 @@ +// +// OutdatedSplitProxyHandlerTests.swift +// SplitTests +// +// Created on 13/05/2025. +// Copyright © 2025 Split. All rights reserved. +// + +import XCTest +@testable import Split + +class OutdatedSplitProxyHandlerTests: XCTestCase { + + private var mockStorage: GeneralInfoStorageMock! + private var handler: OutdatedSplitProxyHandler! + + private let latestSpec = "1.3" + private let previousSpec = "1.2" + private let proxyCheckInterval: Int64 = 3600000 // 1 hour in milliseconds + + override func setUp() { + super.setUp() + mockStorage = GeneralInfoStorageMock() + handler = OutdatedSplitProxyHandler( + flagSpec: latestSpec, + previousSpec: previousSpec, + forBackgroundSync: false, + generalInfoStorage: mockStorage, + proxyCheckIntervalMillis: proxyCheckInterval + ) + } + + override func tearDown() { + mockStorage = nil + handler = nil + super.tearDown() + } + + func testInitialStateIsNoneAndUsesLatestSpec() { + XCTAssertEqual(handler.getCurrentSpec(), latestSpec) + XCTAssertFalse(handler.isFallbackMode()) + XCTAssertFalse(handler.isRecoveryMode()) + } + + func testProxyErrorTriggersFallbackModeAndUsesPreviousSpec() { + handler.trackProxyError() + + XCTAssertEqual(handler.getCurrentSpec(), previousSpec) + XCTAssertTrue(handler.isFallbackMode()) + XCTAssertFalse(handler.isRecoveryMode()) + + XCTAssertNotEqual(mockStorage.lastProxyUpdateTimestamp, 0) + } + + func testPerformProxyCheckWithNoError() { + mockStorage.lastProxyUpdateTimestamp = 0 + handler.performProxyCheck() + + XCTAssertEqual(handler.getCurrentSpec(), latestSpec) + XCTAssertFalse(handler.isFallbackMode()) + XCTAssertFalse(handler.isRecoveryMode()) + } + + func testFallbackModePersistsUntilIntervalElapses() { + let currentTime = Date.nowMillis() + mockStorage.lastProxyUpdateTimestamp = currentTime - 1000 // 1 second ago + + handler.performProxyCheck() + + XCTAssertEqual(handler.getCurrentSpec(), previousSpec) + XCTAssertTrue(handler.isFallbackMode()) + XCTAssertFalse(handler.isRecoveryMode()) + } + + func testIntervalElapsedEntersRecoveryModeAndUsesLatestSpec() { + let currentTime = Date.nowMillis() + mockStorage.lastProxyUpdateTimestamp = currentTime - proxyCheckInterval - 1000 + + handler.performProxyCheck() + + XCTAssertEqual(handler.getCurrentSpec(), latestSpec) + XCTAssertFalse(handler.isFallbackMode()) + XCTAssertTrue(handler.isRecoveryMode()) + } + + func testRecoveryModeResetsToNoneAfterResetProxyCheckTimestamp() { + handler.trackProxyError() + XCTAssertTrue(handler.isFallbackMode()) + + handler.resetProxyCheckTimestamp() + + handler.performProxyCheck() + + XCTAssertEqual(handler.getCurrentSpec(), latestSpec) + XCTAssertFalse(handler.isFallbackMode()) + XCTAssertFalse(handler.isRecoveryMode()) + } + + func testSettingUpForBackgroundSyncIsAlwaysInNoneMode() { + let bgHandler = OutdatedSplitProxyHandler( + flagSpec: latestSpec, + previousSpec: previousSpec, + forBackgroundSync: true, + generalInfoStorage: mockStorage, + proxyCheckIntervalMillis: proxyCheckInterval + ) + + bgHandler.trackProxyError() + + XCTAssertEqual(bgHandler.getCurrentSpec(), latestSpec) + XCTAssertFalse(bgHandler.isFallbackMode()) + XCTAssertFalse(bgHandler.isRecoveryMode()) + } + + func testRecoveryToFallbackTransition() { + let currentTime = Date.nowMillis() + mockStorage.lastProxyUpdateTimestamp = currentTime - proxyCheckInterval - 1000 + handler.performProxyCheck() + XCTAssertTrue(handler.isRecoveryMode()) + + handler.trackProxyError() + + XCTAssertEqual(handler.getCurrentSpec(), previousSpec) + XCTAssertTrue(handler.isFallbackMode()) + XCTAssertFalse(handler.isRecoveryMode()) + } +} diff --git a/SplitTests/RestClientCustomFailureHandlerTest.swift b/SplitTests/RestClientCustomFailureHandlerTest.swift index 3db9e4f79..f321ffce2 100644 --- a/SplitTests/RestClientCustomFailureHandlerTest.swift +++ b/SplitTests/RestClientCustomFailureHandlerTest.swift @@ -77,6 +77,7 @@ class RestClientCustomFailureHandlerTest: XCTestCase { XCTAssertNotNil(error) XCTAssertTrue(customErrorHandled) + // Verify we got our custom error if let nsError = error as? NSError { XCTAssertEqual(nsError.domain, "CustomErrorDomain") XCTAssertEqual(nsError.code, 999) diff --git a/SplitTests/Storage/GeneralInfoStorageTest.swift b/SplitTests/Storage/GeneralInfoStorageTest.swift index 9d1839e4f..ecfd3c015 100644 --- a/SplitTests/Storage/GeneralInfoStorageTest.swift +++ b/SplitTests/Storage/GeneralInfoStorageTest.swift @@ -85,4 +85,21 @@ final class GeneralInfoStorageTest: XCTestCase { func testGetRuleBasedSegmentsChangeNumberReturnsMinusOneIfEntityIsNil() throws { XCTAssertEqual(generalInfoStorage.getRuleBasedSegmentsChangeNumber(), -1) } + + func testSetLastProxyUpdateTimestampSetsValueOnDao() throws { + let timestamp = Int(Date().timeIntervalSince1970).asInt64() + generalInfoStorage.setLastProxyUpdateTimestamp(timestamp) + + XCTAssertEqual(generalInfoDao.updatedLong, ["lastProxyCheckTimestamp": timestamp]) + } + + func testGetLastProxyUpdateTimestampGetsValueFromDao() throws { + generalInfoDao.update(info: .lastProxyUpdateTimestamp, longValue: 1234567) + + XCTAssertEqual(generalInfoStorage.getLastProxyUpdateTimestamp(), 1234567) + } + + func testGetLastProxyUpdateTimestampReturnsZeroIfEntityIsNil() throws { + XCTAssertEqual(generalInfoStorage.getLastProxyUpdateTimestamp(), 0) + } }