Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
21 changes: 17 additions & 4 deletions Sources/App/CareSyncController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand All @@ -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)
}
}
35 changes: 29 additions & 6 deletions Sources/App/NativeUsageProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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": <percent used>, "resets_at": …}.
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 19 additions & 8 deletions Sources/App/PetBrowser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading