Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
140 changes: 137 additions & 3 deletions FlagsmithClient/Classes/Flagsmith.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ public final class Flagsmith: @unchecked Sendable {
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,147 @@ public final class Flagsmith: @unchecked Sendable {
}

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) {
// 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 {
// Cache data is corrupted, return nil
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/")
var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false)!
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 {
// Cache data is corrupted, return nil
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 {
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, assume valid for the configured TTL
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
}
}

// 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
Loading
Loading