|
| 1 | +# SOAR-0015: Error Handler Protocols for Client and Server |
| 2 | + |
| 3 | +Introduce `ClientErrorHandler` and `ServerErrorHandler` protocols for centralized error observation on both client and server sides. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +- Proposal: SOAR-0015 |
| 8 | +- Author(s): [winnisx7](https://github.com/winnisx7) |
| 9 | +- Status: **Proposed** |
| 10 | +- Issue: [apple/swift-openapi-runtime#162](https://github.com/apple/swift-openapi-runtime/pull/162) |
| 11 | +- Implementation: |
| 12 | + - [apple/swift-openapi-runtime#162](https://github.com/apple/swift-openapi-runtime/pull/162) |
| 13 | +- Affected components: |
| 14 | + - runtime |
| 15 | + |
| 16 | +### Introduction |
| 17 | + |
| 18 | +This proposal introduces `ClientErrorHandler` and `ServerErrorHandler` protocols to provide extension points for centralized error observation on both client and server sides. These handlers are configured through the `Configuration` struct and are invoked after errors have been wrapped in `ClientError` or `ServerError`. |
| 19 | + |
| 20 | +### Motivation |
| 21 | + |
| 22 | +Currently, swift-openapi-runtime provides limited options for centralized error handling. Developers face the following challenges: |
| 23 | + |
| 24 | +**Problem 1: Scattered Error Handling** |
| 25 | + |
| 26 | +Errors must be handled individually at each API call site, leading to code duplication and inconsistent error handling. |
| 27 | + |
| 28 | +```swift |
| 29 | +// Current approach: individual handling at each call site |
| 30 | +do { |
| 31 | + let response = try await client.getUser(...) |
| 32 | +} catch { |
| 33 | + // Repeated error handling logic |
| 34 | + logger.error("API error: \(error)") |
| 35 | +} |
| 36 | + |
| 37 | +do { |
| 38 | + let response = try await client.getPosts(...) |
| 39 | +} catch { |
| 40 | + // Same error handling logic repeated |
| 41 | + logger.error("API error: \(error)") |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +**Problem 2: Middleware Limitations** |
| 46 | + |
| 47 | +The existing `ClientMiddleware` operates at the HTTP request/response level, making it difficult to intercept decoding errors or runtime errors. There is still a lack of extension points for error **observation**. |
| 48 | + |
| 49 | +**Problem 3: Telemetry and Logging Complexity** |
| 50 | + |
| 51 | +To collect telemetry or implement centralized logging for all errors, developers currently need to modify every API call site. |
| 52 | + |
| 53 | +**Problem 4: Difficulty Utilizing Error Context** |
| 54 | + |
| 55 | +`ClientError` and `ServerError` contain rich context information such as `operationID`, `request`, and `response`, but centralized analysis using this information is difficult. |
| 56 | + |
| 57 | +### Proposed solution |
| 58 | + |
| 59 | +Introduce `ClientErrorHandler` and `ServerErrorHandler` protocols and add optional handler properties to the `Configuration` struct. These handlers are invoked **after** errors have been wrapped in `ClientError` or `ServerError`, allowing logging, monitoring, and analytics operations. |
| 60 | + |
| 61 | +```swift |
| 62 | +// Custom error handler with logging |
| 63 | +struct LoggingClientErrorHandler: ClientErrorHandler { |
| 64 | + func handleClientError(_ error: ClientError) { |
| 65 | + logger.error("Client error in \(error.operationID): \(error.causeDescription)") |
| 66 | + analytics.track("client_error", metadata: [ |
| 67 | + "operation": error.operationID, |
| 68 | + "status": error.response?.status.code |
| 69 | + ]) |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +let config = Configuration( |
| 74 | + clientErrorHandler: LoggingClientErrorHandler() |
| 75 | +) |
| 76 | +let client = UniversalClient(configuration: config, transport: transport) |
| 77 | +``` |
| 78 | + |
| 79 | +### Detailed design |
| 80 | + |
| 81 | +#### New Protocol Definitions |
| 82 | + |
| 83 | +```swift |
| 84 | +/// A protocol for handling client-side errors. |
| 85 | +/// |
| 86 | +/// Implement this protocol to observe and react to errors that occur during |
| 87 | +/// client API calls. The handler is invoked after the error has been wrapped |
| 88 | +/// in a ``ClientError``. |
| 89 | +/// |
| 90 | +/// Use this to add logging, monitoring, or analytics for client-side errors. |
| 91 | +public protocol ClientErrorHandler: Sendable { |
| 92 | + /// Handles a client error. |
| 93 | + /// |
| 94 | + /// This method is called after an error has been wrapped in a ``ClientError`` |
| 95 | + /// but before it is thrown to the caller. |
| 96 | + /// |
| 97 | + /// - Parameter error: The client error that occurred, containing context such as |
| 98 | + /// the operation ID, request, response, and underlying cause. |
| 99 | + func handleClientError(_ error: ClientError) |
| 100 | +} |
| 101 | + |
| 102 | +/// A protocol for handling server-side errors. |
| 103 | +/// |
| 104 | +/// Implement this protocol to observe and react to errors that occur during |
| 105 | +/// server request handling. The handler is invoked after the error has been |
| 106 | +/// wrapped in a ``ServerError``. |
| 107 | +/// |
| 108 | +/// Use this to add logging, monitoring, or analytics for server-side errors. |
| 109 | +public protocol ServerErrorHandler: Sendable { |
| 110 | + /// Handles a server error. |
| 111 | + /// |
| 112 | + /// This method is called after an error has been wrapped in a ``ServerError`` |
| 113 | + /// but before it is thrown to the caller. |
| 114 | + /// |
| 115 | + /// - Parameter error: The server error that occurred, containing context such as |
| 116 | + /// the operation ID, request, and underlying cause. |
| 117 | + func handleServerError(_ error: ServerError) |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +#### Configuration Struct Changes |
| 122 | + |
| 123 | +```swift |
| 124 | +public struct Configuration: Sendable { |
| 125 | + // ... existing properties ... |
| 126 | + |
| 127 | + /// Custom XML coder for encoding and decoding xml bodies. |
| 128 | + public var xmlCoder: (any CustomCoder)? |
| 129 | + |
| 130 | + /// The handler for client-side errors. |
| 131 | + /// |
| 132 | + /// This handler is invoked after a client error has been wrapped in a ``ClientError``. |
| 133 | + /// Use this to add logging, monitoring, or analytics for client-side errors. |
| 134 | + /// If `nil`, errors are thrown without additional handling. |
| 135 | + public var clientErrorHandler: (any ClientErrorHandler)? |
| 136 | + |
| 137 | + /// The handler for server-side errors. |
| 138 | + /// |
| 139 | + /// This handler is invoked after a server error has been wrapped in a ``ServerError``. |
| 140 | + /// Use this to add logging, monitoring, or analytics for server-side errors. |
| 141 | + /// If `nil`, errors are thrown without additional handling. |
| 142 | + public var serverErrorHandler: (any ServerErrorHandler)? |
| 143 | + |
| 144 | + /// Creates a new configuration with the specified values. |
| 145 | + /// |
| 146 | + /// - Parameters: |
| 147 | + /// - dateTranscoder: The transcoder for date/time conversions. |
| 148 | + /// - multipartBoundaryGenerator: The generator for multipart boundaries. |
| 149 | + /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. |
| 150 | + /// - clientErrorHandler: Optional handler for observing client-side errors. Defaults to `nil`. |
| 151 | + /// - serverErrorHandler: Optional handler for observing server-side errors. Defaults to `nil`. |
| 152 | + public init( |
| 153 | + dateTranscoder: any DateTranscoder = .iso8601, |
| 154 | + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, |
| 155 | + xmlCoder: (any CustomCoder)? = nil, |
| 156 | + clientErrorHandler: (any ClientErrorHandler)? = nil, |
| 157 | + serverErrorHandler: (any ServerErrorHandler)? = nil |
| 158 | + ) { |
| 159 | + self.dateTranscoder = dateTranscoder |
| 160 | + self.multipartBoundaryGenerator = multipartBoundaryGenerator |
| 161 | + self.xmlCoder = xmlCoder |
| 162 | + self.clientErrorHandler = clientErrorHandler |
| 163 | + self.serverErrorHandler = serverErrorHandler |
| 164 | + } |
| 165 | +} |
| 166 | +``` |
| 167 | + |
| 168 | +#### UniversalClient Changes |
| 169 | + |
| 170 | +In `UniversalClient`, the configured handler is called after the error has been wrapped in `ClientError`: |
| 171 | + |
| 172 | +```swift |
| 173 | +// Inside UniversalClient (pseudocode) |
| 174 | +do { |
| 175 | + // API call logic |
| 176 | +} catch { |
| 177 | + let clientError = ClientError( |
| 178 | + operationID: operationID, |
| 179 | + request: request, |
| 180 | + response: response, |
| 181 | + underlyingError: error |
| 182 | + ) |
| 183 | + |
| 184 | + // Call handler if configured |
| 185 | + configuration.clientErrorHandler?.handleClientError(clientError) |
| 186 | + |
| 187 | + throw clientError |
| 188 | +} |
| 189 | +``` |
| 190 | + |
| 191 | +#### UniversalServer Changes |
| 192 | + |
| 193 | +Similarly, `UniversalServer` calls the handler after wrapping the error in `ServerError`. |
| 194 | + |
| 195 | +#### Error Handling Flow |
| 196 | + |
| 197 | +``` |
| 198 | +┌──────────────────────┐ |
| 199 | +│ API Call/Handle │ |
| 200 | +└──────────┬───────────┘ |
| 201 | + │ |
| 202 | + ▼ (error occurs) |
| 203 | +┌──────────────────────┐ |
| 204 | +│ Wrap in ClientError/ │ |
| 205 | +│ ServerError │ |
| 206 | +└──────────┬───────────┘ |
| 207 | + │ |
| 208 | + ▼ |
| 209 | +┌──────────────────────┐ |
| 210 | +│ errorHandler │──────┐ |
| 211 | +│ configured? │ │ No |
| 212 | +└──────────┬───────────┘ │ |
| 213 | + Yes │ │ |
| 214 | + ▼ │ |
| 215 | +┌──────────────────────┐ │ |
| 216 | +│ handleClientError/ │ │ |
| 217 | +│ handleServerError │ │ |
| 218 | +│ called (observe) │ │ |
| 219 | +└──────────┬───────────┘ │ |
| 220 | + │ │ |
| 221 | + ▼ ▼ |
| 222 | +┌─────────────────────────────────┐ |
| 223 | +│ Original ClientError/ServerError│ |
| 224 | +│ thrown │ |
| 225 | +└─────────────────────────────────┘ |
| 226 | +``` |
| 227 | + |
| 228 | +> **Important:** Handlers only **observe** errors; they do not transform or suppress them. The original error is always thrown. |
| 229 | +
|
| 230 | +#### Usage Examples |
| 231 | + |
| 232 | +**Basic Usage: Logging** |
| 233 | + |
| 234 | +```swift |
| 235 | +struct LoggingClientErrorHandler: ClientErrorHandler { |
| 236 | + func handleClientError(_ error: ClientError) { |
| 237 | + print("🚨 Client error in \(error.operationID): \(error.causeDescription)") |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +let config = Configuration( |
| 242 | + clientErrorHandler: LoggingClientErrorHandler() |
| 243 | +) |
| 244 | +``` |
| 245 | + |
| 246 | +**Telemetry Integration** |
| 247 | + |
| 248 | +```swift |
| 249 | +struct AnalyticsClientErrorHandler: ClientErrorHandler { |
| 250 | + let analytics: AnalyticsService |
| 251 | + |
| 252 | + func handleClientError(_ error: ClientError) { |
| 253 | + analytics.track("client_error", metadata: [ |
| 254 | + "operation": error.operationID, |
| 255 | + "status": error.response?.status.code as Any, |
| 256 | + "cause": error.causeDescription, |
| 257 | + "timestamp": Date().ISO8601Format() |
| 258 | + ]) |
| 259 | + } |
| 260 | +} |
| 261 | +``` |
| 262 | + |
| 263 | +**Conditional Logging (Operation ID Based)** |
| 264 | + |
| 265 | +```swift |
| 266 | +struct SelectiveLoggingHandler: ClientErrorHandler { |
| 267 | + let criticalOperations: Set<String> |
| 268 | + |
| 269 | + func handleClientError(_ error: ClientError) { |
| 270 | + if criticalOperations.contains(error.operationID) { |
| 271 | + // Send immediate alert for critical operations |
| 272 | + alertService.sendAlert( |
| 273 | + message: "Critical operation failed: \(error.operationID)", |
| 274 | + severity: .high |
| 275 | + ) |
| 276 | + } |
| 277 | + |
| 278 | + // Log all errors |
| 279 | + logger.error("[\(error.operationID)] \(error.causeDescription)") |
| 280 | + } |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +**Server-Side Error Handler** |
| 285 | + |
| 286 | +```swift |
| 287 | +struct ServerErrorLoggingHandler: ServerErrorHandler { |
| 288 | + func handleServerError(_ error: ServerError) { |
| 289 | + logger.error(""" |
| 290 | + Server error: |
| 291 | + - Operation: \(error.operationID) |
| 292 | + - Request: \(error.request) |
| 293 | + - Cause: \(error.underlyingError) |
| 294 | + """) |
| 295 | + } |
| 296 | +} |
| 297 | + |
| 298 | +let config = Configuration( |
| 299 | + serverErrorHandler: ServerErrorLoggingHandler() |
| 300 | +) |
| 301 | +``` |
| 302 | + |
| 303 | +### API stability |
| 304 | + |
| 305 | +This change maintains **full backward compatibility**: |
| 306 | + |
| 307 | +- The `clientErrorHandler` and `serverErrorHandler` parameters default to `nil`, so existing code works without modification. |
| 308 | +- Existing `Configuration` initialization code continues to work unchanged. |
| 309 | + |
| 310 | +```swift |
| 311 | +// Existing code - works without changes |
| 312 | +let config = Configuration() |
| 313 | +let config = Configuration(dateTranscoder: .iso8601) |
| 314 | + |
| 315 | +// Using new features |
| 316 | +let config = Configuration( |
| 317 | + clientErrorHandler: LoggingClientErrorHandler() |
| 318 | +) |
| 319 | +let config = Configuration( |
| 320 | + clientErrorHandler: LoggingClientErrorHandler(), |
| 321 | + serverErrorHandler: ServerErrorLoggingHandler() |
| 322 | +) |
| 323 | +``` |
| 324 | + |
| 325 | +### Test plan |
| 326 | + |
| 327 | +**Unit Tests** |
| 328 | + |
| 329 | +1. **Default Behavior Tests** |
| 330 | + - Verify errors are thrown normally when `clientErrorHandler` is `nil` |
| 331 | + - Verify errors are thrown normally when `serverErrorHandler` is `nil` |
| 332 | + |
| 333 | +2. **Handler Invocation Tests** |
| 334 | + - Verify `handleClientError` is called when `ClientError` occurs |
| 335 | + - Verify `handleServerError` is called when `ServerError` occurs |
| 336 | + - Verify original error is thrown after handler invocation |
| 337 | + |
| 338 | +3. **Sendable Conformance Tests** |
| 339 | + - Verify handler protocols properly conform to `Sendable` |
| 340 | + |
| 341 | +4. **Error Context Tests** |
| 342 | + - Verify `ClientError`/`ServerError` passed to handlers contains correct context |
| 343 | + |
| 344 | +**Integration Tests** |
| 345 | + |
| 346 | +1. **Real API Call Scenarios** |
| 347 | + - Verify handlers are called for various error situations including network errors and decoding errors |
| 348 | + |
| 349 | +2. **Performance Tests** |
| 350 | + - Verify error handler addition has minimal performance impact |
| 351 | + |
| 352 | +### Future directions |
| 353 | + |
| 354 | +- **Async handler methods**: The current design uses synchronous handler methods. A future enhancement could introduce async variants for handlers that need to perform asynchronous operations like remote logging. |
| 355 | + |
| 356 | +- **Error transformation**: While this proposal focuses on error observation, a future proposal could introduce error transformation capabilities, allowing handlers to modify or replace errors before they are thrown. |
| 357 | + |
| 358 | +- **Built-in handler implementations**: The runtime could provide common handler implementations out of the box, such as a `LoggingErrorHandler` that integrates with swift-log. |
| 359 | + |
| 360 | +### Alternatives considered |
| 361 | + |
| 362 | +**Using middleware for error handling** |
| 363 | + |
| 364 | +One alternative considered was extending the existing `ClientMiddleware` and `ServerMiddleware` protocols to handle errors. However, middleware operates at the HTTP request/response level and cannot intercept errors that occur during response decoding or other runtime operations. The error handler approach provides a more comprehensive solution for error observation. |
| 365 | + |
| 366 | +**Closure-based handlers instead of protocols** |
| 367 | + |
| 368 | +Instead of defining `ClientErrorHandler` and `ServerErrorHandler` protocols, we could use closure properties directly: |
| 369 | + |
| 370 | +```swift |
| 371 | +public var onClientError: ((ClientError) -> Void)? |
| 372 | +``` |
| 373 | + |
| 374 | +While this approach is simpler, the protocol-based design was chosen because: |
| 375 | +- It allows for more complex handler implementations with internal state |
| 376 | +- It provides better documentation through protocol requirements |
| 377 | +- It follows the existing patterns in swift-openapi-runtime (e.g., `DateTranscoder`, `MultipartBoundaryGenerator`) |
0 commit comments