Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,377 @@
# SOAR-0015: Error Handler Protocols for Client and Server

Introduce `ClientErrorHandler` and `ServerErrorHandler` protocols for centralized error observation on both client and server sides.

## Overview

- Proposal: SOAR-0015
- Author(s): [winnisx7](https://github.com/winnisx7)
- Status: **Proposed**
- Issue: [apple/swift-openapi-runtime#162](https://github.com/apple/swift-openapi-runtime/pull/162)
- Implementation:
- [apple/swift-openapi-runtime#162](https://github.com/apple/swift-openapi-runtime/pull/162)
- Affected components:
- runtime

### Introduction

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`.

### Motivation

Currently, swift-openapi-runtime provides limited options for centralized error handling. Developers face the following challenges:

**Problem 1: Scattered Error Handling**

Errors must be handled individually at each API call site, leading to code duplication and inconsistent error handling.

```swift
// Current approach: individual handling at each call site
do {
let response = try await client.getUser(...)
} catch {
// Repeated error handling logic
logger.error("API error: \(error)")
}

do {
let response = try await client.getPosts(...)
} catch {
// Same error handling logic repeated
logger.error("API error: \(error)")
}
```

**Problem 2: Middleware Limitations**

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**.

**Problem 3: Telemetry and Logging Complexity**

To collect telemetry or implement centralized logging for all errors, developers currently need to modify every API call site.

**Problem 4: Difficulty Utilizing Error Context**

`ClientError` and `ServerError` contain rich context information such as `operationID`, `request`, and `response`, but centralized analysis using this information is difficult.

### Proposed solution

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.

```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
])
}
}

let config = Configuration(
clientErrorHandler: LoggingClientErrorHandler()
)
let client = UniversalClient(configuration: config, transport: transport)
```

### Detailed design

#### New Protocol Definitions

```swift
/// A protocol for handling client-side errors.
///
/// Implement this protocol to observe and react to errors that occur during
/// client API calls. The handler is invoked after the error has been wrapped
/// in a ``ClientError``.
///
/// Use this to add logging, monitoring, or analytics for client-side errors.
public protocol ClientErrorHandler: Sendable {
/// Handles a client error.
///
/// This method is called after an error has been wrapped in a ``ClientError``
/// but before it is thrown to the caller.
///
/// - Parameter error: The client error that occurred, containing context such as
/// the operation ID, request, response, and underlying cause.
func handleClientError(_ error: ClientError)
}

/// A protocol for handling server-side errors.
///
/// Implement this protocol to observe and react to errors that occur during
/// server request handling. The handler is invoked after the error has been
/// wrapped in a ``ServerError``.
///
/// Use this to add logging, monitoring, or analytics for server-side errors.
public protocol ServerErrorHandler: Sendable {
/// Handles a server error.
///
/// This method is called after an error has been wrapped in a ``ServerError``
/// but before it is thrown to the caller.
///
/// - Parameter error: The server error that occurred, containing context such as
/// the operation ID, request, and underlying cause.
func handleServerError(_ error: ServerError)
}
```

#### Configuration Struct Changes

```swift
public struct Configuration: Sendable {
// ... existing properties ...

/// Custom XML coder for encoding and decoding xml bodies.
public var xmlCoder: (any CustomCoder)?

/// The handler for client-side errors.
///
/// This handler is invoked after a client error has been wrapped in a ``ClientError``.
/// Use this to add logging, monitoring, or analytics for client-side errors.
/// If `nil`, errors are thrown without additional handling.
public var clientErrorHandler: (any ClientErrorHandler)?

/// The handler for server-side errors.
///
/// This handler is invoked after a server error has been wrapped in a ``ServerError``.
/// Use this to add logging, monitoring, or analytics for server-side errors.
/// If `nil`, errors are thrown without additional handling.
public var serverErrorHandler: (any ServerErrorHandler)?

/// Creates a new configuration with the specified values.
///
/// - Parameters:
/// - dateTranscoder: The transcoder for date/time conversions.
/// - multipartBoundaryGenerator: The generator for multipart boundaries.
/// - xmlCoder: Custom XML coder for encoding and decoding xml bodies.
/// - clientErrorHandler: Optional handler for observing client-side errors. Defaults to `nil`.
/// - serverErrorHandler: Optional handler for observing server-side errors. Defaults to `nil`.
public init(
dateTranscoder: any DateTranscoder = .iso8601,
multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random,
xmlCoder: (any CustomCoder)? = nil,
clientErrorHandler: (any ClientErrorHandler)? = nil,
serverErrorHandler: (any ServerErrorHandler)? = nil
) {
self.dateTranscoder = dateTranscoder
self.multipartBoundaryGenerator = multipartBoundaryGenerator
self.xmlCoder = xmlCoder
self.clientErrorHandler = clientErrorHandler
self.serverErrorHandler = serverErrorHandler
}
}
```

#### UniversalClient Changes

In `UniversalClient`, the configured handler is called after the error has been wrapped in `ClientError`:

```swift
// Inside UniversalClient (pseudocode)
do {
// API call logic
} catch {
let clientError = ClientError(
operationID: operationID,
request: request,
response: response,
underlyingError: error
)

// Call handler if configured
configuration.clientErrorHandler?.handleClientError(clientError)

throw clientError
}
```

#### UniversalServer Changes

Similarly, `UniversalServer` calls the handler after wrapping the error in `ServerError`.

#### Error Handling Flow

```
┌──────────────────────┐
│ API Call/Handle │
└──────────┬───────────┘
▼ (error occurs)
┌──────────────────────┐
│ Wrap in ClientError/ │
│ ServerError │
└──────────┬───────────┘
┌──────────────────────┐
│ errorHandler │──────┐
│ configured? │ │ No
└──────────┬───────────┘ │
Yes │ │
▼ │
┌──────────────────────┐ │
│ handleClientError/ │ │
│ handleServerError │ │
│ called (observe) │ │
└──────────┬───────────┘ │
│ │
▼ ▼
┌─────────────────────────────────┐
│ Original ClientError/ServerError│
│ thrown │
└─────────────────────────────────┘
```

> **Important:** Handlers only **observe** errors; they do not transform or suppress them. The original error is always thrown.
#### Usage Examples

**Basic Usage: Logging**

```swift
struct LoggingClientErrorHandler: ClientErrorHandler {
func handleClientError(_ error: ClientError) {
print("🚨 Client error in \(error.operationID): \(error.causeDescription)")
}
}

let config = Configuration(
clientErrorHandler: LoggingClientErrorHandler()
)
```

**Telemetry Integration**

```swift
struct AnalyticsClientErrorHandler: ClientErrorHandler {
let analytics: AnalyticsService

func handleClientError(_ error: ClientError) {
analytics.track("client_error", metadata: [
"operation": error.operationID,
"status": error.response?.status.code as Any,
"cause": error.causeDescription,
"timestamp": Date().ISO8601Format()
])
}
}
```

**Conditional Logging (Operation ID Based)**

```swift
struct SelectiveLoggingHandler: ClientErrorHandler {
let criticalOperations: Set<String>

func handleClientError(_ error: ClientError) {
if criticalOperations.contains(error.operationID) {
// Send immediate alert for critical operations
alertService.sendAlert(
message: "Critical operation failed: \(error.operationID)",
severity: .high
)
}

// Log all errors
logger.error("[\(error.operationID)] \(error.causeDescription)")
}
}
```

**Server-Side Error Handler**

```swift
struct ServerErrorLoggingHandler: ServerErrorHandler {
func handleServerError(_ error: ServerError) {
logger.error("""
Server error:
- Operation: \(error.operationID)
- Request: \(error.request)
- Cause: \(error.underlyingError)
""")
}
}

let config = Configuration(
serverErrorHandler: ServerErrorLoggingHandler()
)
```

### API stability

This change maintains **full backward compatibility**:

- The `clientErrorHandler` and `serverErrorHandler` parameters default to `nil`, so existing code works without modification.
- Existing `Configuration` initialization code continues to work unchanged.

```swift
// Existing code - works without changes
let config = Configuration()
let config = Configuration(dateTranscoder: .iso8601)

// Using new features
let config = Configuration(
clientErrorHandler: LoggingClientErrorHandler()
)
let config = Configuration(
clientErrorHandler: LoggingClientErrorHandler(),
serverErrorHandler: ServerErrorLoggingHandler()
)
```

### Test plan

**Unit Tests**

1. **Default Behavior Tests**
- Verify errors are thrown normally when `clientErrorHandler` is `nil`
- Verify errors are thrown normally when `serverErrorHandler` is `nil`

2. **Handler Invocation Tests**
- Verify `handleClientError` is called when `ClientError` occurs
- Verify `handleServerError` is called when `ServerError` occurs
- Verify original error is thrown after handler invocation

3. **Sendable Conformance Tests**
- Verify handler protocols properly conform to `Sendable`

4. **Error Context Tests**
- Verify `ClientError`/`ServerError` passed to handlers contains correct context

**Integration Tests**

1. **Real API Call Scenarios**
- Verify handlers are called for various error situations including network errors and decoding errors

2. **Performance Tests**
- Verify error handler addition has minimal performance impact

### Future directions

- **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.

- **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.

- **Built-in handler implementations**: The runtime could provide common handler implementations out of the box, such as a `LoggingErrorHandler` that integrates with swift-log.

### Alternatives considered

**Using middleware for error handling**

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.

**Closure-based handlers instead of protocols**

Instead of defining `ClientErrorHandler` and `ServerErrorHandler` protocols, we could use closure properties directly:

```swift
public var onClientError: ((ClientError) -> Void)?
```

While this approach is simpler, the protocol-based design was chosen because:
- It allows for more complex handler implementations with internal state
- It provides better documentation through protocol requirements
- It follows the existing patterns in swift-openapi-runtime (e.g., `DateTranscoder`, `MultipartBoundaryGenerator`)