Skip to content

Commit 32fe7f1

Browse files
BrentMifsudclaude
andcommitted
Add decodingError case to ClientError with response body context
Wrap decoding failures in HTTPResponse.decodeSuccessBody() and decodeFailureBody() as ClientError.decodingError, preserving the raw response body for downstream logging and debugging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 230f8a4 commit 32fe7f1

File tree

3 files changed

+117
-4
lines changed

3 files changed

+117
-4
lines changed

Sources/Simplicity/HTTP/HTTPResponse.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,32 @@ public nonisolated struct HTTPResponse<Success: Decodable & Sendable, Failure: D
7272

7373
/// Decodes `httpBody` into the `Success` type using the configured success decoder.
7474
/// - Returns: A value of type `Success`.
75-
/// - Throws: Any decoding error thrown by the underlying decoder.
75+
/// - Throws: `ClientError.decodingError` wrapping the underlying error and the raw response body.
7676
public func decodeSuccessBody() throws -> Success {
77-
try successBodyDecoder(httpBody)
77+
do {
78+
return try successBodyDecoder(httpBody)
79+
} catch {
80+
throw ClientError.decodingError(
81+
type: String(describing: Success.self),
82+
responseBody: httpBody,
83+
underlyingError: error
84+
)
85+
}
7886
}
7987

8088
/// Decodes `httpBody` into the `Failure` type using the configured failure decoder.
8189
/// - Returns: A value of type `Failure`.
82-
/// - Throws: Any decoding error thrown by the underlying decoder.
90+
/// - Throws: `ClientError.decodingError` wrapping the underlying error and the raw response body.
8391
public func decodeFailureBody() throws -> Failure {
84-
try failureBodyDecoder(httpBody)
92+
do {
93+
return try failureBodyDecoder(httpBody)
94+
} catch {
95+
throw ClientError.decodingError(
96+
type: String(describing: Failure.self),
97+
responseBody: httpBody,
98+
underlyingError: error
99+
)
100+
}
85101
}
86102
}
87103

Sources/Simplicity/Implementation/ClientError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public nonisolated enum ClientError: Sendable, LocalizedError {
1212
case timedOut
1313
case cacheMiss
1414
case encodingError(type: String, underlyingError: any Error)
15+
case decodingError(type: String, responseBody: Data, underlyingError: any Error)
1516
case transport(URLError)
1617
case middleware(middleware: any Middleware, underlyingError: any Error)
1718
case invalidResponse(String)
@@ -26,6 +27,8 @@ public nonisolated enum ClientError: Sendable, LocalizedError {
2627
"The request was cancelled"
2728
case let .encodingError(type, underlyingError):
2829
"Failed to encode \(type): \(underlyingError.localizedDescription)"
30+
case let .decodingError(type, responseBody, underlyingError):
31+
"Failed to decode \(type): \(underlyingError.localizedDescription)\nResponse body: \(String(data: responseBody, encoding: .utf8) ?? "<\(responseBody.count) bytes>")"
2932
case .transport(let error):
3033
error.localizedDescription
3134
case .invalidResponse(let details):

Tests/SimplicityTests/URLSessionHTTPClientTests.swift

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,100 @@ private struct SuccessOnlyRequest: HTTPRequest {
440440
}
441441
}
442442

443+
// MARK: - Decoding Error Tests
444+
445+
@Suite("HTTPResponse Decoding Error Tests")
446+
struct HTTPResponseDecodingErrorTests {
447+
@Test
448+
func testDecodeSuccessBody_throwsDecodingError_withResponseBody() throws {
449+
// Arrange — response body is valid JSON but wrong shape for SuccessModel
450+
let mismatchedBody = Data("{\"unexpected\":\"field\"}".utf8)
451+
let response = HTTPResponse<SuccessModel, ErrorModel>(
452+
statusCode: .ok,
453+
url: URL(string: "https://example.com/test")!,
454+
headers: [:],
455+
httpBody: mismatchedBody,
456+
successBodyDecoder: { data in
457+
try JSONDecoder().decode(SuccessModel.self, from: data)
458+
},
459+
failureBodyDecoder: { data in
460+
try JSONDecoder().decode(ErrorModel.self, from: data)
461+
}
462+
)
463+
464+
// Act & Assert
465+
do {
466+
_ = try response.decodeSuccessBody()
467+
Issue.record("Expected decodingError to be thrown")
468+
} catch let error as ClientError {
469+
guard case let .decodingError(type, responseBody, underlyingError) = error else {
470+
Issue.record("Expected ClientError.decodingError, got: \(error)")
471+
return
472+
}
473+
#expect(type == "SuccessModel")
474+
#expect(responseBody == mismatchedBody)
475+
#expect(underlyingError is DecodingError)
476+
}
477+
}
478+
479+
@Test
480+
func testDecodeFailureBody_throwsDecodingError_withResponseBody() throws {
481+
// Arrange — response body is not valid JSON at all
482+
let invalidBody = Data("not json".utf8)
483+
let response = HTTPResponse<SuccessModel, ErrorModel>(
484+
statusCode: .badRequest,
485+
url: URL(string: "https://example.com/test")!,
486+
headers: [:],
487+
httpBody: invalidBody,
488+
successBodyDecoder: { data in
489+
try JSONDecoder().decode(SuccessModel.self, from: data)
490+
},
491+
failureBodyDecoder: { data in
492+
try JSONDecoder().decode(ErrorModel.self, from: data)
493+
}
494+
)
495+
496+
// Act & Assert
497+
do {
498+
_ = try response.decodeFailureBody()
499+
Issue.record("Expected decodingError to be thrown")
500+
} catch let error as ClientError {
501+
guard case let .decodingError(type, responseBody, underlyingError) = error else {
502+
Issue.record("Expected ClientError.decodingError, got: \(error)")
503+
return
504+
}
505+
#expect(type == "ErrorModel")
506+
#expect(responseBody == invalidBody)
507+
#expect(underlyingError is DecodingError)
508+
}
509+
}
510+
511+
@Test
512+
func testDecodingError_errorDescription_includesResponseBody() throws {
513+
let body = Data("{\"wrong\":true}".utf8)
514+
let error = ClientError.decodingError(
515+
type: "SuccessModel",
516+
responseBody: body,
517+
underlyingError: DecodingError.keyNotFound(
518+
AnyCodingKey(stringValue: "value"),
519+
.init(codingPath: [], debugDescription: "No value for key 'value'")
520+
)
521+
)
522+
523+
let description = error.errorDescription ?? ""
524+
#expect(description.contains("Failed to decode SuccessModel"))
525+
#expect(description.contains("{\"wrong\":true}"))
526+
}
527+
}
528+
529+
/// A type-erased CodingKey for test assertions.
530+
private struct AnyCodingKey: CodingKey {
531+
var stringValue: String
532+
var intValue: Int?
533+
init(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }
534+
init?(intValue: Int) { self.stringValue = "\(intValue)"; self.intValue = intValue }
535+
}
536+
443537
// MARK: - Cache Tests
444538

445539
@Suite("URLSessionHTTPClient Cache Tests")

0 commit comments

Comments
 (0)