diff --git a/.changeset/heavy-pianos-shout.md b/.changeset/heavy-pianos-shout.md new file mode 100644 index 0000000..827917c --- /dev/null +++ b/.changeset/heavy-pianos-shout.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprototypes": patch +--- + +preserve the body-decode error when a recognized response fails to parse diff --git a/Sources/AtprotoTypes/XRPC/ResponseParsing.swift b/Sources/AtprotoTypes/XRPC/ResponseParsing.swift index a14e837..53980ab 100644 --- a/Sources/AtprotoTypes/XRPC/ResponseParsing.swift +++ b/Sources/AtprotoTypes/XRPC/ResponseParsing.swift @@ -59,6 +59,11 @@ extension Atproto.XRPC { error: ErrorResponse ) case unrecognized(HTTPResponse) + //A recognized status whose body failed to decode. Distinct from .unrecognized + //so a well-formed 200 with an undecodable body (e.g. a record whose format + //drifted) keeps its DecodingError — coding path and all — instead of being + //reported identically to an unknown status. + case undecodable(HTTPResponse, underlying: any Error) public var errorDescription: String? { switch self { @@ -66,6 +71,8 @@ extension Atproto.XRPC { "\(status): \(error.error)" case .unrecognized(let response): "Unrecognized response: \(response)" + case .undecodable(let response, let underlying): + "Undecodable response: \(response), underlying: \(underlying)" } } } @@ -105,7 +112,7 @@ extension Atproto.XRPC.ResponseParsing { return .error(.unrecognized(fullResponse.response)) } catch { - return .error(.unrecognized(fullResponse.response)) + return .error(.undecodable(fullResponse.response, underlying: error)) } } diff --git a/Tests/AtprotoTypesTests/ResponseParsingTests.swift b/Tests/AtprotoTypesTests/ResponseParsingTests.swift new file mode 100644 index 0000000..4055534 --- /dev/null +++ b/Tests/AtprotoTypesTests/ResponseParsingTests.swift @@ -0,0 +1,58 @@ +// +// ResponseParsingTests.swift +// AtprotoTypes +// +// Created by Mark @ Germ on 7/15/26. +// + +import AtprotoTypes +import Foundation +import GermConvenience +import HTTPTypes +import Testing + +struct ResponseParsingTests { + private struct Body: Decodable, Sendable { + let name: String + } + + private enum MockParsing: Atproto.XRPC.ResponseParsing { + typealias Output = Body + static var badRequestErrors: Set { defaultErrors } + } + + //A 200 whose body fails to decode must keep the DecodingError (coding path + //and all) rather than collapsing into .unrecognized, which reports nothing + //beyond the response headers. + @Test func undecodableBodyPreservesUnderlyingError() throws { + let response = HTTPDataResponse( + data: Data("{\"name\": 42}".utf8), + response: .init(status: .ok) + ) + + let parsed = try MockParsing.parse(fullResponse: response) + guard case .error(.undecodable(_, let underlying)) = parsed else { + Issue.record("expected .undecodable, got \(parsed)") + return + } + guard case DecodingError.typeMismatch(_, let context) = underlying else { + Issue.record("expected DecodingError.typeMismatch, got \(underlying)") + return + } + #expect(context.codingPath.map(\.stringValue) == ["name"]) + } + + //an unknown status without a decode attempt still reports .unrecognized + @Test func unknownStatusReportsUnrecognized() throws { + let response = HTTPDataResponse( + data: Data(), + response: .init(status: .init(code: 418)) + ) + + let parsed = try MockParsing.parse(fullResponse: response) + guard case .error(.unrecognized) = parsed else { + Issue.record("expected .unrecognized, got \(parsed)") + return + } + } +}