// // ATProtoClient.swift // Gulliver // // Created by Bailey Townsend on 1/21/26. // import Foundation actor ATProtoClient { private var accessToken: String? private var refreshToken: String? private var dpopNonce: String? private let session: URLSession private let sessionId: String? private let keychainStore: KeychainStorage private let dpopSigner: DPoPSigner enum ATProtoError: Error { case unauthorized case dpopNonceRequired(nonce: String?) case maxRetriesExceeded case networkError(Error) } init(sessionId: String, session: URLSession = .shared) throws { self.sessionId = sessionId self.session = session //We will always use the session keychain here since the state is used else where self.keychainStore = getSessionKeychainStore() let dpopKey = try self.keychainStore.retrieveDPoPKey(keyTag: sessionId) self.dpopSigner = DPoPSigner(privateKey: dpopKey, keychainStore: self.keychainStore) } func request( _ endpoint: String, method: String = "GET", body: Data? = nil, maxRetries: Int = 3 ) async throws -> T { try await Task.retrying( maxRetryCount: maxRetries, retryDelay: 0.5, // Shorter delay for API retries operation: { try await self.performRequest(endpoint, method: method, body: body) }, onRetry: { [weak self] error, attempt in guard let self else { return } switch error { case let atError as ATProtoError: switch atError { case .dpopNonceRequired(let nonce): // Nonce already updated, just retry print("Retrying with DPoP nonce (attempt \(attempt + 1))") case .unauthorized: // Try to refresh token print("Refreshing access token (attempt \(attempt + 1))") try await self.refreshAccessToken() default: throw error // Don't retry other errors } default: throw error // Don't retry unknown errors } } ).value } private func performRequest( _ endpoint: String, method: String, body: Data? ) async throws -> T { guard let url = URL(string: endpoint) else { throw URLError(.badURL) } var request = URLRequest(url: url) request.httpMethod = method request.httpBody = body if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } let dpopProof = try self.dpopSigner.createProof(httpMethod: method, url: endpoint.lowercased()) request.setValue(dpopProof, forHTTPHeaderField: "DPoP") let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } // Always capture the nonce if present if let newNonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { //save to cache now dpopNonce = newNonce } //TODO maybe abstract this out to be used? // Handle error cases if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { if let dpopNonce = httpResponse.value(forHTTPHeaderField: "dpop-nonce") { throw ATProtoError.dpopNonceRequired(nonce: dpopNonce) } throw ATProtoError.unauthorized } guard (200...299).contains(httpResponse.statusCode) else { throw URLError(.badServerResponse) } return try JSONDecoder().decode(T.self, from: data) } private func refreshAccessToken() async throws { guard let refreshToken else { throw ATProtoError.unauthorized } struct RefreshResponse: Decodable { let accessJwt: String let refreshJwt: String } let body = try JSONEncoder().encode(["refreshToken": refreshToken]) // Refresh can also require DPoP retries, so it goes through request() let response: RefreshResponse = try await request( "https://your-pds.host/xrpc/com.atproto.server.refreshSession", method: "POST", body: body, maxRetries: 2 // Fewer retries for refresh ) accessToken = response.accessJwt self.refreshToken = response.refreshJwt } }