Skip to content

Commit

Permalink
Improved enriched error propagation from the transport and middlewares (
Browse files Browse the repository at this point in the history
#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
  • Loading branch information
czechboy0 authored Oct 26, 2023
1 parent 51bdb07 commit ad8bf04
Show file tree
Hide file tree
Showing 9 changed files with 700 additions and 47 deletions.
84 changes: 84 additions & 0 deletions Sources/OpenAPIRuntime/Deprecated/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,89 @@
//
//===----------------------------------------------------------------------===//
import Foundation
import HTTPTypes

// MARK: - Functionality to be removed in the future

extension ClientError {
/// Creates a new error.
/// - Parameters:
/// - operationID: The OpenAPI operation identifier.
/// - operationInput: The operation-specific Input value.
/// - request: The HTTP request created during the operation.
/// - requestBody: The HTTP request body created during the operation.
/// - baseURL: The base URL for HTTP requests.
/// - response: The HTTP response received during the operation.
/// - responseBody: The HTTP response body received during the operation.
/// - underlyingError: The underlying error that caused the operation
/// to fail.
@available(
*,
deprecated,
renamed:
"ClientError.init(operationID:operationInput:request:requestBody:baseURL:response:responseBody:causeDescription:underlyingError:)",
message: "Use the initializer with a causeDescription parameter."
)
public init(
operationID: String,
operationInput: any Sendable,
request: HTTPRequest? = nil,
requestBody: HTTPBody? = nil,
baseURL: URL? = nil,
response: HTTPResponse? = nil,
responseBody: HTTPBody? = nil,
underlyingError: any Error
) {
self.init(
operationID: operationID,
operationInput: operationInput,
request: request,
requestBody: requestBody,
baseURL: baseURL,
response: response,
responseBody: responseBody,
causeDescription: "Legacy error without a causeDescription.",
underlyingError: underlyingError
)
}
}

extension ServerError {
/// Creates a new error.
/// - Parameters:
/// - operationID: The OpenAPI operation identifier.
/// - request: The HTTP request provided to the server.
/// - requestBody: The HTTP request body provided to the server.
/// - requestMetadata: The request metadata extracted by the server.
/// - operationInput: An operation-specific Input value.
/// - operationOutput: An operation-specific Output value.
/// - underlyingError: The underlying error that caused the operation
/// to fail.
@available(
*,
deprecated,
renamed:
"ServerError.init(operationID:request:requestBody:requestMetadata:operationInput:operationOutput:causeDescription:underlyingError:)",
message: "Use the initializer with a causeDescription parameter."
)
public init(
operationID: String,
request: HTTPRequest,
requestBody: HTTPBody?,
requestMetadata: ServerRequestMetadata,
operationInput: (any Sendable)? = nil,
operationOutput: (any Sendable)? = nil,
underlyingError: any Error
) {
self.init(
operationID: operationID,
request: request,
requestBody: requestBody,
requestMetadata: requestMetadata,
operationInput: operationInput,
operationOutput: operationOutput,
causeDescription: "Legacy error without a causeDescription.",
underlyingError: underlyingError
)
}
}
10 changes: 9 additions & 1 deletion Sources/OpenAPIRuntime/Errors/ClientError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ public struct ClientError: Error {
/// Will be nil if the error resulted before the response was received.
public var responseBody: HTTPBody?

/// A user-facing description of what caused the underlying error
/// to be thrown.
public var causeDescription: String

/// The underlying error that caused the operation to fail.
public var underlyingError: any Error

Expand All @@ -76,6 +80,8 @@ public struct ClientError: Error {
/// - baseURL: The base URL for HTTP requests.
/// - response: The HTTP response received during the operation.
/// - responseBody: The HTTP response body received during the operation.
/// - causeDescription: A user-facing description of what caused
/// the underlying error to be thrown.
/// - underlyingError: The underlying error that caused the operation
/// to fail.
public init(
Expand All @@ -86,6 +92,7 @@ public struct ClientError: Error {
baseURL: URL? = nil,
response: HTTPResponse? = nil,
responseBody: HTTPBody? = nil,
causeDescription: String,
underlyingError: any Error
) {
self.operationID = operationID
Expand All @@ -95,6 +102,7 @@ public struct ClientError: Error {
self.baseURL = baseURL
self.response = response
self.responseBody = responseBody
self.causeDescription = causeDescription
self.underlyingError = underlyingError
}

Expand All @@ -115,7 +123,7 @@ extension ClientError: CustomStringConvertible {
///
/// - Returns: A string describing the client error and its associated details.
public var description: String {
"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)"
"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>")"
}
}

Expand Down
23 changes: 19 additions & 4 deletions Sources/OpenAPIRuntime/Errors/RuntimeError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,25 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret

// Transport/Handler
case transportFailed(any Error)
case middlewareFailed(middlewareType: Any.Type, any Error)
case handlerFailed(any Error)

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

/// A wrapped root cause error, if one was thrown by other code.
var underlyingError: (any Error)? {
switch self {
case .transportFailed(let error),
.handlerFailed(let error),
.middlewareFailed(_, let error):
return error
default:
return nil
}
}

// MARK: CustomStringConvertible

var description: String {
Expand Down Expand Up @@ -103,10 +116,12 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
return "Missing required request body"
case .missingRequiredResponseBody:
return "Missing required response body"
case .transportFailed(let underlyingError):
return "Transport failed with error: \(underlyingError.localizedDescription)"
case .handlerFailed(let underlyingError):
return "User handler failed with error: \(underlyingError.localizedDescription)"
case .transportFailed:
return "Transport threw an error."
case .middlewareFailed(middlewareType: let type, _):
return "Middleware of type '\(type)' threw an error."
case .handlerFailed:
return "User handler threw an error."
case .unexpectedResponseStatus(let expectedStatus, let response):
return "Unexpected response, expected status code: \(expectedStatus), response: \(response)"
case .unexpectedResponseBody(let expectedContentType, let body):
Expand Down
12 changes: 10 additions & 2 deletions Sources/OpenAPIRuntime/Errors/ServerError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ public struct ServerError: Error {
/// Is nil if error was thrown before/during Output -> response conversion.
public var operationOutput: (any Sendable)?

/// A user-facing description of what caused the underlying error
/// to be thrown.
public var causeDescription: String

/// The underlying error that caused the operation to fail.
public var underlyingError: any Error

Expand All @@ -51,6 +55,8 @@ public struct ServerError: Error {
/// - requestMetadata: The request metadata extracted by the server.
/// - operationInput: An operation-specific Input value.
/// - operationOutput: An operation-specific Output value.
/// - causeDescription: A user-facing description of what caused
/// the underlying error to be thrown.
/// - underlyingError: The underlying error that caused the operation
/// to fail.
public init(
Expand All @@ -60,14 +66,16 @@ public struct ServerError: Error {
requestMetadata: ServerRequestMetadata,
operationInput: (any Sendable)? = nil,
operationOutput: (any Sendable)? = nil,
underlyingError: (any Error)
causeDescription: String,
underlyingError: any Error
) {
self.operationID = operationID
self.request = request
self.requestBody = requestBody
self.requestMetadata = requestMetadata
self.operationInput = operationInput
self.operationOutput = operationOutput
self.causeDescription = causeDescription
self.underlyingError = underlyingError
}

Expand All @@ -88,7 +96,7 @@ extension ServerError: CustomStringConvertible {
///
/// - Returns: A string describing the server error and its associated details.
public var description: String {
"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)"
"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>")"
}
}

Expand Down
90 changes: 65 additions & 25 deletions Sources/OpenAPIRuntime/Interface/UniversalClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,76 +90,116 @@ import Foundation
serializer: @Sendable (OperationInput) throws -> (HTTPRequest, HTTPBody?),
deserializer: @Sendable (HTTPResponse, HTTPBody?) async throws -> OperationOutput
) async throws -> OperationOutput where OperationInput: Sendable, OperationOutput: Sendable {
@Sendable
func wrappingErrors<R>(
@Sendable func wrappingErrors<R>(
work: () async throws -> R,
mapError: (any Error) -> any Error
) async throws -> R {
do {
return try await work()
} catch let error as ClientError {
throw error
} catch {
throw mapError(error)
}
}
let baseURL = serverURL
func makeError(
@Sendable func makeError(
request: HTTPRequest? = nil,
requestBody: HTTPBody? = nil,
baseURL: URL? = nil,
response: HTTPResponse? = nil,
responseBody: HTTPBody? = nil,
error: any Error
) -> any Error {
ClientError(
if var error = error as? ClientError {
error.request = error.request ?? request
error.requestBody = error.requestBody ?? requestBody
error.baseURL = error.baseURL ?? baseURL
error.response = error.response ?? response
error.responseBody = error.responseBody ?? responseBody
return error
}
let causeDescription: String
let underlyingError: any Error
if let runtimeError = error as? RuntimeError {
causeDescription = runtimeError.prettyDescription
underlyingError = runtimeError.underlyingError ?? error
} else {
causeDescription = "Unknown"
underlyingError = error
}
return ClientError(
operationID: operationID,
operationInput: input,
request: request,
requestBody: requestBody,
baseURL: baseURL,
response: response,
responseBody: responseBody,
underlyingError: error
causeDescription: causeDescription,
underlyingError: underlyingError
)
}
let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors {
try serializer(input)
} mapError: { error in
makeError(error: error)
}
let (response, responseBody): (HTTPResponse, HTTPBody?) = try await wrappingErrors {
var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = {
var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = {
(_request, _body, _url) in
try await wrappingErrors {
try await transport.send(
_request,
body: _body,
baseURL: _url,
operationID: operationID
)
} mapError: { error in
makeError(
request: request,
requestBody: requestBody,
baseURL: baseURL,
error: RuntimeError.transportFailed(error)
)
}
}
for middleware in middlewares.reversed() {
let tmp = next
next = {
(_request, _body, _url) in
try await wrappingErrors {
try await transport.send(
try await middleware.intercept(
_request,
body: _body,
baseURL: _url,
operationID: operationID
)
} mapError: { error in
RuntimeError.transportFailed(error)
}
}
for middleware in middlewares.reversed() {
let tmp = next
next = {
try await middleware.intercept(
$0,
body: $1,
baseURL: $2,
operationID: operationID,
next: tmp
)
} mapError: { error in
makeError(
request: request,
requestBody: requestBody,
baseURL: baseURL,
error: RuntimeError.middlewareFailed(
middlewareType: type(of: middleware),
error
)
)
}
}
return try await next(request, requestBody, baseURL)
} mapError: { error in
makeError(request: request, baseURL: baseURL, error: error)
}
let (response, responseBody): (HTTPResponse, HTTPBody?) = try await next(request, requestBody, baseURL)
return try await wrappingErrors {
try await deserializer(response, responseBody)
} mapError: { error in
makeError(request: request, baseURL: baseURL, response: response, error: error)
makeError(
request: request,
requestBody: requestBody,
baseURL: baseURL,
response: response,
responseBody: responseBody,
error: error
)
}
}
}
Loading

0 comments on commit ad8bf04

Please sign in to comment.