Skip to content

Commit ad8bf04

Browse files
authored
Improved enriched error propagation from the transport and middlewares (#63)
Improved enriched error propagation from the transport and middlewares ### Motivation Fixes apple/swift-openapi-generator#302 and apple/swift-openapi-generator#17. The issue was that we hid away errors thrown in transports and middlewares, and the adopter would get `ClientError` where the `underlyingError` wasn't the error thrown by the underlying transport/middleware, but instead a private wrapper of ours. ### Modifications Make sure `{Client,Server}Error.underlyingError` contains the error thrown from the underlying transport/middleware when that was the cause of the error, otherwise keep `RuntimeError` there as was the behavior until now. Also added a `causeDescription` property on both public error types to allow communicating the context for the underlying error. Also made sure middleware errors are now wrapped in Client/ServerError, they weren't before so didn't contain the context necessary to debug issues well. ### Result Adopters can now extract the errors thrown e.g. by URLSession from our public error types using the `underlyingError` property and understand the context of where it was thrown by checking the user-facing `causeDescription`. Also, adopters now get enriched errors thrown from middlewares. ### Test Plan Wrote unit tests for both UniversalClient and UniversalServer, inevitably found some minor bugs there as well, fixed them all, plus the unit tests now verify the behavior new in this PR. Reviewed by: glbrntt Builds: ✔︎ pull request validation (5.10) - Build finished. ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. ✖︎ pull request validation (api breakage) - Build finished. #63
1 parent 51bdb07 commit ad8bf04

File tree

9 files changed

+700
-47
lines changed

9 files changed

+700
-47
lines changed

Sources/OpenAPIRuntime/Deprecated/Deprecated.swift

+84
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,89 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414
import Foundation
15+
import HTTPTypes
1516

1617
// MARK: - Functionality to be removed in the future
18+
19+
extension ClientError {
20+
/// Creates a new error.
21+
/// - Parameters:
22+
/// - operationID: The OpenAPI operation identifier.
23+
/// - operationInput: The operation-specific Input value.
24+
/// - request: The HTTP request created during the operation.
25+
/// - requestBody: The HTTP request body created during the operation.
26+
/// - baseURL: The base URL for HTTP requests.
27+
/// - response: The HTTP response received during the operation.
28+
/// - responseBody: The HTTP response body received during the operation.
29+
/// - underlyingError: The underlying error that caused the operation
30+
/// to fail.
31+
@available(
32+
*,
33+
deprecated,
34+
renamed:
35+
"ClientError.init(operationID:operationInput:request:requestBody:baseURL:response:responseBody:causeDescription:underlyingError:)",
36+
message: "Use the initializer with a causeDescription parameter."
37+
)
38+
public init(
39+
operationID: String,
40+
operationInput: any Sendable,
41+
request: HTTPRequest? = nil,
42+
requestBody: HTTPBody? = nil,
43+
baseURL: URL? = nil,
44+
response: HTTPResponse? = nil,
45+
responseBody: HTTPBody? = nil,
46+
underlyingError: any Error
47+
) {
48+
self.init(
49+
operationID: operationID,
50+
operationInput: operationInput,
51+
request: request,
52+
requestBody: requestBody,
53+
baseURL: baseURL,
54+
response: response,
55+
responseBody: responseBody,
56+
causeDescription: "Legacy error without a causeDescription.",
57+
underlyingError: underlyingError
58+
)
59+
}
60+
}
61+
62+
extension ServerError {
63+
/// Creates a new error.
64+
/// - Parameters:
65+
/// - operationID: The OpenAPI operation identifier.
66+
/// - request: The HTTP request provided to the server.
67+
/// - requestBody: The HTTP request body provided to the server.
68+
/// - requestMetadata: The request metadata extracted by the server.
69+
/// - operationInput: An operation-specific Input value.
70+
/// - operationOutput: An operation-specific Output value.
71+
/// - underlyingError: The underlying error that caused the operation
72+
/// to fail.
73+
@available(
74+
*,
75+
deprecated,
76+
renamed:
77+
"ServerError.init(operationID:request:requestBody:requestMetadata:operationInput:operationOutput:causeDescription:underlyingError:)",
78+
message: "Use the initializer with a causeDescription parameter."
79+
)
80+
public init(
81+
operationID: String,
82+
request: HTTPRequest,
83+
requestBody: HTTPBody?,
84+
requestMetadata: ServerRequestMetadata,
85+
operationInput: (any Sendable)? = nil,
86+
operationOutput: (any Sendable)? = nil,
87+
underlyingError: any Error
88+
) {
89+
self.init(
90+
operationID: operationID,
91+
request: request,
92+
requestBody: requestBody,
93+
requestMetadata: requestMetadata,
94+
operationInput: operationInput,
95+
operationOutput: operationOutput,
96+
causeDescription: "Legacy error without a causeDescription.",
97+
underlyingError: underlyingError
98+
)
99+
}
100+
}

Sources/OpenAPIRuntime/Errors/ClientError.swift

+9-1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ public struct ClientError: Error {
6464
/// Will be nil if the error resulted before the response was received.
6565
public var responseBody: HTTPBody?
6666

67+
/// A user-facing description of what caused the underlying error
68+
/// to be thrown.
69+
public var causeDescription: String
70+
6771
/// The underlying error that caused the operation to fail.
6872
public var underlyingError: any Error
6973

@@ -76,6 +80,8 @@ public struct ClientError: Error {
7680
/// - baseURL: The base URL for HTTP requests.
7781
/// - response: The HTTP response received during the operation.
7882
/// - responseBody: The HTTP response body received during the operation.
83+
/// - causeDescription: A user-facing description of what caused
84+
/// the underlying error to be thrown.
7985
/// - underlyingError: The underlying error that caused the operation
8086
/// to fail.
8187
public init(
@@ -86,6 +92,7 @@ public struct ClientError: Error {
8692
baseURL: URL? = nil,
8793
response: HTTPResponse? = nil,
8894
responseBody: HTTPBody? = nil,
95+
causeDescription: String,
8996
underlyingError: any Error
9097
) {
9198
self.operationID = operationID
@@ -95,6 +102,7 @@ public struct ClientError: Error {
95102
self.baseURL = baseURL
96103
self.response = response
97104
self.responseBody = responseBody
105+
self.causeDescription = causeDescription
98106
self.underlyingError = underlyingError
99107
}
100108

@@ -115,7 +123,7 @@ extension ClientError: CustomStringConvertible {
115123
///
116124
/// - Returns: A string describing the client error and its associated details.
117125
public var description: String {
118-
"Client error - operationID: \(operationID), operationInput: \(String(describing: operationInput)), request: \(request?.prettyDescription ?? "<nil>"), requestBody: \(requestBody?.prettyDescription ?? "<nil>"), baseURL: \(baseURL?.absoluteString ?? "<nil>"), response: \(response?.prettyDescription ?? "<nil>"), responseBody: \(responseBody?.prettyDescription ?? "<nil>") , underlying error: \(underlyingErrorDescription)"
126+
"Client error - cause description: '\(causeDescription)', underlying error: \(underlyingErrorDescription), operationID: \(operationID), operationInput: \(String(describing: operationInput)), request: \(request?.prettyDescription ?? "<nil>"), requestBody: \(requestBody?.prettyDescription ?? "<nil>"), baseURL: \(baseURL?.absoluteString ?? "<nil>"), response: \(response?.prettyDescription ?? "<nil>"), responseBody: \(responseBody?.prettyDescription ?? "<nil>")"
119127
}
120128
}
121129

Sources/OpenAPIRuntime/Errors/RuntimeError.swift

+19-4
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,25 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
5555

5656
// Transport/Handler
5757
case transportFailed(any Error)
58+
case middlewareFailed(middlewareType: Any.Type, any Error)
5859
case handlerFailed(any Error)
5960

6061
// Unexpected response (thrown by shorthand APIs)
6162
case unexpectedResponseStatus(expectedStatus: String, response: any Sendable)
6263
case unexpectedResponseBody(expectedContent: String, body: any Sendable)
6364

65+
/// A wrapped root cause error, if one was thrown by other code.
66+
var underlyingError: (any Error)? {
67+
switch self {
68+
case .transportFailed(let error),
69+
.handlerFailed(let error),
70+
.middlewareFailed(_, let error):
71+
return error
72+
default:
73+
return nil
74+
}
75+
}
76+
6477
// MARK: CustomStringConvertible
6578

6679
var description: String {
@@ -103,10 +116,12 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
103116
return "Missing required request body"
104117
case .missingRequiredResponseBody:
105118
return "Missing required response body"
106-
case .transportFailed(let underlyingError):
107-
return "Transport failed with error: \(underlyingError.localizedDescription)"
108-
case .handlerFailed(let underlyingError):
109-
return "User handler failed with error: \(underlyingError.localizedDescription)"
119+
case .transportFailed:
120+
return "Transport threw an error."
121+
case .middlewareFailed(middlewareType: let type, _):
122+
return "Middleware of type '\(type)' threw an error."
123+
case .handlerFailed:
124+
return "User handler threw an error."
110125
case .unexpectedResponseStatus(let expectedStatus, let response):
111126
return "Unexpected response, expected status code: \(expectedStatus), response: \(response)"
112127
case .unexpectedResponseBody(let expectedContentType, let body):

Sources/OpenAPIRuntime/Errors/ServerError.swift

+10-2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public struct ServerError: Error {
4040
/// Is nil if error was thrown before/during Output -> response conversion.
4141
public var operationOutput: (any Sendable)?
4242

43+
/// A user-facing description of what caused the underlying error
44+
/// to be thrown.
45+
public var causeDescription: String
46+
4347
/// The underlying error that caused the operation to fail.
4448
public var underlyingError: any Error
4549

@@ -51,6 +55,8 @@ public struct ServerError: Error {
5155
/// - requestMetadata: The request metadata extracted by the server.
5256
/// - operationInput: An operation-specific Input value.
5357
/// - operationOutput: An operation-specific Output value.
58+
/// - causeDescription: A user-facing description of what caused
59+
/// the underlying error to be thrown.
5460
/// - underlyingError: The underlying error that caused the operation
5561
/// to fail.
5662
public init(
@@ -60,14 +66,16 @@ public struct ServerError: Error {
6066
requestMetadata: ServerRequestMetadata,
6167
operationInput: (any Sendable)? = nil,
6268
operationOutput: (any Sendable)? = nil,
63-
underlyingError: (any Error)
69+
causeDescription: String,
70+
underlyingError: any Error
6471
) {
6572
self.operationID = operationID
6673
self.request = request
6774
self.requestBody = requestBody
6875
self.requestMetadata = requestMetadata
6976
self.operationInput = operationInput
7077
self.operationOutput = operationOutput
78+
self.causeDescription = causeDescription
7179
self.underlyingError = underlyingError
7280
}
7381

@@ -88,7 +96,7 @@ extension ServerError: CustomStringConvertible {
8896
///
8997
/// - Returns: A string describing the server error and its associated details.
9098
public var description: String {
91-
"Server error - operationID: \(operationID), request: \(request.prettyDescription), requestBody: \(requestBody?.prettyDescription ?? "<nil>"), metadata: \(requestMetadata.description), operationInput: \(operationInput.map { String(describing: $0) } ?? "<nil>"), operationOutput: \(operationOutput.map { String(describing: $0) } ?? "<nil>"), underlying error: \(underlyingErrorDescription)"
99+
"Server error - cause description: '\(causeDescription)', underlying error: \(underlyingErrorDescription), operationID: \(operationID), request: \(request.prettyDescription), requestBody: \(requestBody?.prettyDescription ?? "<nil>"), metadata: \(requestMetadata.description), operationInput: \(operationInput.map { String(describing: $0) } ?? "<nil>"), operationOutput: \(operationOutput.map { String(describing: $0) } ?? "<nil>")"
92100
}
93101
}
94102

Sources/OpenAPIRuntime/Interface/UniversalClient.swift

+65-25
Original file line numberDiff line numberDiff line change
@@ -90,76 +90,116 @@ import Foundation
9090
serializer: @Sendable (OperationInput) throws -> (HTTPRequest, HTTPBody?),
9191
deserializer: @Sendable (HTTPResponse, HTTPBody?) async throws -> OperationOutput
9292
) async throws -> OperationOutput where OperationInput: Sendable, OperationOutput: Sendable {
93-
@Sendable
94-
func wrappingErrors<R>(
93+
@Sendable func wrappingErrors<R>(
9594
work: () async throws -> R,
9695
mapError: (any Error) -> any Error
9796
) async throws -> R {
9897
do {
9998
return try await work()
99+
} catch let error as ClientError {
100+
throw error
100101
} catch {
101102
throw mapError(error)
102103
}
103104
}
104105
let baseURL = serverURL
105-
func makeError(
106+
@Sendable func makeError(
106107
request: HTTPRequest? = nil,
107108
requestBody: HTTPBody? = nil,
108109
baseURL: URL? = nil,
109110
response: HTTPResponse? = nil,
110111
responseBody: HTTPBody? = nil,
111112
error: any Error
112113
) -> any Error {
113-
ClientError(
114+
if var error = error as? ClientError {
115+
error.request = error.request ?? request
116+
error.requestBody = error.requestBody ?? requestBody
117+
error.baseURL = error.baseURL ?? baseURL
118+
error.response = error.response ?? response
119+
error.responseBody = error.responseBody ?? responseBody
120+
return error
121+
}
122+
let causeDescription: String
123+
let underlyingError: any Error
124+
if let runtimeError = error as? RuntimeError {
125+
causeDescription = runtimeError.prettyDescription
126+
underlyingError = runtimeError.underlyingError ?? error
127+
} else {
128+
causeDescription = "Unknown"
129+
underlyingError = error
130+
}
131+
return ClientError(
114132
operationID: operationID,
115133
operationInput: input,
116134
request: request,
117135
requestBody: requestBody,
118136
baseURL: baseURL,
119137
response: response,
120138
responseBody: responseBody,
121-
underlyingError: error
139+
causeDescription: causeDescription,
140+
underlyingError: underlyingError
122141
)
123142
}
124143
let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors {
125144
try serializer(input)
126145
} mapError: { error in
127146
makeError(error: error)
128147
}
129-
let (response, responseBody): (HTTPResponse, HTTPBody?) = try await wrappingErrors {
130-
var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = {
148+
var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = {
149+
(_request, _body, _url) in
150+
try await wrappingErrors {
151+
try await transport.send(
152+
_request,
153+
body: _body,
154+
baseURL: _url,
155+
operationID: operationID
156+
)
157+
} mapError: { error in
158+
makeError(
159+
request: request,
160+
requestBody: requestBody,
161+
baseURL: baseURL,
162+
error: RuntimeError.transportFailed(error)
163+
)
164+
}
165+
}
166+
for middleware in middlewares.reversed() {
167+
let tmp = next
168+
next = {
131169
(_request, _body, _url) in
132170
try await wrappingErrors {
133-
try await transport.send(
171+
try await middleware.intercept(
134172
_request,
135173
body: _body,
136174
baseURL: _url,
137-
operationID: operationID
138-
)
139-
} mapError: { error in
140-
RuntimeError.transportFailed(error)
141-
}
142-
}
143-
for middleware in middlewares.reversed() {
144-
let tmp = next
145-
next = {
146-
try await middleware.intercept(
147-
$0,
148-
body: $1,
149-
baseURL: $2,
150175
operationID: operationID,
151176
next: tmp
152177
)
178+
} mapError: { error in
179+
makeError(
180+
request: request,
181+
requestBody: requestBody,
182+
baseURL: baseURL,
183+
error: RuntimeError.middlewareFailed(
184+
middlewareType: type(of: middleware),
185+
error
186+
)
187+
)
153188
}
154189
}
155-
return try await next(request, requestBody, baseURL)
156-
} mapError: { error in
157-
makeError(request: request, baseURL: baseURL, error: error)
158190
}
191+
let (response, responseBody): (HTTPResponse, HTTPBody?) = try await next(request, requestBody, baseURL)
159192
return try await wrappingErrors {
160193
try await deserializer(response, responseBody)
161194
} mapError: { error in
162-
makeError(request: request, baseURL: baseURL, response: response, error: error)
195+
makeError(
196+
request: request,
197+
requestBody: requestBody,
198+
baseURL: baseURL,
199+
response: response,
200+
responseBody: responseBody,
201+
error: error
202+
)
163203
}
164204
}
165205
}

0 commit comments

Comments
 (0)