Skip to content

Automatically throw error if HTTP response represents a failure #508

Open
@fumoboy007

Description

@fumoboy007

Motivation

Hi! I am the developer of the swift-http-error-handling package, which helps with interpreting HTTP responses and handling failures. I am looking to apply some of the concepts from that package to my usage of swift-openapi-generator, but I am running into some issues.

Overview of swift-http-error-handling

A core part of swift-http-error-handling is interpreting an HTTP response as a success or a failure.

Representing an HTTP Application Failure

The package introduces an HTTPApplicationError type to represent a failure. This allows the failure to be propagated and handled using Swift’s standard try-catch error handling mechanism. The error type contains the following information:

  • The HTTPResponse value.
  • A generically-typed value representing the response body, if desired. Some HTTP servers add additional details about a failure to the response body.
  • Whether the failure is transient. This is important for the package’s retry feature.

Interpreting HTTP Responses

The package also extends HTTPResponse to add two methods: throwIfFailed(successStatuses:transientFailureStatuses:) and throwIfFailed(successStatuses:transientFailureStatuses:makeResponseBody:).

The methods have default values for the successStatuses and transientFailureStatuses parameters so that the methods can be called conveniently:

try response.throwIfFailed()

The successStatuses and transientFailureStatuses parameters allow for a customized interpretation if needed:

try response.throwIfFailed(
  successStatuses: [.created],
  transientFailureStatuses: HTTPResponse.Status.transientFailures.union([.conflict])
)

The second throwIfFailed method accepts an async closure to attach a response body to the error:

try await response.throwIfFailed {
  return try await deserializeFailureDetails(from: responseBody)
}

The response body can be accessed later like so:

do {
   try await performRequest()
} catch let error as HTTPApplicationError<MyFailureDetails> {
   let failureDetails = error.responseBody
   doSomething(with: failureDetails)
}

Shortcomings of the OpenAPI-Generated Client

If I wanted to interpret an OpenAPI-generated response as a success or failure, I might do the following:

let response = try await client.getGreeting()
doSomething(with: try response.ok.body.json)

If the HTTP response status was not .ok, then the response.ok property access would throw RuntimeError.unexpectedResponseStatus. This seems like it works for our use case; however, there are a number of issues with this approach.

Unsafe

A developer needs to remember to check the response status. Not checking the response status introduces a subtle bug.

This is obvious for experienced developers who have used Swift HTTP libraries like Foundation or AsyncHTTPClient but may be non-intuitive for other developers. Even experienced developers may forget to check the response status in cases where they do not intend to access the response body.

Inconvenient

Consider the case where a response can have multiple successful statuses (e.g. .ok, .notFound, and .gone for a delete request). The developer would have to do one of two things, both of which are inconvenient:

let response = try await client.deleteSomething()
do {
   _ = try response.ok
} catch {
  do {
     _ = try response.notFound
  } catch {
    _ = try response.gone
  }
}
let response = try await client.deleteSomething()
switch response {
case .ok, .notFound, .gone:
  break

default:
  throw MyCustomError()  // need to define our own error type

Limited Error Handling Ability

The ability to handle such an error is limited by the following:

  • The error type is not public.
  • The error does not contain the response body.

Duplicate Logic in Middleware

Some middleware also needs to interpret the response as a success or failure (e.g. to retry, to log failures, etc.). Now, the response interpretation logic is duplicated across the caller and the middleware, which is both inconvenient and could cause inconsistency across the two implementations, causing bugs.

Proposed Solution

I propose a breaking change for swift-openapi-generator version 2.0.0 to address all of the current shortcomings.

I propose that the client automatically interprets responses and throws HTTPApplicationError when the response represents a failure.

Since the response deserialization code is inside of the client, the client could deserialize the response body, if any, and attach it to the error automatically. Error handling code would be able to catch the error using its concrete type HTTPApplicationError<Operations.myOperation.Output> or as an instance of HTTPApplicationErrorProtocol if the code does not care about the response body.

The generated client could expose properties to customize the response interpretation. The generated client methods could also have additional parameters to customize the response interpretation per operation.

Finally, it would be important for the client to interpret the response before passing the response to the middleware so that the middleware do not have to do their own response interpretation.

Putting it all together, it would look something like the following:

let client = Client(serverURL: URL(string: "http://localhost:8080/api")!,
                    transport: URLSessionTransport(),
                    // The middleware will handle HTTP application failures using the standard
                    // try-catch mechanism.
                    middlewares: [RetryMiddleware(), LoggingMiddleware()])

do {
   // Safer than before.
   _ = try await client.doSomething()

   // More convenient than before.
   _ = try await client.deleteSomething(successStatuses: [.ok, .notFound, .gone])
} catch let error as any HTTPApplicationErrorProtocol {
  // The error type is public, allowing for more sophisticated error handling.
  doSomething(with: error)
}

Alternatives Considered

Alternative 1: Add middleware that interprets the response

Instead of building the response interpretation into the client directly, swift-openapi-runtime or a third-party library could expose a hypothetical ResponseInterpretationMiddleware that does the response interpretation. Developers would add ResponseInterpretationMiddleware to the list of middleware when creating the client.

The first issue with this alternative is that middleware do not have access to the response deserialization code, so ResponseInterpretationMiddleware would not be able to attach the deserialized response body to the error.

Second, since the ResponseInterpretationMiddleware is optional, other middleware would not be able to rely on its availability and would need to do their own response interpretation, duplicating logic.

Finally, this alternative is less safe compared to the proposed solution since the developer would need to remember to add the ResponseInterpretationMiddleware to the list of middleware.

Alternative 2: Add a property to the generated response that returns the HTTPResponse value

In this alternative, the generated response would have a property to access the HTTPResponse value. The caller could then interpret the response themselves.

This alternative does not solve the safety issue since the developer still needs to remember to interpret the response.

This alternative also does not solve the middleware code duplication issue since the middleware would still need to do their own response interpretation.

Additional Information

If the proposed solution is accepted, feel free to add a dependency to my swift-http-error-handling package to help with implementation. You would gain access to the HTTPApplicationError type, the throwIfFailed methods, the default set of success statuses, and the default set of transient failure statuses.

I promise I won’t do anything sketchy with my package. 😆 But if it makes you feel better, I am also considering creating a proposal to merge my package into the swift-http-types package. Let me know if that would be a prerequisite. If it is, I’ll get right on it!

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions