Skip to content
Open
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
174 changes: 171 additions & 3 deletions FlagsmithClient/Classes/Flagsmith.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
self.updateFlagStreamAndLastUpdatedAt(thisIdentity.flags)
completion(.success(thisIdentity.flags))
case let .failure(error):
self.handleFlagsError(error, completion: completion)
self.handleFlagsErrorForIdentity(error, identity: identity, completion: completion)
}
}
}
Expand All @@ -171,13 +171,181 @@
}

private func handleFlagsError(_ error: any Error, completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void) {
if defaultFlags.isEmpty {
completion(.failure(error))
// Priority: 1. Try cached flags, 2. Fall back to default flags, 3. Return error

// First, try to get cached flags if caching is enabled
if cacheConfig.useCache {
if let cachedFlags = getCachedFlags() {
completion(.success(cachedFlags))
return
}
}

// If no cached flags available, try default flags
if !defaultFlags.isEmpty {
completion(.success(defaultFlags))
} else {
completion(.failure(error))
}
}

private func handleFlagsErrorForIdentity(_ error: any Error, identity: String, completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void) {

Check warning on line 192 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Line Length Violation: Line should be 140 characters or less; currently it has 153 characters (line_length)
// Priority: 1. Try cached flags for identity, 2. Try general cached flags, 3. Fall back to default flags, 4. Return error

// First, try to get cached flags for the specific identity if caching is enabled
if cacheConfig.useCache {
if let cachedFlags = getCachedFlags(forIdentity: identity) {
completion(.success(cachedFlags))
return
}

// If no identity-specific cache, try general flags cache
if let cachedFlags = getCachedFlags() {
completion(.success(cachedFlags))
return
}
}

// If no cached flags available, try default flags
if !defaultFlags.isEmpty {
completion(.success(defaultFlags))
} else {
completion(.failure(error))
}
}

private func getCachedFlags() -> [Flag]? {
let cache = cacheConfig.cache

// Create request for general flags
let request = URLRequest(url: baseURL.appendingPathComponent("flags/"))

// Check if we have a cached response
if let cachedResponse = cache.cachedResponse(for: request) {
// Check if cache is still valid based on TTL
if isCacheValid(cachedResponse: cachedResponse) {
do {
let flags = try JSONDecoder().decode([Flag].self, from: cachedResponse.data)
return flags
} catch {
print("Flagsmith - Failed to decode cached flags: \(error.localizedDescription)")
return nil
}
}
}

return nil
}

private func getCachedFlags(forIdentity identity: String) -> [Flag]? {
let cache = cacheConfig.cache

// Create request for identity-specific flags
let identityURL = baseURL.appendingPathComponent("identities/")
guard var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false) else {
return nil
}
components.queryItems = [URLQueryItem(name: "identifier", value: identity)]

guard let url = components.url else { return nil }
let request = URLRequest(url: url)

// Check if we have a cached response
if let cachedResponse = cache.cachedResponse(for: request) {
// Check if cache is still valid based on TTL
if isCacheValid(cachedResponse: cachedResponse) {
do {
let identity = try JSONDecoder().decode(Identity.self, from: cachedResponse.data)
return identity.flags
} catch {
print("Flagsmith - Failed to decode cached identity flags: \(error.localizedDescription)")
return nil
}
}
}

return nil
}

private func isCacheValid(cachedResponse: CachedURLResponse) -> Bool {
guard let httpResponse = cachedResponse.response as? HTTPURLResponse else { return false }

// Check if we have a cache control header
if let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String {
// First check for no-cache and no-store directives (case-insensitive, token-aware)
if hasNoCacheDirective(in: cacheControl) {
return false
}

if let maxAge = extractMaxAge(from: cacheControl) {
// Check if cache is still valid based on max-age
if let dateString = httpResponse.allHeaderFields["Date"] as? String,
let date = HTTPURLResponse.dateFormatter.date(from: dateString) {
let age = Date().timeIntervalSince(date)
return age < maxAge
}
}
}

// If no cache control, validate against configured TTL
if cacheConfig.cacheTTL > 0 {
if let dateString = httpResponse.allHeaderFields["Date"] as? String,
let date = HTTPURLResponse.dateFormatter.date(from: dateString) {
let age = Date().timeIntervalSince(date)
return age < cacheConfig.cacheTTL
}
// No Date header, be conservative
return false
}
// TTL of 0 means infinite

return true

}

private func extractMaxAge(from cacheControl: String) -> TimeInterval? {
let components = cacheControl.split(separator: ",")
for component in components {
let trimmed = component.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("max-age=") {
let maxAgeString = String(trimmed.dropFirst(8))
return TimeInterval(maxAgeString)
}
}
return nil
}

private func hasNoCacheDirective(in cacheControl: String) -> Bool {
let components = cacheControl.split(separator: ",")
for component in components {
let trimmed = component.trimmingCharacters(in: .whitespaces)
let directiveTokens = trimmed.split(separator: "=").first?.split(separator: ";").first
guard let directiveToken = directiveTokens else { continue }

let directive = directiveToken.trimmingCharacters(in: .whitespaces).lowercased()
if directive == "no-cache" || directive == "no-store" {
return true
}
}
return false
}
}

// MARK: - HTTPURLResponse Extensions

extension HTTPURLResponse {
static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(abbreviation: "GMT")
return formatter
}()
}

// MARK: - Public API Methods

extension Flagsmith {
/// Check feature exists and is enabled optionally for a specific identity
///
/// - Parameters:
Expand Down Expand Up @@ -366,7 +534,7 @@
switch result {
case let .failure(error):
print("Flagsmith - Error getting flags in SSE stream: \(error.localizedDescription)")
case .success(_):

Check warning on line 537 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Empty Enum Arguments Violation: Arguments can be omitted when matching enums with associated values if they are not used (empty_enum_arguments)
break
}
}
Expand All @@ -380,7 +548,7 @@
func updateFlagStreamAndLastUpdatedAt(_ flags: [Flag]) {
// Update the flag stream
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
if (flags != lastFlags) {

Check warning on line 551 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Control Statement Violation: `if`, `for`, `guard`, `switch`, `while`, and `catch` statements shouldn't unnecessarily wrap their conditionals or arguments in parentheses (control_statement)
flagStreamContinuation?.yield(flags)
}
}
Expand All @@ -407,4 +575,4 @@

/// Skip API if there is a cache available
public var skipAPI: Bool = false
}

Check warning on line 578 in FlagsmithClient/Classes/Flagsmith.swift

View workflow job for this annotation

GitHub Actions / swift-lint

File Length Violation: File should contain 400 lines or less: currently contains 578 (file_length)
151 changes: 151 additions & 0 deletions FlagsmithClient/Tests/APIErrorCacheFallbackCoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//
// APIErrorCacheFallbackCoreTests.swift
// FlagsmithClientTests
//
// Core API error scenarios with cache fallback behavior
// Customer requirement: "When fetching flags and we run into an error and have a valid cache we should return the cached flags"
//

@testable import FlagsmithClient
import XCTest

final class APIErrorCacheFallbackCoreTests: FlagsmithClientTestCase {
var testCache: URLCache!

override func setUp() {
super.setUp()

// Create isolated cache for testing
testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil)

// Reset Flagsmith to known state using TestConfig
Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key"
Flagsmith.shared.baseURL = TestConfig.baseURL
Flagsmith.shared.enableRealtimeUpdates = false
Flagsmith.shared.cacheConfig.useCache = true
Flagsmith.shared.cacheConfig.skipAPI = false
Flagsmith.shared.cacheConfig.cache = testCache
Flagsmith.shared.cacheConfig.cacheTTL = 300
Flagsmith.shared.defaultFlags = []
}

override func tearDown() {
testCache.removeAllCachedResponses()
Flagsmith.shared.cacheConfig.useCache = false
Flagsmith.shared.cacheConfig.skipAPI = false
Flagsmith.shared.apiKey = nil
super.tearDown()
}

// MARK: - Test Helper Methods

private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) throws -> CachedURLResponse {
let jsonData = try JSONEncoder().encode(flags)
let httpResponse = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/json",
"Cache-Control": "max-age=300"
]
)!
return CachedURLResponse(response: httpResponse, data: jsonData)
}

// MARK: - Core API Error Cache Fallback Tests

func testGetFeatureFlags_APIFailure_ReturnsCachedFlags() throws {
// This test works with mock data, no real API key needed
let expectation = expectation(description: "API failure with cache fallback")

// Create mock flags for cache
let cachedFlags = [
Flag(featureName: "cached_feature_1", value: .string("cached_value_1"), enabled: true, featureType: "FLAG"),
Flag(featureName: "cached_feature_2", value: .string("cached_value_2"), enabled: false, featureType: "FLAG")
]

// Pre-populate cache with successful response
var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/"))
mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key")
let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags)
testCache.storeCachedResponse(cachedResponse, for: mockRequest)

// Mock API failure by using invalid API key
Flagsmith.shared.apiKey = "invalid-api-key"

// Request should fail API call but return cached flags
Flagsmith.shared.getFeatureFlags { result in
switch result {
case .success(let flags):
// Should return cached flags
XCTAssertEqual(flags.count, 2, "Should return cached flags")
XCTAssertEqual(flags.first?.feature.name, "cached_feature_1", "Should return first cached flag")
XCTAssertEqual(flags.last?.feature.name, "cached_feature_2", "Should return second cached flag")
case .failure(let error):
XCTFail("Should return cached flags instead of failing: \(error)")
}
expectation.fulfill()
}

wait(for: [expectation], timeout: 5.0)
}

func testGetFeatureFlags_APIFailure_NoCache_ReturnsDefaultFlags() throws {
// This test works with mock data, no real API key needed
let expectation = expectation(description: "API failure with no cache, default flags fallback")

// Set up default flags
let defaultFlags = [
Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG")
]
Flagsmith.shared.defaultFlags = defaultFlags

// Ensure no cache exists
testCache.removeAllCachedResponses()

// Mock API failure
Flagsmith.shared.apiKey = "invalid-api-key"

// Request should fail API call and return default flags
Flagsmith.shared.getFeatureFlags { result in
switch result {
case .success(let flags):
// Should return default flags
XCTAssertEqual(flags.count, 1, "Should return default flags")
XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag")
case .failure(let error):
XCTFail("Should return default flags instead of failing: \(error)")
}
expectation.fulfill()
}

wait(for: [expectation], timeout: 5.0)
}

func testGetFeatureFlags_APIFailure_NoCacheNoDefaults_ReturnsError() throws {
// This test works with mock data, no real API key needed
let expectation = expectation(description: "API failure with no cache and no defaults")

// Ensure no cache and no defaults
testCache.removeAllCachedResponses()
Flagsmith.shared.defaultFlags = []

// Mock API failure
Flagsmith.shared.apiKey = "invalid-api-key"

// Request should fail
Flagsmith.shared.getFeatureFlags { result in
switch result {
case .success(_):
XCTFail("Should fail when no cache and no defaults")
case .failure(let error):
// Should return the original API error
XCTAssertTrue(error is FlagsmithError, "Should return FlagsmithError")
}
expectation.fulfill()
}

wait(for: [expectation], timeout: 5.0)
}
}
Loading
Loading