diff --git a/Sources/App/CareSyncController.swift b/Sources/App/CareSyncController.swift index 8e8da13..7513ce9 100644 --- a/Sources/App/CareSyncController.swift +++ b/Sources/App/CareSyncController.swift @@ -23,6 +23,7 @@ final class CareSyncController: ObservableObject { static let base = URL(string: "https://agentpet.thenightwatcher.online")! private var debounce: Timer? + private var failCount = 0 init() { linked = UserDefaults.standard.string(forKey: Self.tokenKey) != nil @@ -127,6 +128,7 @@ final class CareSyncController: ObservableObject { var request = URLRequest(url: Self.base.appendingPathComponent("api/care/sync")) request.httpMethod = "POST" + request.timeoutInterval = 15 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.httpBody = try? JSONSerialization.data(withJSONObject: ["pets": pets]) @@ -137,16 +139,27 @@ final class CareSyncController: ObservableObject { if status == 200 { lastSyncAt = Date() lastError = nil + failCount = 0 } else if status == 401 { // Token revoked from the web side: unlink quietly. disconnect() } else { - lastError = NSLocalizedString("Sync failed, will retry.", comment: "") - scheduleSync(after: 300) + retryWithBackoff() } } catch { - lastError = NSLocalizedString("Sync failed, will retry.", comment: "") - scheduleSync(after: 300) + retryWithBackoff() } } + + private func retryWithBackoff() { + failCount += 1 + if failCount >= 5 { + lastError = NSLocalizedString("Sync failed repeatedly. Re-link to retry.", comment: "") + return + } + let delays: [TimeInterval] = [30, 120, 300, 600] + let delay = delays[min(failCount - 1, delays.count - 1)] + lastError = NSLocalizedString("Sync failed, will retry.", comment: "") + scheduleSync(after: delay) + } } diff --git a/Sources/App/NativeUsageProbe.swift b/Sources/App/NativeUsageProbe.swift index ae99d1e..48f90ab 100644 --- a/Sources/App/NativeUsageProbe.swift +++ b/Sources/App/NativeUsageProbe.swift @@ -58,9 +58,20 @@ final class NativeUsageProbe: ObservableObject { request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") request.setValue("claude-code/2.1.69", forHTTPHeaderField: "User-Agent") - guard let (data, response) = try? await URLSession.shared.data(for: request), - (response as? HTTPURLResponse)?.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + var data: Data? + for attempt in 0..<2 { + guard let (d, response) = try? await URLSession.shared.data(for: request), + let http = response as? HTTPURLResponse else { + if attempt < 1 { try? await Task.sleep(nanoseconds: 1_000_000_000); continue } + return nil + } + if http.statusCode == 200 { data = d; break } + if http.statusCode >= 500, attempt < 1 { + try? await Task.sleep(nanoseconds: 1_000_000_000); continue + } + return nil + } + guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } // Windows are {"utilization": , "resets_at": …}. @@ -135,9 +146,21 @@ final class NativeUsageProbe: ObservableObject { request.setValue(account, forHTTPHeaderField: "ChatGPT-Account-Id") } - guard let (data, response) = try? await URLSession.shared.data(for: request), - let http = response as? HTTPURLResponse, http.statusCode == 200 - else { return nil } + var data: Data? + var httpResponse: HTTPURLResponse? + for attempt in 0..<2 { + guard let (d, response) = try? await URLSession.shared.data(for: request), + let http = response as? HTTPURLResponse else { + if attempt < 1 { try? await Task.sleep(nanoseconds: 1_000_000_000); continue } + return nil + } + if http.statusCode == 200 { data = d; httpResponse = http; break } + if http.statusCode >= 500, attempt < 1 { + try? await Task.sleep(nanoseconds: 1_000_000_000); continue + } + return nil + } + guard let data, let http = httpResponse else { return nil } // used-percent comes in response headers; the body's rate_limit // windows carry the same plus reset timing. diff --git a/Sources/App/PetBrowser.swift b/Sources/App/PetBrowser.swift index 5ef25c6..b531811 100644 --- a/Sources/App/PetBrowser.swift +++ b/Sources/App/PetBrowser.swift @@ -94,16 +94,27 @@ final class PetBrowser: ObservableObject { } } - /// Loads one manifest; returns `[]` on any failure so the other source still shows. + /// Loads one manifest; retries once on transient failures. Returns `[]` on + /// permanent failure so the other source still shows. private static func fetch(_ url: URL, isCommunity: Bool) async -> [RemotePet] { - do { - let (data, _) = try await URLSession.shared.data(from: url) - var list = try JSONDecoder().decode(Manifest.self, from: data).pets - if isCommunity { for i in list.indices { list[i].isCommunity = true } } - return list - } catch { - return [] + for attempt in 0..<2 { + do { + var request = URLRequest(url: url) + request.timeoutInterval = 10 + let (data, response) = try await URLSession.shared.data(for: request) + let code = (response as? HTTPURLResponse)?.statusCode ?? 200 + if (200..<300).contains(code) { + var list = try JSONDecoder().decode(Manifest.self, from: data).pets + if isCommunity { for i in list.indices { list[i].isCommunity = true } } + return list + } + guard code == 429 || code >= 500, attempt < 1 else { return [] } + } catch { + guard attempt < 1 else { return [] } + } + try? await Task.sleep(nanoseconds: 1_000_000_000) } + return [] } /// Keeps the first occurrence of each slug (community entries come first).