Skip to content

Conversation

@winnisx7
Copy link

@winnisx7 winnisx7 commented Nov 7, 2025

Adds new optional parameter to Configuration initializers

Motivation

Currently, swift-openapi-runtime provides limited options for centralized error handling, which creates several challenges for developers:

  1. No centralized error logging: Errors occur at various points (serialization, transport, middleware, deserialization) without a unified way to log them with operation context
  2. Cannot transform framework errors: No mechanism to convert ClientError into application-specific error types
  3. Difficult debugging: Without operation context attached to errors, debugging API issues requires extensive instrumentation

This change addresses these pain points by providing a single interception point for all errors with full context.

Modifications

Added errorHandler property to Configuration struct for custom error transformation

Result

When an error occurs, if an errorHandler is configured, it will transform the error before throwing
This allows for centralized error mapping, logging, or custom error type conversion

Flexibility: Enables custom error transformation without modifying core runtime code
Centralized Error Handling: Allows global error mapping in a single configuration point
Backward Compatible: The errorHandler is optional, maintaining compatibility with existing code

Test Plan

  • Verified existing tests pass without modification (backward compatibility)
  • Manually tested with sample OpenAPI project:
  • Error handler correctly receives all error types with proper ClientError context
  • Operation ID is properly passed to handler
  • Error transformation works as expected
  • Nil handler behaves identically to current version (no behavior change)

- Adds new optional parameter to Configuration initializers
@winnisx7
Copy link
Author

winnisx7 commented Nov 7, 2025

Hi @czechboy0,

I've noticed error handling has been a recurring topic in discussions. This PR offers a simple, backward-compatible solution for error transformation and logging that many developers need.

Key benefits:

  • Transform ClientError to app-specific errors
  • Add operation context for debugging

The implementation is minimal and non-breaking. Would love to hear your thoughts on this approach.

Thanks for your time and for maintaining swift-openapi-runtime! 🙏

@czechboy0
Copy link
Contributor

Hi @winnisx7,

thanks for the PR. You're right that this is an interesting topic that continues to evolve.

Let me briefly react to the motivation (thanks for including that!) now and we can discuss more later.

No centralized error logging: Errors occur at various points (serialization, transport, middleware, deserialization) without a unified way to log them with operation context

That's right, at the moment the only place to catch all errors are outside of a call to the client. I think it's desirable to figure out if we can offer such a closure for observability purposes, one that doesn't change the error, just allows logging it in one place. Similar to your proposed design, just with the signature (ClientError) -> Void, for example.

Cannot transform framework errors: No mechanism to convert ClientError into application-specific error types

This is partly a design choice - since the 1.0, all errors thrown by the client are ClientError, a public type that includes not just the root error, but also information about the request that was being made. That helps debug why a certain request failed. However this also means changing that contract could be potentially considered breaking, plus it'd prevent us from potentially adopting typed throws (apple/swift-openapi-generator#844). Nothing is decided, we just need to consider this change in context of what we might want to do with the API.

Difficult debugging: Without operation context attached to errors, debugging API issues requires extensive instrumentation

Did you check ClientError? That has a lot of context and if you just log the error, it'll contain plenty of detail.

@winnisx7
Copy link
Author

winnisx7 commented Nov 8, 2025

Hi @czechboy0 ,
I need your help understanding the full scope of error handling in swift-openapi-runtime.
Initially, I thought I only needed to handle ClientError and that modifying wrappingErrors would be sufficient to centralize all error handling. However, through debugging yesterday, I discovered that there are many flows where the generator throws internal RuntimeError types directly without going through the runtime’s error handling pipeline.
For example, when accessing properties like operation.ok.body.json, if the server returns a 5xx error, a RuntimeError is thrown directly from the generated code, bypassing the UniversalClient’s error handling entirely.
I believe we need a comprehensive API for centralizing all errors for debugging purposes. Since this is fundamentally a networking library, when we call operation.ok.body.json and the network request itself succeeds (even with a non-2xx status), users should at minimum be able to access the status code and response body from the error.
Currently, when a 503 error occurs, users get an opaque RuntimeError without access to the actual HTTP response details, which makes proper error handling and debugging very difficult.
Could you provide guidance on:
1. The intended design for handling errors that occur outside of UniversalClient.send()
2. Whether making RuntimeError public or converting it to ClientError at all throw points would be acceptable
3. The best approach to ensure networking information (status code, headers, body) is preserved in all error cases
Thank you for your help!

@czechboy0
Copy link
Contributor

Great questions!

  1. The intended design for handling errors that occur outside of UniversalClient.send()

Calls to methods on Client do (or should, and if any don't, that's a bug) always throw a ClientError, which contains detailed information about the HTTP request/response.

An operation call on Client returns a Operations.Foo.Output value, which is just a plain old data type, which doesn't reference the client it came from. The design is intentional - data types in (Input), data types out (Output). We don't want to retain a reference to anything stateful in those types, like the Client.

The errors you encounter when using the convenience methods on Output can't throw ClientError, because they're not thrown by the Client. They're also not meant to be caught and inspected, they're only meant to be logged, more details in https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.3/documentation/swift-openapi-generator/soar-0007

If you need to handle more than one status type, you should use a switch statement on the Output type, not the shorthand unwrap methods.

  1. Whether making RuntimeError public or converting it to ClientError at all throw points would be acceptable

I don't think so, the proposal linked above goes into more details behind why it's deliberately designed this way.

  1. The best approach to ensure networking information (status code, headers, body) is preserved in all error cases

Ultimately that needs to be handled by your application, just like errors thrown by other libraries, from Foundation to higher level libraries.

One way that's worked for me in the past is: implement a middleware and record whatever information you need, and use Swift Log with metadata to include a request id. That way you'll be able to record all request and response information for all HTTP traffic, and when one of your requests results in a thrown error, you'll be able to find the request details in the logs based on the request id.

@winnisx7
Copy link
Author

winnisx7 commented Nov 8, 2025

Re: Error Handler Support - Evolved Proposal

Hi @czechboy0 ,
After further reflection and discussion, I've realized my initial approach was too narrow. The real need isn't just about error handling - it's about observability across the entire API call lifecycle.
The Problem Space Has Evolved
Initially, I focused on centralizing error handling. But the actual pain point is deeper: we need visibility into every stage of an API operation, not just when things fail.

Currently:

  • Middleware only sees the transport layer (HTTP request/response)
  • Client code only sees the final result or error
  • No visibility into encoding/decoding stages or operation context
public protocol OperationObserver {
    func willEncodeParameters(operationId: String, parameters: Any)
    func didEncodeParameters(operationId: String, result: Result<HTTPRequest, Error>)
    
    func willSendRequest(operationId: String, request: HTTPRequest)
    func didReceiveResponse(operationId: String, response: HTTPResponse, duration: TimeInterval)
    
    func willDecodeResponse(operationId: String, response: HTTPResponse)
    func didDecodeResponse(operationId: String, result: Result<Any, Error>)
    
    func didCompleteOperation(operationId: String, result: Result<Any, Error>, metrics: OperationMetrics)
}

let configuration = Configuration(
    observer: MyTelemetryObserver()
)

Why This Matters
Production Requirements:

  • Performance monitoring: Track encoding/decoding time separately from network time
  • Debugging: Know exactly where failures occur (encoding? network? deserialization?)
  • Compliance: Log full request/response for audit (before/after encoding)
  • Analytics: Track operation-level metrics, not just HTTP metrics

Real Example:

// Current: Can't distinguish between these failures
"API call failed" // Was it network? Deserialization? Invalid parameters?

// With Observer:
"[getUserProfile] Parameter encoding: 2ms ✓"
"[getUserProfile] Network request: 234ms ✓"
"[getUserProfile] Response deserialization: FAILED - unexpected enum value"

I believe this addresses a real gap in production observability while maintaining the clean separation of concerns in the current architecture.
What are your thoughts on this evolved approach?
Best regards

@czechboy0
Copy link
Contributor

I think improving observability is always a great goal. We'll just want to be clear about what exact use case each additional feature serves, and make sure it composes well with existing features.

For example, to track timings, distributed tracing can work really well - you'd put one span around your call to the client, and add a middleware at the last position in the stack that adds a span around the transport call. That way, you'll see in a tracing visualization tool how much time was spent outside the HTTP transport layer.

For next steps, it might be good for you to file issues for the specific problems you'd like to solve, and optionally, if you have ideas, some proposed solutions for each. We can then discuss each topic in each issue, and once we narrow on a solution, we'll run it through our lightweight proposal process.

Thanks again, I agree this area can use improvement 🙏

@winnisx7
Copy link
Author

winnisx7 commented Nov 9, 2025

Hi @czechboy0,

Thank you for the feedback. I want to clarify my immediate need and get your guidance on the best path forward.

The Critical Problem

My most urgent issue is tracking decoding failures in production. When response deserialization fails, I need to immediately capture this in our crash reporting system (like Crashlytics) for rapid response. This isn't something each client caller should handle individually - it needs centralized monitoring.

Two Possible Approaches

I'm considering two solutions:

Approach 1: Error Handler (Simple, Immediate)

// In Configuration
let errorHandler: ((ClientError) -> Void)?

// Wraps UniversalClient.send() to catch all ClientErrors

Pros:

  • Solves my immediate need
  • Minimal API surface
  • I'm confident I can implement this cleanly

Cons:

  • Only handles errors, no timing/metrics
  • Might complicate future observability features

Approach 2: Operation Observer (Comprehensive, Better)

// Full lifecycle visibility
protocol OperationObserver {
    func didEncodeParameters(operationId: String, duration: TimeInterval)
    func didReceiveResponse(operationId: String, response: HTTPResponse)
    func didFailDecoding(operationId: String, error: Error)
    // ... other hooks
}

Pros:

  • Solves errors + enables metrics/tracing
  • Better long-term solution

Cons:

  • Larger API, more design decisions
  • Honestly, I'm not confident I can design this well without proper guidance on how it should integrate with the existing middleware architecture

What I'm Planning

I'll likely implement Approach 1 to solve my immediate production need, since I can do it confidently and it's minimal.

However, if you have design guidance or ideas on how a proper observability layer should work in swift-openapi-runtime (e.g., how it should compose with middleware, what hooks make sense, etc.), I'd be happy to attempt Approach 2 instead. Your architectural insights would make a huge difference here.

What are your thoughts? Should I proceed with the simple error handler, or is there a clearer path to proper observability that I'm missing?

@czechboy0
Copy link
Contributor

If (1) would address your needs, I agree it's likely the right one to go for. We attempted and ultimately rejected a "mapper" approach earlier this year (https://github.com/apple/swift-openapi-runtime/pull/141/files), but I think you could take inspiration from that PR on how the closures were added on the Configuration struct. The implementation in your case will be simpler, because you just need an informative callback and not a mapper.

Once you have the implementation working and unit tests passing, please write up a lightweight proposal so that we can get the rest of the community to chime in as well - doesn't need to be long, short and clear is usually preferred: https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.3/documentation/swift-openapi-generator/proposals

@czechboy0
Copy link
Contributor

czechboy0 commented Nov 9, 2025

In unit tests, it'll be important to show that the callback sees errors from all these locations:

  1. transport
  2. middleware
  3. encoding of request
  4. decoding of response

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants