[lcfeaturecontrol-review-fixes] Fix JWT log leak, drop macOS/tvOS, coalesce requests
- FeatureControlManager/NotificationsClient: pass debug: false at every
api.request(...) call site — the API default (debug: true) was printing
Authorization: Bearer <jwt> on every request, release builds included.
- Package.swift: drop macOS/tvOS from platforms — LCEssentials.API (used by
LCFeatureControl) is iOS/watchOS-only and the vendored xcframework has no
macOS/tvOS slice.
- FeatureControlManager: coalesce concurrent evaluate() calls on a cold cache
into a single in-flight request per key instead of firing one per caller.
- FeatureControlManager/NotificationsClient: treat a 200 response carrying
{"error": true} as a failure instead of caching/returning it as success.
- FeatureControlNotificationsClient: thread a cursor param through list() so
the already-decoded nextCursor can actually be used to page.
- Tests: 9 new tests (coalescing, envelope-error-on-200, cursor param, a real
object payload decoded through a full evaluate response, date-decode
failures) and removed the 7 remaining force-unwraps in test scaffolding.
- Documentation/FeatureControl.md: new guide for the LCFeatureControl product,
cross-linked from README.md and Extensions.md.
This commit is contained in:
@@ -21,6 +21,9 @@ public actor FeatureControlManager: FeatureControlEvaluating {
|
||||
private let defaults: [String: FeatureControlFlag]
|
||||
private let cache: FeatureControlCache
|
||||
private var exposureBuffer: [FeatureControlExposureEvent] = []
|
||||
/// Coalesces concurrent cold-cache callers onto one in-flight request per key,
|
||||
/// instead of firing one POST per caller (which was hammering the 429 limit).
|
||||
private var inFlight: [FeatureControlCacheKey: Task<FeatureControlSnapshot, Error>] = [:]
|
||||
|
||||
/// `configVersion` sentinel returned when no network response and no cache exist —
|
||||
/// distinguishes "never evaluated" from any real server value (server versions are ≥ 0).
|
||||
@@ -52,25 +55,40 @@ public actor FeatureControlManager: FeatureControlEvaluating {
|
||||
let key = cacheKey(keys: keys, context: context)
|
||||
if let fresh = await cache.get(key, allowStale: false) { return fresh }
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
|
||||
let body = jsonBody(FeatureControlEvaluateRequestBody(
|
||||
environment: configuration.environment, keys: keys, context: context))
|
||||
|
||||
do {
|
||||
let envelope: FeatureControlEvaluateEnvelope = try await api.request(
|
||||
url: configuration.baseURL + configuration.evaluatePath,
|
||||
method: .post,
|
||||
body: body,
|
||||
headers: headers,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
await cache.set(key, snapshot: envelope.result, ttl: configuration.cacheTTL)
|
||||
return envelope.result
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
if let inFlightTask = inFlight[key] {
|
||||
return try await inFlightTask.value
|
||||
}
|
||||
|
||||
let task = Task<FeatureControlSnapshot, Error> {
|
||||
var headers: [String: String] = [:]
|
||||
await self.configuration.auth.authorize(&headers)
|
||||
|
||||
let body = jsonBody(FeatureControlEvaluateRequestBody(
|
||||
environment: self.configuration.environment, keys: keys, context: context))
|
||||
|
||||
do {
|
||||
let envelope: FeatureControlEvaluateEnvelope = try await self.api.request(
|
||||
url: self.configuration.baseURL + self.configuration.evaluatePath,
|
||||
method: .post,
|
||||
body: body,
|
||||
headers: headers,
|
||||
debug: false,
|
||||
timeoutInterval: self.configuration.requestTimeout
|
||||
)
|
||||
guard !envelope.error else {
|
||||
throw FeatureControlError.server(code: envelope.code ?? "UNKNOWN", status: 200)
|
||||
}
|
||||
await self.cache.set(key, snapshot: envelope.result, ttl: self.configuration.cacheTTL)
|
||||
return envelope.result
|
||||
} catch let error as FeatureControlError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
}
|
||||
}
|
||||
inFlight[key] = task
|
||||
defer { inFlight[key] = nil }
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
public func invalidateCache() async {
|
||||
@@ -110,6 +128,7 @@ extension FeatureControlManager {
|
||||
method: .post,
|
||||
body: body,
|
||||
headers: headers,
|
||||
debug: false,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
) as FeatureControlExposureBatchEnvelope
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import LCEssentials
|
||||
|
||||
/// Protocol seam for DI, mirroring `FeatureControlEvaluating`.
|
||||
public protocol FeatureControlNotifying: Sendable {
|
||||
func list(status: FeatureControlNotificationStatus, limit: Int) async throws
|
||||
func list(status: FeatureControlNotificationStatus, limit: Int, cursor: String?) async throws
|
||||
-> (items: [FeatureControlNotification], nextCursor: String?)
|
||||
func markRead(id: String) async throws
|
||||
func markAllRead() async throws
|
||||
@@ -22,20 +22,29 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func list(status: FeatureControlNotificationStatus = .all, limit: Int = 20) async throws
|
||||
public func list(status: FeatureControlNotificationStatus = .all, limit: Int = 20, cursor: String? = nil) async throws
|
||||
-> (items: [FeatureControlNotification], nextCursor: String?) {
|
||||
var headers: [String: String] = [:]
|
||||
await configuration.auth.authorize(&headers)
|
||||
|
||||
let query = "?status=\(status.rawValue)&limit=\(limit)"
|
||||
var query = "?status=\(status.rawValue)&limit=\(limit)"
|
||||
if let cursor, let encodedCursor = cursor.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
|
||||
query += "&cursor=\(encodedCursor)"
|
||||
}
|
||||
do {
|
||||
let envelope: FeatureControlNotificationListEnvelope = try await api.request(
|
||||
url: configuration.baseURL + configuration.notificationsPath + query,
|
||||
method: .get,
|
||||
headers: headers,
|
||||
debug: false,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
guard !envelope.error else {
|
||||
throw FeatureControlError.server(code: "UNKNOWN", status: 200)
|
||||
}
|
||||
return (envelope.result.items, envelope.result.nextCursor)
|
||||
} catch let error as FeatureControlError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FeatureControlErrorMapper.map(error)
|
||||
}
|
||||
@@ -51,6 +60,7 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
|
||||
url: configuration.baseURL + configuration.notificationsPath + "/\(encodedId)/read",
|
||||
method: .post,
|
||||
headers: headers,
|
||||
debug: false,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
} catch {
|
||||
@@ -67,6 +77,7 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
|
||||
url: configuration.baseURL + configuration.notificationsPath + "/read-all",
|
||||
method: .post,
|
||||
headers: headers,
|
||||
debug: false,
|
||||
timeoutInterval: configuration.requestTimeout
|
||||
)
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user