Skip to content

Commit 170ae32

Browse files
committed
Add centralized error handling through Configuration
This introduces ErrorHandler protocols that allow observation and logging of errors for both client and server operations, enabling centralized error management, logging, and monitoring capabilities. ## Changes ### New Error Handler Protocols - Add `ClientErrorHandler` protocol for observing client-side errors - Add `ServerErrorHandler` protocol for observing server-side errors - Handlers receive fully-wrapped `ClientError` or `ServerError` instances - Simple observation pattern: handlers don't transform or return errors ### Configuration Integration - Add optional `clientErrorHandler` property to `Configuration` (default: `nil`) - Add optional `serverErrorHandler` property to `Configuration` (default: `nil`) - Zero overhead when not configured - no default implementations or allocations - Completely backward compatible - existing code requires no changes ### Runtime Integration - Update `UniversalClient` to call configured `ClientErrorHandler` after wrapping errors - Update `UniversalServer` to call configured `ServerErrorHandler` after wrapping errors - Error wrapping logic remains unchanged from existing behavior - Handlers are invoked only if configured, with no performance impact otherwise ### Testing - Add comprehensive unit tests for error handler protocols (3 tests) - Add integration tests for end-to-end error handling flow (9 tests) - All 218 tests pass, demonstrating full backward compatibility ## Usage Example ```swift // Custom error handler with logging struct LoggingClientErrorHandler: ClientErrorHandler { func handleClientError(_ error: ClientError) { logger.error("Client error in \(error.operationID): \(error.causeDescription)") analytics.track("client_error", metadata: [ "operation": error.operationID, "status": error.response?.status.code ]) } } // Configure client with custom handler let config = Configuration( clientErrorHandler: LoggingClientErrorHandler() ) let client = UniversalClient(configuration: config, transport: transport) ``` ## Design Rationale - **Observation-Only**: Handlers observe but don't transform errors, keeping the API simple - **RuntimeError Stays Internal**: No exposure of internal error types to public API - **Zero Overhead**: Optional handlers mean no cost when not used - **Backward Compatible**: Existing code works unchanged; opt-in for new functionality - **Type-Safe**: Protocol-based design with compile-time verification
1 parent 7cdf333 commit 170ae32

File tree

6 files changed

+551
-3
lines changed

6 files changed

+551
-3
lines changed

Sources/OpenAPIRuntime/Conversion/Configuration.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ public struct Configuration: Sendable {
152152
/// Custom XML coder for encoding and decoding xml bodies.
153153
public var xmlCoder: (any CustomCoder)?
154154

155+
/// The handler for client-side errors.
156+
///
157+
/// This handler is invoked after a client error has been wrapped in a ``ClientError``.
158+
/// Use this to add logging, monitoring, or analytics for client-side errors.
159+
/// If `nil`, errors are thrown without additional handling.
160+
public var clientErrorHandler: (any ClientErrorHandler)?
161+
162+
/// The handler for server-side errors.
163+
///
164+
/// This handler is invoked after a server error has been wrapped in a ``ServerError``.
165+
/// Use this to add logging, monitoring, or analytics for server-side errors.
166+
/// If `nil`, errors are thrown without additional handling.
167+
public var serverErrorHandler: (any ServerErrorHandler)?
168+
155169
/// Creates a new configuration with the specified values.
156170
///
157171
/// - Parameters:
@@ -160,15 +174,21 @@ public struct Configuration: Sendable {
160174
/// - jsonEncodingOptions: The options for the underlying JSON encoder.
161175
/// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies.
162176
/// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads.
177+
/// - clientErrorHandler: Optional handler for observing client-side errors. Defaults to `nil`.
178+
/// - serverErrorHandler: Optional handler for observing server-side errors. Defaults to `nil`.
163179
public init(
164180
dateTranscoder: any DateTranscoder = .iso8601,
165181
jsonEncodingOptions: JSONEncodingOptions = [.sortedKeys, .prettyPrinted],
166182
multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random,
167-
xmlCoder: (any CustomCoder)? = nil
183+
xmlCoder: (any CustomCoder)? = nil,
184+
clientErrorHandler: (any ClientErrorHandler)? = nil,
185+
serverErrorHandler: (any ServerErrorHandler)? = nil
168186
) {
169187
self.dateTranscoder = dateTranscoder
170188
self.jsonEncodingOptions = jsonEncodingOptions
171189
self.multipartBoundaryGenerator = multipartBoundaryGenerator
172190
self.xmlCoder = xmlCoder
191+
self.clientErrorHandler = clientErrorHandler
192+
self.serverErrorHandler = serverErrorHandler
173193
}
174194
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 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+
import Foundation
15+
import HTTPTypes
16+
17+
// MARK: - Client Error Handler
18+
19+
/// A protocol for observing and logging errors that occur during client operations.
20+
///
21+
/// Implement this protocol to add logging, monitoring, or analytics for client-side errors.
22+
/// This handler is called after the error has been wrapped in a ``ClientError``, providing
23+
/// full context about the operation and the error.
24+
///
25+
/// - Note: This handler should not throw or modify the error. Its purpose is observation only.
26+
public protocol ClientErrorHandler: Sendable {
27+
/// Called when a client error occurs, after it has been wrapped in a ``ClientError``.
28+
///
29+
/// Use this method to log, monitor, or send analytics about the error. The error
30+
/// will be thrown to the caller after this method returns.
31+
///
32+
/// - Parameter error: The ``ClientError`` that will be thrown to the caller.
33+
func handleClientError(_ error: ClientError)
34+
}
35+
36+
37+
// MARK: - Server Error Handler
38+
39+
/// A protocol for observing and logging errors that occur during server operations.
40+
///
41+
/// Implement this protocol to add logging, monitoring, or analytics for server-side errors.
42+
/// This handler is called after the error has been wrapped in a ``ServerError``, providing
43+
/// full context about the operation and the HTTP response that will be sent.
44+
///
45+
/// - Note: This handler should not throw or modify the error. Its purpose is observation only.
46+
public protocol ServerErrorHandler: Sendable {
47+
/// Called when a server error occurs, after it has been wrapped in a ``ServerError``.
48+
///
49+
/// Use this method to log, monitor, or send analytics about the error. The error
50+
/// will be thrown to the error handling middleware after this method returns.
51+
///
52+
/// - Parameter error: The ``ServerError`` that will be thrown to the middleware.
53+
func handleServerError(_ error: ServerError)
54+
}

Sources/OpenAPIRuntime/Interface/UniversalClient.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import struct Foundation.URL
9898
}
9999
}
100100
let baseURL = serverURL
101+
let errorHandler = converter.configuration.clientErrorHandler
101102
@Sendable func makeError(
102103
request: HTTPRequest? = nil,
103104
requestBody: HTTPBody? = nil,
@@ -112,6 +113,7 @@ import struct Foundation.URL
112113
error.baseURL = error.baseURL ?? baseURL
113114
error.response = error.response ?? response
114115
error.responseBody = error.responseBody ?? responseBody
116+
errorHandler?.handleClientError(error)
115117
return error
116118
}
117119
let causeDescription: String
@@ -123,7 +125,7 @@ import struct Foundation.URL
123125
causeDescription = "Unknown"
124126
underlyingError = error
125127
}
126-
return ClientError(
128+
let clientError = ClientError(
127129
operationID: operationID,
128130
operationInput: input,
129131
request: request,
@@ -134,6 +136,8 @@ import struct Foundation.URL
134136
causeDescription: causeDescription,
135137
underlyingError: underlyingError
136138
)
139+
errorHandler?.handleClientError(clientError)
140+
return clientError
137141
}
138142
let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors {
139143
try serializer(input)

Sources/OpenAPIRuntime/Interface/UniversalServer.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,14 @@ import struct Foundation.URLComponents
102102
throw mapError(error)
103103
}
104104
}
105+
let errorHandler = converter.configuration.serverErrorHandler
105106
@Sendable func makeError(input: OperationInput? = nil, output: OperationOutput? = nil, error: any Error)
106107
-> any Error
107108
{
108109
if var error = error as? ServerError {
109110
error.operationInput = error.operationInput ?? input
110111
error.operationOutput = error.operationOutput ?? output
112+
errorHandler?.handleServerError(error)
111113
return error
112114
}
113115
let causeDescription: String
@@ -136,7 +138,7 @@ import struct Foundation.URLComponents
136138
httpHeaderFields = [:]
137139
httpBody = nil
138140
}
139-
return ServerError(
141+
let serverError = ServerError(
140142
operationID: operationID,
141143
request: request,
142144
requestBody: requestBody,
@@ -149,6 +151,8 @@ import struct Foundation.URLComponents
149151
httpHeaderFields: httpHeaderFields,
150152
httpBody: httpBody
151153
)
154+
errorHandler?.handleServerError(serverError)
155+
return serverError
152156
}
153157
var next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) =
154158
{ _request, _requestBody, _metadata in
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
// MARK: - Test Helpers
20+
21+
/// A custom client error handler that logs all errors for testing
22+
final class LoggingClientErrorHandler: ClientErrorHandler {
23+
var handledErrors: [ClientError] = []
24+
private let lock = NSLock()
25+
26+
func handleClientError(_ error: ClientError) {
27+
lock.lock()
28+
handledErrors.append(error)
29+
lock.unlock()
30+
}
31+
}
32+
33+
/// A custom server error handler that logs all errors for testing
34+
final class LoggingServerErrorHandler: ServerErrorHandler {
35+
var handledErrors: [ServerError] = []
36+
private let lock = NSLock()
37+
38+
func handleServerError(_ error: ServerError) {
39+
lock.lock()
40+
handledErrors.append(error)
41+
lock.unlock()
42+
}
43+
}
44+
45+
// MARK: - ErrorHandler Tests
46+
47+
final class Test_ErrorHandler: XCTestCase {
48+
49+
func testClientErrorHandler_IsCalledWithClientError() throws {
50+
let handler = LoggingClientErrorHandler()
51+
let clientError = ClientError(
52+
operationID: "testOp",
53+
operationInput: "test-input",
54+
request: .init(soar_path: "/test", method: .get),
55+
requestBody: nil,
56+
baseURL: URL(string: "https://example.com"),
57+
response: nil,
58+
responseBody: nil,
59+
causeDescription: "Test error",
60+
underlyingError: NSError(domain: "test", code: 1)
61+
)
62+
63+
handler.handleClientError(clientError)
64+
65+
XCTAssertEqual(handler.handledErrors.count, 1)
66+
XCTAssertEqual(handler.handledErrors[0].operationID, "testOp")
67+
XCTAssertEqual(handler.handledErrors[0].causeDescription, "Test error")
68+
}
69+
70+
func testServerErrorHandler_IsCalledWithServerError() throws {
71+
let handler = LoggingServerErrorHandler()
72+
let serverError = ServerError(
73+
operationID: "testOp",
74+
request: .init(soar_path: "/test", method: .post),
75+
requestBody: nil,
76+
requestMetadata: .init(),
77+
operationInput: "test-input",
78+
operationOutput: nil,
79+
causeDescription: "Test error",
80+
underlyingError: NSError(domain: "test", code: 1),
81+
httpStatus: .badRequest,
82+
httpHeaderFields: [:],
83+
httpBody: nil
84+
)
85+
86+
handler.handleServerError(serverError)
87+
88+
XCTAssertEqual(handler.handledErrors.count, 1)
89+
XCTAssertEqual(handler.handledErrors[0].operationID, "testOp")
90+
XCTAssertEqual(handler.handledErrors[0].httpStatus, .badRequest)
91+
}
92+
93+
func testMultipleErrors_AreAllLogged() throws {
94+
let clientHandler = LoggingClientErrorHandler()
95+
let serverHandler = LoggingServerErrorHandler()
96+
97+
// Log multiple client errors
98+
for i in 1...3 {
99+
let error = ClientError(
100+
operationID: "op\(i)",
101+
operationInput: nil as String?,
102+
request: nil,
103+
requestBody: nil,
104+
baseURL: nil,
105+
response: nil,
106+
responseBody: nil,
107+
causeDescription: "Error \(i)",
108+
underlyingError: NSError(domain: "test", code: i)
109+
)
110+
clientHandler.handleClientError(error)
111+
}
112+
113+
// Log multiple server errors
114+
for i in 1...3 {
115+
let error = ServerError(
116+
operationID: "op\(i)",
117+
request: .init(soar_path: "/test", method: .get),
118+
requestBody: nil,
119+
requestMetadata: .init(),
120+
operationInput: nil as String?,
121+
operationOutput: nil as String?,
122+
causeDescription: "Error \(i)",
123+
underlyingError: NSError(domain: "test", code: i),
124+
httpStatus: .internalServerError,
125+
httpHeaderFields: [:],
126+
httpBody: nil
127+
)
128+
serverHandler.handleServerError(error)
129+
}
130+
131+
XCTAssertEqual(clientHandler.handledErrors.count, 3)
132+
XCTAssertEqual(serverHandler.handledErrors.count, 3)
133+
}
134+
}

0 commit comments

Comments
 (0)