mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(atproto): auth seam retry-on-401 + DPoP-nonce hook + shared JSON coders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+167 -26
+15 -6
Packages/ATProto/Sources/ATProto/Support/AuthTokenProvider.swift
··· 1 1 import Foundation 2 2 3 3 public protocol AuthTokenProvider: Sendable { 4 - /// Returns auth headers to attach to the given request URL, or nil if no 5 - /// auth is available. DPoP-bound providers will supply both 6 - /// `Authorization` and `DPoP` headers. 4 + /// Returns auth headers to attach to the given request, or nil for anonymous. 5 + /// DPoP-bound providers return both `Authorization: DPoP <token>` and 6 + /// `DPoP: <signed-jwt>` bound to the request's method + URL. 7 7 func authHeaders(for request: URLRequest) async throws -> [String: String]? 8 8 9 - /// Called when the server rejected the previous token so the provider can 10 - /// refresh before the caller retries. 9 + /// Called by `XRPCClient` after any response that uses our headers. 10 + /// DPoP providers consume the response's `DPoP-Nonce` header here to prepare 11 + /// the nonce for the next request. 12 + func handleResponse(_ response: HTTPURLResponse, for request: URLRequest) async 13 + 14 + /// Called after a 401 so the provider can refresh tokens before the 15 + /// one-shot retry fires. Default implementation is a no-op. 11 16 func reportTokenRejected() async 12 17 } 13 18 19 + public extension AuthTokenProvider { 20 + func handleResponse(_ response: HTTPURLResponse, for request: URLRequest) async {} 21 + func reportTokenRejected() async {} 22 + } 23 + 14 24 public struct AnonymousAuthTokenProvider: AuthTokenProvider { 15 25 public init() {} 16 26 public func authHeaders(for request: URLRequest) async throws -> [String: String]? { nil } 17 - public func reportTokenRejected() async {} 18 27 }
+75 -20
Packages/ATProto/Sources/ATProto/XRPC/XRPCClient.swift
··· 37 37 var request = URLRequest(url: url) 38 38 request.httpMethod = "POST" 39 39 request.addValue("application/json", forHTTPHeaderField: "Content-Type") 40 - request.httpBody = try JSONEncoder().encode(input) 40 + request.httpBody = try Self.jsonEncoder.encode(input) 41 41 return try await send(request) 42 42 } 43 43 ··· 55 55 return try await sendRaw(request) 56 56 } 57 57 58 + // MARK: - Shared coders 59 + 60 + /// ISO-8601 with fractional seconds — atproto's timestamp format. 61 + static let jsonDecoder: JSONDecoder = { 62 + let d = JSONDecoder() 63 + d.dateDecodingStrategy = .custom { decoder in 64 + let container = try decoder.singleValueContainer() 65 + let s = try container.decode(String.self) 66 + let withFrac = ISO8601DateFormatter() 67 + withFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 68 + if let date = withFrac.date(from: s) { return date } 69 + // Fall back to no-fractional for servers that omit it. 70 + let noFrac = ISO8601DateFormatter() 71 + noFrac.formatOptions = [.withInternetDateTime] 72 + if let date = noFrac.date(from: s) { return date } 73 + throw DecodingError.dataCorruptedError( 74 + in: container, debugDescription: "invalid ISO-8601 date: \(s)" 75 + ) 76 + } 77 + return d 78 + }() 79 + 80 + static let jsonEncoder: JSONEncoder = { 81 + let e = JSONEncoder() 82 + let formatter = ISO8601DateFormatter() 83 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 84 + e.dateEncodingStrategy = .custom { date, encoder in 85 + var container = encoder.singleValueContainer() 86 + try container.encode(formatter.string(from: date)) 87 + } 88 + return e 89 + }() 90 + 91 + // MARK: - Internal helpers 92 + 58 93 func buildURL(nsid: String, params: [String: String]?) throws -> URL { 59 94 guard var comps = URLComponents(url: service, resolvingAgainstBaseURL: false) else { 60 95 throw ATProtoError.invalidURL(service.absoluteString) ··· 70 105 } 71 106 comps.path = basePath + "xrpc/\(nsid)" 72 107 if let params, !params.isEmpty { 73 - // Sort keys for deterministic query order — eases debugging + test assertions. 74 108 comps.queryItems = params.sorted { $0.key < $1.key } 75 109 .map { URLQueryItem(name: $0.key, value: $0.value) } 76 110 } ··· 79 113 } 80 114 81 115 func send<Output: Decodable>(_ request: URLRequest) async throws -> Output { 82 - let (data, response) = try await sendRaw(request) 116 + let (data, _) = try await sendRaw(request) 83 117 return try decode(data) 84 118 } 85 119 86 120 func sendRaw(_ request: URLRequest) async throws -> (Data, URLResponse) { 121 + // First attempt. 122 + do { 123 + let (data, response) = try await attempt(request) 124 + if let http = response as? HTTPURLResponse { 125 + await auth.handleResponse(http, for: request) 126 + if http.statusCode == 401 { 127 + // One-shot retry: give the provider a chance to refresh. 128 + await auth.reportTokenRejected() 129 + let (data2, response2) = try await attempt(request) 130 + if let http2 = response2 as? HTTPURLResponse { 131 + await auth.handleResponse(http2, for: request) 132 + } 133 + return try throwIfError(data: data2, response: response2) 134 + } 135 + } 136 + return try throwIfError(data: data, response: response) 137 + } catch let urlErr as URLError { 138 + throw ATProtoError.network(urlErr) 139 + } 140 + } 141 + 142 + private func attempt(_ request: URLRequest) async throws -> (Data, URLResponse) { 87 143 var request = request 88 144 if let headers = try await auth.authHeaders(for: request) { 89 145 for (k, v) in headers { request.addValue(v, forHTTPHeaderField: k) } 90 146 } 91 - do { 92 - let (data, response) = try await session.data(for: request) 93 - guard let http = response as? HTTPURLResponse else { 94 - throw ATProtoError.http(status: 0, body: nil) 147 + return try await session.data(for: request) 148 + } 149 + 150 + private func throwIfError(data: Data, response: URLResponse) throws -> (Data, URLResponse) { 151 + guard let http = response as? HTTPURLResponse else { 152 + throw ATProtoError.http(status: 0, body: nil) 153 + } 154 + if !(200..<300).contains(http.statusCode) { 155 + // Envelope decode is a best-effort XRPC disambiguation. False positives 156 + // (e.g. CDN/proxy JSON error pages) are benign because .xrpc and .http 157 + // map to the same errno for the same status code. 158 + if let envelope = try? JSONDecoder().decode(ATProtoError.XRPCEnvelope.self, from: data) { 159 + throw ATProtoError.xrpc(code: envelope.error, message: envelope.message, status: http.statusCode) 95 160 } 96 - if !(200..<300).contains(http.statusCode) { 97 - // Envelope decode is a best-effort XRPC disambiguation. False positives 98 - // (e.g. CDN/proxy JSON error pages) are benign because .xrpc and .http 99 - // map to the same errno for the same status code. 100 - if let envelope = try? JSONDecoder().decode(ATProtoError.XRPCEnvelope.self, from: data) { 101 - throw ATProtoError.xrpc(code: envelope.error, message: envelope.message, status: http.statusCode) 102 - } 103 - throw ATProtoError.http(status: http.statusCode, body: data) 104 - } 105 - return (data, response) 106 - } catch let urlErr as URLError { 107 - throw ATProtoError.network(urlErr) 161 + throw ATProtoError.http(status: http.statusCode, body: data) 108 162 } 163 + return (data, response) 109 164 } 110 165 111 166 func decode<Output: Decodable>(_ data: Data) throws -> Output { 112 167 do { 113 - return try JSONDecoder().decode(Output.self, from: data) 168 + return try Self.jsonDecoder.decode(Output.self, from: data) 114 169 } catch { 115 170 throw ATProtoError.decoding("\(error)") 116 171 }
+77
Packages/ATProto/Tests/ATProtoTests/XRPCClientTests.swift
··· 117 117 ) 118 118 #expect(result == Out(ok: true)) 119 119 } 120 + 121 + // MARK: - Auth seam tests 122 + 123 + actor CountingProvider: AuthTokenProvider { 124 + var tokenCalls = 0 125 + var rejectedCalls = 0 126 + var responseCalls = 0 127 + var responsesSeen: [Int] = [] 128 + var tokenToReturn: String = "t1" 129 + 130 + func authHeaders(for request: URLRequest) async throws -> [String: String]? { 131 + tokenCalls += 1 132 + return ["Authorization": "Bearer \(tokenToReturn)"] 133 + } 134 + func handleResponse(_ response: HTTPURLResponse, for request: URLRequest) async { 135 + responseCalls += 1 136 + responsesSeen.append(response.statusCode) 137 + } 138 + func reportTokenRejected() async { 139 + rejectedCalls += 1 140 + tokenToReturn = "t2" 141 + } 142 + } 143 + 144 + @Test("401 triggers one-shot retry after reportTokenRejected") 145 + func retryOn401() async throws { 146 + actor Counter { var count = 0; func tick() { count += 1 } } 147 + let counter = Counter() 148 + let session = URLProtocolStub.install { _ in 149 + Task { await counter.tick() } 150 + return URLProtocolStub.Response( 151 + statusCode: 401, 152 + headers: [:], 153 + body: Data() 154 + ) 155 + } 156 + defer { URLProtocolStub.reset(session: session) } 157 + 158 + let provider = CountingProvider() 159 + let client = XRPCClient( 160 + service: URL(string: "https://bsky.social")!, 161 + session: session, 162 + auth: provider 163 + ) 164 + 165 + do { 166 + let _: Echo = try await client.get(nsid: "com.example.echo", params: nil) 167 + Issue.record("expected throw") 168 + } catch is ATProtoError { 169 + // ok 170 + } 171 + 172 + // Two HTTP attempts total (original + one retry). 173 + let count = await counter.count 174 + #expect(count == 2) 175 + #expect(await provider.tokenCalls == 2) 176 + #expect(await provider.rejectedCalls == 1) 177 + #expect(await provider.responseCalls == 2) 178 + } 179 + 180 + @Test("handleResponse fires on success") 181 + func handleResponseOnSuccess() async throws { 182 + let session = URLProtocolStub.install { _ in 183 + .json(#"{"value":"ok"}"#) 184 + } 185 + defer { URLProtocolStub.reset(session: session) } 186 + 187 + let provider = CountingProvider() 188 + let client = XRPCClient( 189 + service: URL(string: "https://bsky.social")!, 190 + session: session, 191 + auth: provider 192 + ) 193 + let _: Echo = try await client.get(nsid: "com.example.echo", params: nil) 194 + #expect(await provider.responseCalls == 1) 195 + #expect(await provider.responsesSeen == [200]) 196 + } 120 197 }