Skip to content

Commit 4a0f4e4

Browse files
authored
feat: cache priority and tests (#96)
1 parent 702d5a5 commit 4a0f4e4

9 files changed

+987
-4
lines changed

FlagsmithClient/Classes/Flagsmith.swift

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public final class Flagsmith: @unchecked Sendable {
148148
self.updateFlagStreamAndLastUpdatedAt(thisIdentity.flags)
149149
completion(.success(thisIdentity.flags))
150150
case let .failure(error):
151-
self.handleFlagsError(error, completion: completion)
151+
self.handleFlagsErrorForIdentity(error, identity: identity, completion: completion)
152152
}
153153
}
154154
}
@@ -171,13 +171,181 @@ public final class Flagsmith: @unchecked Sendable {
171171
}
172172

173173
private func handleFlagsError(_ error: any Error, completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void) {
174-
if defaultFlags.isEmpty {
175-
completion(.failure(error))
174+
// Priority: 1. Try cached flags, 2. Fall back to default flags, 3. Return error
175+
176+
// First, try to get cached flags if caching is enabled
177+
if cacheConfig.useCache {
178+
if let cachedFlags = getCachedFlags() {
179+
completion(.success(cachedFlags))
180+
return
181+
}
182+
}
183+
184+
// If no cached flags available, try default flags
185+
if !defaultFlags.isEmpty {
186+
completion(.success(defaultFlags))
176187
} else {
188+
completion(.failure(error))
189+
}
190+
}
191+
192+
private func handleFlagsErrorForIdentity(_ error: any Error, identity: String, completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void) {
193+
// Priority: 1. Try cached flags for identity, 2. Try general cached flags, 3. Fall back to default flags, 4. Return error
194+
195+
// First, try to get cached flags for the specific identity if caching is enabled
196+
if cacheConfig.useCache {
197+
if let cachedFlags = getCachedFlags(forIdentity: identity) {
198+
completion(.success(cachedFlags))
199+
return
200+
}
201+
202+
// If no identity-specific cache, try general flags cache
203+
if let cachedFlags = getCachedFlags() {
204+
completion(.success(cachedFlags))
205+
return
206+
}
207+
}
208+
209+
// If no cached flags available, try default flags
210+
if !defaultFlags.isEmpty {
177211
completion(.success(defaultFlags))
212+
} else {
213+
completion(.failure(error))
214+
}
215+
}
216+
217+
private func getCachedFlags() -> [Flag]? {
218+
let cache = cacheConfig.cache
219+
220+
// Create request for general flags
221+
let request = URLRequest(url: baseURL.appendingPathComponent("flags/"))
222+
223+
// Check if we have a cached response
224+
if let cachedResponse = cache.cachedResponse(for: request) {
225+
// Check if cache is still valid based on TTL
226+
if isCacheValid(cachedResponse: cachedResponse) {
227+
do {
228+
let flags = try JSONDecoder().decode([Flag].self, from: cachedResponse.data)
229+
return flags
230+
} catch {
231+
print("Flagsmith - Failed to decode cached flags: \(error.localizedDescription)")
232+
return nil
233+
}
234+
}
178235
}
236+
237+
return nil
179238
}
239+
240+
private func getCachedFlags(forIdentity identity: String) -> [Flag]? {
241+
let cache = cacheConfig.cache
242+
243+
// Create request for identity-specific flags
244+
let identityURL = baseURL.appendingPathComponent("identities/")
245+
guard var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false) else {
246+
return nil
247+
}
248+
components.queryItems = [URLQueryItem(name: "identifier", value: identity)]
249+
250+
guard let url = components.url else { return nil }
251+
let request = URLRequest(url: url)
252+
253+
// Check if we have a cached response
254+
if let cachedResponse = cache.cachedResponse(for: request) {
255+
// Check if cache is still valid based on TTL
256+
if isCacheValid(cachedResponse: cachedResponse) {
257+
do {
258+
let identity = try JSONDecoder().decode(Identity.self, from: cachedResponse.data)
259+
return identity.flags
260+
} catch {
261+
print("Flagsmith - Failed to decode cached identity flags: \(error.localizedDescription)")
262+
return nil
263+
}
264+
}
265+
}
266+
267+
return nil
268+
}
269+
270+
private func isCacheValid(cachedResponse: CachedURLResponse) -> Bool {
271+
guard let httpResponse = cachedResponse.response as? HTTPURLResponse else { return false }
272+
273+
// Check if we have a cache control header
274+
if let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String {
275+
// First check for no-cache and no-store directives (case-insensitive, token-aware)
276+
if hasNoCacheDirective(in: cacheControl) {
277+
return false
278+
}
279+
280+
if let maxAge = extractMaxAge(from: cacheControl) {
281+
// Check if cache is still valid based on max-age
282+
if let dateString = httpResponse.allHeaderFields["Date"] as? String,
283+
let date = HTTPURLResponse.dateFormatter.date(from: dateString) {
284+
let age = Date().timeIntervalSince(date)
285+
return age < maxAge
286+
}
287+
}
288+
}
289+
290+
// If no cache control, validate against configured TTL
291+
if cacheConfig.cacheTTL > 0 {
292+
if let dateString = httpResponse.allHeaderFields["Date"] as? String,
293+
let date = HTTPURLResponse.dateFormatter.date(from: dateString) {
294+
let age = Date().timeIntervalSince(date)
295+
return age < cacheConfig.cacheTTL
296+
}
297+
// No Date header, be conservative
298+
return false
299+
}
300+
// TTL of 0 means infinite
301+
302+
return true
303+
304+
}
305+
306+
private func extractMaxAge(from cacheControl: String) -> TimeInterval? {
307+
let components = cacheControl.split(separator: ",")
308+
for component in components {
309+
let trimmed = component.trimmingCharacters(in: .whitespaces)
310+
if trimmed.hasPrefix("max-age=") {
311+
let maxAgeString = String(trimmed.dropFirst(8))
312+
return TimeInterval(maxAgeString)
313+
}
314+
}
315+
return nil
316+
}
317+
318+
private func hasNoCacheDirective(in cacheControl: String) -> Bool {
319+
let components = cacheControl.split(separator: ",")
320+
for component in components {
321+
let trimmed = component.trimmingCharacters(in: .whitespaces)
322+
let directiveTokens = trimmed.split(separator: "=").first?.split(separator: ";").first
323+
guard let directiveToken = directiveTokens else { continue }
324+
325+
let directive = directiveToken.trimmingCharacters(in: .whitespaces).lowercased()
326+
if directive == "no-cache" || directive == "no-store" {
327+
return true
328+
}
329+
}
330+
return false
331+
}
332+
}
333+
334+
// MARK: - HTTPURLResponse Extensions
335+
336+
extension HTTPURLResponse {
337+
static let dateFormatter: DateFormatter = {
338+
let formatter = DateFormatter()
339+
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
340+
formatter.locale = Locale(identifier: "en_US_POSIX")
341+
formatter.timeZone = TimeZone(abbreviation: "GMT")
342+
return formatter
343+
}()
344+
}
345+
346+
// MARK: - Public API Methods
180347

348+
extension Flagsmith {
181349
/// Check feature exists and is enabled optionally for a specific identity
182350
///
183351
/// - Parameters:
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
//
2+
// APIErrorCacheFallbackCoreTests.swift
3+
// FlagsmithClientTests
4+
//
5+
// Core API error scenarios with cache fallback behavior
6+
// Customer requirement: "When fetching flags and we run into an error and have a valid cache we should return the cached flags"
7+
//
8+
9+
@testable import FlagsmithClient
10+
import XCTest
11+
12+
final class APIErrorCacheFallbackCoreTests: FlagsmithClientTestCase {
13+
var testCache: URLCache!
14+
15+
override func setUp() {
16+
super.setUp()
17+
18+
// Create isolated cache for testing
19+
testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil)
20+
21+
// Reset Flagsmith to known state using TestConfig
22+
Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key"
23+
Flagsmith.shared.baseURL = TestConfig.baseURL
24+
Flagsmith.shared.enableRealtimeUpdates = false
25+
Flagsmith.shared.cacheConfig.useCache = true
26+
Flagsmith.shared.cacheConfig.skipAPI = false
27+
Flagsmith.shared.cacheConfig.cache = testCache
28+
Flagsmith.shared.cacheConfig.cacheTTL = 300
29+
Flagsmith.shared.defaultFlags = []
30+
}
31+
32+
override func tearDown() {
33+
testCache.removeAllCachedResponses()
34+
Flagsmith.shared.cacheConfig.useCache = false
35+
Flagsmith.shared.cacheConfig.skipAPI = false
36+
Flagsmith.shared.apiKey = nil
37+
super.tearDown()
38+
}
39+
40+
// MARK: - Test Helper Methods
41+
42+
private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) throws -> CachedURLResponse {
43+
let jsonData = try JSONEncoder().encode(flags)
44+
let httpResponse = HTTPURLResponse(
45+
url: request.url!,
46+
statusCode: 200,
47+
httpVersion: "HTTP/1.1",
48+
headerFields: [
49+
"Content-Type": "application/json",
50+
"Cache-Control": "max-age=300"
51+
]
52+
)!
53+
return CachedURLResponse(response: httpResponse, data: jsonData)
54+
}
55+
56+
// MARK: - Core API Error Cache Fallback Tests
57+
58+
func testGetFeatureFlags_APIFailure_ReturnsCachedFlags() throws {
59+
// This test works with mock data, no real API key needed
60+
let expectation = expectation(description: "API failure with cache fallback")
61+
62+
// Create mock flags for cache
63+
let cachedFlags = [
64+
Flag(featureName: "cached_feature_1", value: .string("cached_value_1"), enabled: true, featureType: "FLAG"),
65+
Flag(featureName: "cached_feature_2", value: .string("cached_value_2"), enabled: false, featureType: "FLAG")
66+
]
67+
68+
// Pre-populate cache with successful response
69+
var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/"))
70+
mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key")
71+
let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags)
72+
testCache.storeCachedResponse(cachedResponse, for: mockRequest)
73+
74+
// Mock API failure by using invalid API key
75+
Flagsmith.shared.apiKey = "invalid-api-key"
76+
77+
// Request should fail API call but return cached flags
78+
Flagsmith.shared.getFeatureFlags { result in
79+
switch result {
80+
case .success(let flags):
81+
// Should return cached flags
82+
XCTAssertEqual(flags.count, 2, "Should return cached flags")
83+
XCTAssertEqual(flags.first?.feature.name, "cached_feature_1", "Should return first cached flag")
84+
XCTAssertEqual(flags.last?.feature.name, "cached_feature_2", "Should return second cached flag")
85+
case .failure(let error):
86+
XCTFail("Should return cached flags instead of failing: \(error)")
87+
}
88+
expectation.fulfill()
89+
}
90+
91+
wait(for: [expectation], timeout: 5.0)
92+
}
93+
94+
func testGetFeatureFlags_APIFailure_NoCache_ReturnsDefaultFlags() throws {
95+
// This test works with mock data, no real API key needed
96+
let expectation = expectation(description: "API failure with no cache, default flags fallback")
97+
98+
// Set up default flags
99+
let defaultFlags = [
100+
Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG")
101+
]
102+
Flagsmith.shared.defaultFlags = defaultFlags
103+
104+
// Ensure no cache exists
105+
testCache.removeAllCachedResponses()
106+
107+
// Mock API failure
108+
Flagsmith.shared.apiKey = "invalid-api-key"
109+
110+
// Request should fail API call and return default flags
111+
Flagsmith.shared.getFeatureFlags { result in
112+
switch result {
113+
case .success(let flags):
114+
// Should return default flags
115+
XCTAssertEqual(flags.count, 1, "Should return default flags")
116+
XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag")
117+
case .failure(let error):
118+
XCTFail("Should return default flags instead of failing: \(error)")
119+
}
120+
expectation.fulfill()
121+
}
122+
123+
wait(for: [expectation], timeout: 5.0)
124+
}
125+
126+
func testGetFeatureFlags_APIFailure_NoCacheNoDefaults_ReturnsError() throws {
127+
// This test works with mock data, no real API key needed
128+
let expectation = expectation(description: "API failure with no cache and no defaults")
129+
130+
// Ensure no cache and no defaults
131+
testCache.removeAllCachedResponses()
132+
Flagsmith.shared.defaultFlags = []
133+
134+
// Mock API failure
135+
Flagsmith.shared.apiKey = "invalid-api-key"
136+
137+
// Request should fail
138+
Flagsmith.shared.getFeatureFlags { result in
139+
switch result {
140+
case .success(_):
141+
XCTFail("Should fail when no cache and no defaults")
142+
case .failure(let error):
143+
// Should return the original API error
144+
XCTAssertTrue(error is FlagsmithError, "Should return FlagsmithError")
145+
}
146+
expectation.fulfill()
147+
}
148+
149+
wait(for: [expectation], timeout: 5.0)
150+
}
151+
}

0 commit comments

Comments
 (0)