Skip to content

Commit 1da7a33

Browse files
committed
Add retryable NSE auth fetches with fresh network sessions
1 parent f54773a commit 1da7a33

4 files changed

Lines changed: 168 additions & 25 deletions

File tree

packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,63 @@ import os.log
33

44
private let authLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NSEAuthService")
55

6+
struct NSEAuthTokenCacheKey: Hashable {
7+
let qssUrl: URL
8+
let teamId: String
9+
}
10+
11+
final class NSEAuthTokenCache {
12+
private let lock = NSLock()
13+
private var tokens: [NSEAuthTokenCacheKey: (token: String, expiry: Date)] = [:]
14+
15+
func token(for qssUrl: URL, teamId: String, now: Date = Date()) -> (token: String, expiry: Date)? {
16+
let key = NSEAuthTokenCacheKey(qssUrl: qssUrl, teamId: teamId)
17+
lock.lock()
18+
defer { lock.unlock() }
19+
20+
guard let cached = tokens[key] else {
21+
return nil
22+
}
23+
24+
guard cached.expiry > now else {
25+
tokens.removeValue(forKey: key)
26+
return nil
27+
}
28+
29+
return cached
30+
}
31+
32+
func store(token: String, expiresIn: Int, for qssUrl: URL, teamId: String, now: Date = Date()) {
33+
let key = NSEAuthTokenCacheKey(qssUrl: qssUrl, teamId: teamId)
34+
let expiry = now.addingTimeInterval(TimeInterval(expiresIn) - 30)
35+
lock.lock()
36+
tokens[key] = (token: token, expiry: expiry)
37+
lock.unlock()
38+
}
39+
40+
func removeToken(for qssUrl: URL, teamId: String) {
41+
let key = NSEAuthTokenCacheKey(qssUrl: qssUrl, teamId: teamId)
42+
lock.lock()
43+
tokens.removeValue(forKey: key)
44+
lock.unlock()
45+
}
46+
}
47+
648
class NSEAuthService {
749
private let client: NSENetworkClient
850
private let crypto: DeviceCryptography
51+
private let tokenCache: NSEAuthTokenCache
952

10-
private var tokenCache: [String: (token: String, expiry: Date)] = [:]
11-
12-
init(client: NSENetworkClient, crypto: DeviceCryptography) {
53+
init(client: NSENetworkClient, crypto: DeviceCryptography, tokenCache: NSEAuthTokenCache = NSEAuthTokenCache()) {
1354
self.client = client
1455
self.crypto = crypto
56+
self.tokenCache = tokenCache
1557
}
1658

1759
// MARK: - Full auth flow
1860

1961
func authenticate(deviceId: String, teamId: String) async throws -> String {
20-
if let cached = tokenCache[teamId], cached.expiry > Date() {
62+
if let cached = tokenCache.token(for: client.baseURL, teamId: teamId) {
2163
os_log("authenticate: using cached token for teamId=%{public}@, expires=%{public}@",
2264
log: authLog, type: .debug, teamId, "\(cached.expiry)")
2365
return cached.token
@@ -42,7 +84,7 @@ class NSEAuthService {
4284
)
4385
os_log("authenticate: token received, expiresIn=%{public}d", log: authLog, type: .info, tokenResp.expiresIn)
4486

45-
tokenCache[teamId] = (token: tokenResp.token, expiry: Date().addingTimeInterval(TimeInterval(tokenResp.expiresIn) - 30))
87+
tokenCache.store(token: tokenResp.token, expiresIn: tokenResp.expiresIn, for: client.baseURL, teamId: teamId)
4688

4789
return tokenResp.token
4890
}
@@ -63,12 +105,11 @@ class NSEAuthService {
63105
} catch NSEAuthError.logFetchFailed(let statusCode) where statusCode == 401 {
64106
os_log("fetchNewEntries: token rejected (401) for teamId=%{public}@, evicting cache and retrying",
65107
log: authLog, type: .info, teamId)
66-
tokenCache.removeValue(forKey: teamId)
108+
tokenCache.removeToken(for: client.baseURL, teamId: teamId)
67109
let freshToken = try await authenticate(deviceId: deviceId, teamId: teamId)
68110
let resp = try await client.fetchLogEntries(teamId: teamId, afterSeq: afterSeq, token: freshToken)
69111
os_log("fetchNewEntries: retry succeeded, received %{public}d entries", log: authLog, type: .info, resp.entries.count)
70112
return resp
71113
}
72114
}
73115
}
74-

packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,57 @@ enum NSEAuthError: Error, LocalizedError {
2828
}
2929
}
3030

31+
extension NSEAuthError {
32+
private static let retryableURLCodes: Set<Int> = [
33+
URLError.cannotConnectToHost.rawValue,
34+
URLError.networkConnectionLost.rawValue,
35+
URLError.timedOut.rawValue,
36+
URLError.notConnectedToInternet.rawValue,
37+
URLError.cannotFindHost.rawValue,
38+
URLError.dnsLookupFailed.rawValue,
39+
URLError.resourceUnavailable.rawValue,
40+
URLError.callIsActive.rawValue,
41+
URLError.dataNotAllowed.rawValue
42+
]
43+
44+
private static let retryablePOSIXCodes: Set<Int> = [53, 57, 60, 61, 64, 65]
45+
46+
var isRetryableNetworkFailure: Bool {
47+
guard case .networkError(let error) = self else {
48+
return false
49+
}
50+
return Self.isRetryableNetworkFailure(error)
51+
}
52+
53+
private static func isRetryableNetworkFailure(_ error: Error) -> Bool {
54+
let nsError = error as NSError
55+
56+
if nsError.domain == NSURLErrorDomain, retryableURLCodes.contains(nsError.code) {
57+
return true
58+
}
59+
60+
if nsError.domain == NSPOSIXErrorDomain, retryablePOSIXCodes.contains(nsError.code) {
61+
return true
62+
}
63+
64+
if let streamCode = nsError.userInfo["_kCFStreamErrorCodeKey"] as? Int,
65+
retryablePOSIXCodes.contains(streamCode) {
66+
return true
67+
}
68+
69+
if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError {
70+
if underlying.domain == NSURLErrorDomain, retryableURLCodes.contains(underlying.code) {
71+
return true
72+
}
73+
if underlying.domain == NSPOSIXErrorDomain, retryablePOSIXCodes.contains(underlying.code) {
74+
return true
75+
}
76+
}
77+
78+
return false
79+
}
80+
}
81+
3182
// MARK: - Challenge
3283

3384
struct ChallengePayload: Codable {

packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ class NSENetworkClient {
1616

1717
private static let decoder = JSONDecoder()
1818

19-
// Dedicated session with tight timeouts suitable for an NSE (30-second budget).
20-
private static let defaultSession: URLSession = {
19+
// Each client gets a fresh session so path transitions cannot poison pooled connections.
20+
private static func makeDefaultSession() -> URLSession {
2121
let config = URLSessionConfiguration.ephemeral
2222
config.timeoutIntervalForRequest = 10
2323
config.timeoutIntervalForResource = 20
24+
config.waitsForConnectivity = true
2425
return URLSession(configuration: config)
25-
}()
26+
}
2627

2728
init(baseURL: URL, session: URLSession? = nil) {
2829
self.baseURL = baseURL
29-
self.session = session ?? NSENetworkClient.defaultSession
30+
self.session = session ?? NSENetworkClient.makeDefaultSession()
3031
}
3132

3233
// MARK: - POST /nse-auth/challenge

packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@ class NotificationService: UNNotificationServiceExtension {
99
let message: NSEDecryptedNotificationMessage
1010
}
1111

12+
private static let retryDelaysNanoseconds: [UInt64] = [
13+
250_000_000,
14+
750_000_000
15+
]
16+
17+
private static let maxRetryWindow: TimeInterval = 5
18+
1219
var contentHandler: ((UNNotificationContent) -> Void)?
1320
var bestAttemptContent: UNMutableNotificationContent?
1421
var fetchTask: Task<Void, Never>?
1522

1623
private let crypto = NSECryptoService()
17-
private var authCache: [URL: NSEAuthService] = [:]
24+
private let tokenCache = NSEAuthTokenCache()
1825

1926
private static func channelName(from channelId: String) -> String {
2027
guard let separatorIndex = channelId.firstIndex(of: "_") else {
@@ -76,23 +83,11 @@ class NotificationService: UNNotificationServiceExtension {
7683
log: nseLog, type: .info, teamId, qssUrlString)
7784

7885
do {
79-
let auth: NSEAuthService
80-
if let cached = authCache[qssUrl] {
81-
os_log("fetchAndUpdate: using cached NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString)
82-
auth = cached
83-
} else {
84-
os_log("fetchAndUpdate: creating new NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString)
85-
let client = NSENetworkClient(baseURL: qssUrl)
86-
let newAuth = NSEAuthService(client: client, crypto: crypto)
87-
authCache[qssUrl] = newAuth
88-
auth = newAuth
89-
}
90-
9186
let afterSeq = SharedDefaults.getLastSyncSeq()
9287
os_log("fetchAndUpdate: fetching entries afterSeq=%{public}lld",
9388
log: nseLog, type: .info, afterSeq)
9489

95-
let response = try await auth.fetchNewEntries(teamId: teamId, afterSeq: afterSeq)
90+
let response = try await fetchEntriesWithRetry(qssUrl: qssUrl, teamId: teamId, afterSeq: afterSeq)
9691
let entries = response.entries
9792
let baselineSeq = afterSeq
9893
os_log("fetchAndUpdate: fetched %{public}d entries",
@@ -189,6 +184,61 @@ class NotificationService: UNNotificationServiceExtension {
189184
}
190185
}
191186

187+
private func fetchEntriesWithRetry(qssUrl: URL, teamId: String, afterSeq: Int64) async throws -> LogEntriesResponse {
188+
let startedAt = Date()
189+
var retryIndex = 0
190+
191+
while true {
192+
guard !Task.isCancelled else {
193+
throw CancellationError()
194+
}
195+
196+
do {
197+
let auth = makeAuthService(qssUrl: qssUrl)
198+
return try await auth.fetchNewEntries(teamId: teamId, afterSeq: afterSeq)
199+
} catch {
200+
guard shouldRetryFetch(error: error, startedAt: startedAt, retryIndex: retryIndex) else {
201+
throw error
202+
}
203+
204+
let delay = Self.retryDelaysNanoseconds[retryIndex]
205+
retryIndex += 1
206+
os_log(
207+
"fetchAndUpdate: retrying full auth fetch after transient network error (%{public}d/%{public}d): %{public}@",
208+
log: nseLog,
209+
type: .info,
210+
retryIndex,
211+
Self.retryDelaysNanoseconds.count,
212+
String(describing: error)
213+
)
214+
try await Task.sleep(nanoseconds: delay)
215+
}
216+
}
217+
}
218+
219+
private func makeAuthService(qssUrl: URL) -> NSEAuthService {
220+
os_log("fetchAndUpdate: creating fresh NSEAuthService for %{public}@",
221+
log: nseLog, type: .debug, qssUrl.absoluteString)
222+
let client = NSENetworkClient(baseURL: qssUrl)
223+
return NSEAuthService(client: client, crypto: crypto, tokenCache: tokenCache)
224+
}
225+
226+
private func shouldRetryFetch(error: Error, startedAt: Date, retryIndex: Int) -> Bool {
227+
guard retryIndex < Self.retryDelaysNanoseconds.count else {
228+
return false
229+
}
230+
231+
guard Date().timeIntervalSince(startedAt) < Self.maxRetryWindow else {
232+
return false
233+
}
234+
235+
guard let authError = error as? NSEAuthError else {
236+
return false
237+
}
238+
239+
return authError.isRetryableNetworkFailure
240+
}
241+
192242
private func applyNotificationMessage(_ message: NSEDecryptedNotificationMessage, to content: UNMutableNotificationContent) {
193243
content.title = "#\(Self.channelName(from: message.channelId))"
194244
content.body = message.body

0 commit comments

Comments
 (0)