Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Split.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1969,6 +1972,8 @@
C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestClientCustomDecoderTest.swift; sourceTree = "<group>"; };
C53EDFC32DD3B73A000DCDBC /* RestClientCustomFailureHandlerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestClientCustomFailureHandlerTest.swift; sourceTree = "<group>"; };
C53EDFC52DD3C53F000DCDBC /* SplitChangesErrorHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitChangesErrorHandlerTests.swift; sourceTree = "<group>"; };
C53EDFC72DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutdatedSplitProxyHandler.swift; sourceTree = "<group>"; };
C53EDFCA2DD3E257000DCDBC /* OutdatedSplitProxyHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutdatedSplitProxyHandlerTests.swift; sourceTree = "<group>"; };
C53F3C462DCB956900655753 /* SplitsSyncHelperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitsSyncHelperTest.swift; sourceTree = "<group>"; };
C53F3C4E2DCD110700655753 /* RuleBasedSegmentChangeProcessorStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegmentChangeProcessorStub.swift; sourceTree = "<group>"; };
C58F33722BDAC4AC00D66549 /* split_unsupported_matcher.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = split_unsupported_matcher.json; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2472,6 +2477,7 @@
3B6DEEEF20EA6AE30067435E /* HttpClient */ = {
isa = PBXGroup;
children = (
C53EDFC72DD3DD3E000DCDBC /* OutdatedSplitProxyHandler.swift */,
3B6DEEF120EA6AE30067435E /* HttpRequest.swift */,
3B6DEEF220EA6AE30067435E /* HttpDataResponse.swift */,
593225FE24A4D8FC00496D8B /* HttpResponse.swift */,
Expand Down Expand Up @@ -2847,6 +2853,7 @@
592C6AA6211B6C99002D120C /* SplitTests */ = {
isa = PBXGroup;
children = (
C53EDFCA2DD3E257000DCDBC /* OutdatedSplitProxyHandlerTests.swift */,
C53EDFC52DD3C53F000DCDBC /* SplitChangesErrorHandlerTests.swift */,
C53EDFC32DD3B73A000DCDBC /* RestClientCustomFailureHandlerTest.swift */,
C53EDFAF2DD38572000DCDBC /* RestClientCustomDecoderTest.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
156 changes: 156 additions & 0 deletions Split/Network/HttpClient/OutdatedSplitProxyHandler.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions Split/Storage/GeneralInfo/GeneralInfoDao.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum GeneralInfo: String {
case flagsSpec = "flagsSpec"
case rolloutCacheLastClearTimestamp = "rolloutCacheLastClearTimestamp"
case ruleBasedSegmentsChangeNumber = "ruleBasedSegmentsChangeNumber"
case lastProxyUpdateTimestamp = "lastProxyCheckTimestamp"
}

protocol GeneralInfoDao {
Expand Down
12 changes: 12 additions & 0 deletions Split/Storage/GeneralInfo/GeneralInfoStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
9 changes: 9 additions & 0 deletions SplitTests/Fake/Storage/GeneralInfoStorageMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,4 +49,12 @@ class GeneralInfoStorageMock: GeneralInfoStorage {
func setRuleBasedSegmentsChangeNumber(changeNumber: Int64) {
ruleBasedSegmentsChangeNumber = changeNumber
}

func getLastProxyUpdateTimestamp() -> Int64 {
return lastProxyUpdateTimestamp
}

func setLastProxyUpdateTimestamp(_ timestamp: Int64) {
lastProxyUpdateTimestamp = timestamp
}
}
127 changes: 127 additions & 0 deletions SplitTests/OutdatedSplitProxyHandlerTests.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading