Skip to content

Commit acb9425

Browse files
authored
Fix error description forwarding (#143)
### Motivation Fixes apple/swift-openapi-generator#730. We were not correctly keeping `CustomStringConvertible` and `LocalizedError` conformances separate for wrapper errors like `ClientError` and `ServerError`. This lead to some user-thrown errors (in handlers, transports, and middlewares) to print less information than the error was actually providing (using a different method). ### Modifications Properly untangle the two printing codepaths, and only call `localizedDescription` from the wrapper error's `errorDescription`. Also made the `localizedDescription` strings a bit more user-friendly and less detailed, as in some apps these errors might get directly rendered by a UI component that calls `localizedDescription`. ### Result Error logging should now match adopter expectations. ### Test Plan Added unit tests for `{Client,Server}Error` printing methods.
1 parent c118c19 commit acb9425

11 files changed

+116
-19
lines changed

Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift

+8-2
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,18 @@ struct MultiError: Swift.Error, LocalizedError, CustomStringConvertible {
114114
var description: String {
115115
let combinedDescription =
116116
errors.map { error in
117-
guard let error = error as? (any PrettyStringConvertible) else { return error.localizedDescription }
117+
guard let error = error as? (any PrettyStringConvertible) else { return "\(error)" }
118118
return error.prettyDescription
119119
}
120120
.enumerated().map { ($0.offset + 1, $0.element) }.map { "Error \($0.0): [\($0.1)]" }.joined(separator: ", ")
121121
return "MultiError (contains \(errors.count) error\(errors.count == 1 ? "" : "s")): \(combinedDescription)"
122122
}
123123

124-
var errorDescription: String? { description }
124+
var errorDescription: String? {
125+
if let first = errors.first {
126+
return "Mutliple errors encountered, first one: \(first.localizedDescription)."
127+
} else {
128+
return "No errors"
129+
}
130+
}
125131
}

Sources/OpenAPIRuntime/Errors/ClientError.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,7 @@ public struct ClientError: Error {
109109
// MARK: Private
110110

111111
fileprivate var underlyingErrorDescription: String {
112-
guard let prettyError = underlyingError as? (any PrettyStringConvertible) else {
113-
return underlyingError.localizedDescription
114-
}
112+
guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { return "\(underlyingError)" }
115113
return prettyError.prettyDescription
116114
}
117115
}
@@ -133,5 +131,7 @@ extension ClientError: LocalizedError {
133131
/// This computed property provides a localized human-readable description of the client error, which is suitable for displaying to users.
134132
///
135133
/// - Returns: A localized string describing the client error.
136-
public var errorDescription: String? { description }
134+
public var errorDescription: String? {
135+
"Client encountered an error invoking the operation \"\(operationID)\", caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)."
136+
}
137137
}

Sources/OpenAPIRuntime/Errors/CodingErrors.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ extension DecodingError: PrettyStringConvertible {
2121
case .keyNotFound(let key, let context): output = "keyNotFound \(key) - \(context.prettyDescription)"
2222
case .typeMismatch(let type, let context): output = "typeMismatch \(type) - \(context.prettyDescription)"
2323
case .valueNotFound(let type, let context): output = "valueNotFound \(type) - \(context.prettyDescription)"
24-
@unknown default: output = "unknown: \(localizedDescription)"
24+
@unknown default: output = "unknown: \(self)"
2525
}
2626
return "DecodingError: \(output)"
2727
}
@@ -30,7 +30,7 @@ extension DecodingError: PrettyStringConvertible {
3030
extension DecodingError.Context: PrettyStringConvertible {
3131
var prettyDescription: String {
3232
let path = codingPath.map(\.description).joined(separator: "/")
33-
return "at \(path): \(debugDescription) (underlying error: \(underlyingError?.localizedDescription ?? "<nil>"))"
33+
return "at \(path): \(debugDescription) (underlying error: \(underlyingError.map { "\($0)" } ?? "<nil>"))"
3434
}
3535
}
3636

@@ -39,7 +39,7 @@ extension EncodingError: PrettyStringConvertible {
3939
let output: String
4040
switch self {
4141
case .invalidValue(let value, let context): output = "invalidValue \(value) - \(context.prettyDescription)"
42-
@unknown default: output = "unknown: \(localizedDescription)"
42+
@unknown default: output = "unknown: \(self)"
4343
}
4444
return "EncodingError: \(output)"
4545
}
@@ -48,6 +48,6 @@ extension EncodingError: PrettyStringConvertible {
4848
extension EncodingError.Context: PrettyStringConvertible {
4949
var prettyDescription: String {
5050
let path = codingPath.map(\.description).joined(separator: "/")
51-
return "at \(path): \(debugDescription) (underlying error: \(underlyingError?.localizedDescription ?? "<nil>"))"
51+
return "at \(path): \(debugDescription) (underlying error: \(underlyingError.map { "\($0)" } ?? "<nil>"))"
5252
}
5353
}

Sources/OpenAPIRuntime/Errors/RuntimeError.swift

+4
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
121121
return "Unexpected response body, expected content type: \(expectedContentType), body: \(body)"
122122
}
123123
}
124+
125+
// MARK: - LocalizedError
126+
127+
var errorDescription: String? { description }
124128
}
125129

126130
/// Throws an error to indicate an unexpected HTTP response status.

Sources/OpenAPIRuntime/Errors/ServerError.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,7 @@ public struct ServerError: Error {
8282
// MARK: Private
8383

8484
fileprivate var underlyingErrorDescription: String {
85-
guard let prettyError = underlyingError as? (any PrettyStringConvertible) else {
86-
return underlyingError.localizedDescription
87-
}
85+
guard let prettyError = underlyingError as? (any PrettyStringConvertible) else { return "\(underlyingError)" }
8886
return prettyError.prettyDescription
8987
}
9088
}
@@ -106,5 +104,7 @@ extension ServerError: LocalizedError {
106104
/// This computed property provides a localized human-readable description of the server error, which is suitable for displaying to users.
107105
///
108106
/// - Returns: A localized string describing the server error.
109-
public var errorDescription: String? { description }
107+
public var errorDescription: String? {
108+
"Server encountered an error handling the operation \"\(operationID)\", caused by \"\(causeDescription)\", underlying error: \(underlyingError.localizedDescription)."
109+
}
110110
}

Sources/OpenAPIRuntime/Interface/ServerTransport.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ public protocol ServerTransport {
197197
/// print("<<<: \(response.status.code)")
198198
/// return (response, responseBody)
199199
/// } catch {
200-
/// print("!!!: \(error.localizedDescription)")
200+
/// print("!!!: \(error)")
201201
/// throw error
202202
/// }
203203
/// }

Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -285,5 +285,5 @@ public func XCTAssertEqualStringifiedData(
285285
do {
286286
let actualString = String(decoding: try expression1(), as: UTF8.self)
287287
XCTAssertEqual(actualString, try expression2(), file: file, line: line)
288-
} catch { XCTFail(error.localizedDescription, file: file, line: line) }
288+
} catch { XCTFail("\(error)", file: file, line: line) }
289289
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import HTTPTypes
16+
@_spi(Generated) @testable import OpenAPIRuntime
17+
import XCTest
18+
19+
final class Test_ServerError: XCTestCase {
20+
func testPrinting() throws {
21+
let upstreamError = RuntimeError.handlerFailed(PrintableError())
22+
let error: any Error = ServerError(
23+
operationID: "op",
24+
request: .init(soar_path: "/test", method: .get),
25+
requestBody: nil,
26+
requestMetadata: .init(),
27+
causeDescription: upstreamError.prettyDescription,
28+
underlyingError: upstreamError.underlyingError ?? upstreamError
29+
)
30+
XCTAssertEqual(
31+
"\(error)",
32+
"Server error - cause description: 'User handler threw an error.', underlying error: Just description, operationID: op, request: GET /test [], requestBody: <nil>, metadata: Path parameters: [:], operationInput: <nil>, operationOutput: <nil>"
33+
)
34+
XCTAssertEqual(
35+
error.localizedDescription,
36+
"Server encountered an error handling the operation \"op\", caused by \"User handler threw an error.\", underlying error: Just errorDescription."
37+
)
38+
}
39+
}

Tests/OpenAPIRuntimeTests/Errors/Test_RuntimeError.swift

+6
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ final class Test_RuntimeError: XCTestCase {
6868
)
6969
XCTAssertEqual(response.0.status, .badGateway)
7070
}
71+
72+
func testDescriptions() async throws {
73+
let error: any Error = RuntimeError.transportFailed(PrintableError())
74+
XCTAssertEqual("\(error)", "Transport threw an error.")
75+
XCTAssertEqual(error.localizedDescription, "Transport threw an error.")
76+
}
7177
}
7278

7379
enum TestErrorConvertible: Error, HTTPResponseConvertible {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import HTTPTypes
16+
@_spi(Generated) @testable import OpenAPIRuntime
17+
import XCTest
18+
19+
final class Test_ClientError: XCTestCase {
20+
func testPrinting() throws {
21+
let upstreamError = RuntimeError.transportFailed(PrintableError())
22+
let error: any Error = ClientError(
23+
operationID: "op",
24+
operationInput: "test",
25+
causeDescription: upstreamError.prettyDescription,
26+
underlyingError: upstreamError.underlyingError ?? upstreamError
27+
)
28+
XCTAssertEqual(
29+
"\(error)",
30+
"Client error - cause description: 'Transport threw an error.', underlying error: Just description, operationID: op, operationInput: test, request: <nil>, requestBody: <nil>, baseURL: <nil>, response: <nil>, responseBody: <nil>"
31+
)
32+
XCTAssertEqual(
33+
error.localizedDescription,
34+
"Client encountered an error invoking the operation \"op\", caused by \"Transport threw an error.\", underlying error: Just errorDescription."
35+
)
36+
}
37+
}

Tests/OpenAPIRuntimeTests/Test_Runtime.swift

+8-3
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ extension ArraySlice<UInt8> {
210210

211211
struct TestError: Error, Equatable {}
212212

213+
struct PrintableError: Error, CustomStringConvertible, LocalizedError {
214+
var description: String { "Just description" }
215+
var errorDescription: String? { "Just errorDescription" }
216+
}
217+
213218
struct MockMiddleware: ClientMiddleware, ServerMiddleware {
214219
enum FailurePhase {
215220
case never
@@ -345,7 +350,7 @@ struct PrintingMiddleware: ClientMiddleware {
345350
print("Received: \(response.status)")
346351
return (response, responseBody)
347352
} catch {
348-
print("Failed with error: \(error.localizedDescription)")
353+
print("Failed with error: \(error)")
349354
throw error
350355
}
351356
}
@@ -373,7 +378,7 @@ public func XCTAssertEqualStringifiedData<S: Sequence>(
373378
}
374379
let actualString = String(decoding: Array(value1), as: UTF8.self)
375380
XCTAssertEqual(actualString, try expression2(), file: file, line: line)
376-
} catch { XCTFail(error.localizedDescription, file: file, line: line) }
381+
} catch { XCTFail("\(error)", file: file, line: line) }
377382
}
378383

379384
/// Asserts that the string representation of binary data in an HTTP body is equal to an expected string.
@@ -454,7 +459,7 @@ public func XCTAssertEqualData<C1: Collection, C2: Collection>(
454459
file: file,
455460
line: line
456461
)
457-
} catch { XCTFail(error.localizedDescription, file: file, line: line) }
462+
} catch { XCTFail("\(error)", file: file, line: line) }
458463
}
459464

460465
/// Asserts that the data matches the expected value.

0 commit comments

Comments
 (0)