-
Notifications
You must be signed in to change notification settings - Fork 122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Automatically throw error if HTTP response represents a failure #508
Comments
Hi @fumoboy007, thanks for looking into Swift OpenAPI Generator and providing such a thoughtful proposal. Let me build on some of the points below. But first, I think it's important to remind ourselves the scope and goals of this project, which can be found here: https://swiftpackageindex.com/apple/swift-openapi-generator/1.2.0/documentation/swift-openapi-generator/project-scope-and-goals The TL;DR is that this package's one and only goal is to help you call (client) or implement (server) HTTP APIs that are described by an OpenAPI document. This isn't a general purpose HTTP library, like URLSession, AsyncHTTPClient, and others are. We made a decision to only focus on solving for the OpenAPI-generated code, but not apply any assumptions on top. The most important assumption that we chose to not bake into this tool is interpreting what "success" and "failure" codes are. You provide a great example - when deleting a resource, 404 is considered "success", but if you're fetching a resource, 404 would be considered "failure". APIs come in all shapes and sizes, and we want the adopter to be in control of what success and failure means to them. That's why the response type is a rich enum, where the response headers and body are the associated type of each case. That means that you have to unwrap the enum (either with a switch statement, or using the convenience throwing getters) to get to the value you're interested in.
Yes, that's right - however that's the case in all the libraries that we discuss here. OpenAPI: let response = try await client.getGreeting()
try response.ok.body.json // <-- I might forget this URLSession: let (_, response) = try await URLSession.shared.data(for: URLRequest(url: URL(string: "https://example.com/info")!))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ... } // <-- I might forget this Using your library: let response = try await someLibrary.makeRequest(...)
try response.throwIfFailed() // <-- I might forget this It seems that they all work the same way - you make a request in one step, you interpret the response in another step. If you forget the second step, all the solutions leave you in the same place, where you might be ignoring an unexpected HTTP response code. Now, that said, as you pointed out, using
Yes the first option, the nested do/catch blocks, are very inconvenient, and the throwing getters are not meant to be used when you need to interpret more than one status code. In those cases, use the switch statement, which you have in the second example - can you elaborate on why that version is inconvenient? That's how we designed it, deliberately, so that adopters have to handle every single documented case. That's considered a feature, not a bug 🙂
That's intentional, as it allows us to evolve the error type without breaking the API. We also didn't expect adopters to programmatically inspect these errors, they're only meant to be logged - and the string representation of these errors should contain enough information to know what the issue is. If not, please file a separate issue and we'll improve how these errors log, but I don't think they should be made public.
Right, because they might be thrown before a response body even arrived. We assumed that if you wanted to log every response body, you'd add a logging middleware. Can you describe the use case of where you'd need to get the response body from the error for something other than logging it (i.e. something that can be done today in the middleware)?
I actually find that most middlewares that I've seen don't even look at the status code, or at most log it. Now, the middlewares that do inspect the status code could take the desired list of success/failure codes as input, and when you're instantiating them, you provide the same input to both, keeping them in sync. But I'd also like to learn more about you use case where there's more than one middleware inspecting the status code, as I generally assumed that only one middleware, such as your proposed From your proposal, I quite like Alternative 1 - that's the flow we recommend adopters use, and so far I haven't seen realistic use cases that couldn't be handled by it. Again, this project is not meant to be a full-featured, generic HTTP library, it's really just a layer on top of an HTTP library that provides access to your OpenAPI-defined API. So if anything doesn't come from OpenAPI, it's considered out-of-scope by default, unless there's a compelling case made that many users of this library will need to solve the problem anyway - in those cases, we have opted to provide some conveniences. Let's also keep in mind that this is a problem only if the adopter isn't interpreting the response's headers or body, which is also not nearly as common as actually looking at the response to extract header values or bodies. And for those cases, I think the middleware would be a good fallback, in case someone forgets to interpret the response. To summarize, I think your library can work well when used with general-purpose HTTP clients, such as URLSession, AsyncHTTPClient, where you work with raw HTTP requests and responses, so your extensions on those types can be very useful. However, in clients generated by this project, you don't work with raw HTTP requests and responses, you work with types generated just for you from your OpenAPI document. Raw HTTP requests and responses are only used in middleware and transport implementations, however once the ecosystem of popular transports and middlewares gets large enough, I don't expect most people to need to write new middlewares and transports, as most common use cases will already have a solution they can just pull in (logging, metrics, tracing, retrying, authentication, even success/failure interpretation). Meaning that most adopters won't ever encounter raw HTTP request and responses through this library. Hope this addresses at least some of the concerns you brought up, but in a way, the generated code from OpenAPI is an alternative way to interpret HTTP responses to what your library provides, which is why I'm not yet convinced that combining the two provides enough value to justify the cost. Also tagging @simonjbeaumont who might have more thoughts as well. |
First off, let me echo this. Thanks for such a considered and thorough writeup. And, more specifically, I'm very interested in whether we can make things more ergonomic and safer by default.
I think this is a good observation but IIUC the point is that we should add this to generated code by default precisely so adopters don't forget. Do I understand correctly that the proposal would have a predefined set of acceptable status codes indicating success? If so, for a given operation defined in the OpenAPI document, how would we determine the appropriate set, given the discussion about the pervasive non-standard use of HTTP response codes in APIs.
I quite like this idea too, however it still relies on the end user to make a decision on what's an error and what isn't. And, given this might be different for different operations then the convenience of a middleware starts to diminish, and if you start trying to use different middlewares for different operations, you're not gaining much over using the switch-based APIs. OpenAPI is very expressive and people can (and do) use all sorts of patterns in their OpenAPI document, which makes me skeptical that we can come up with a solution to this problem that generalises well, unlike in e.g. GRPC where success and error have stronger conventions. |
Indeed! I was hoping the problems I listed could be solved without changing the current design of the library; however, as I’ve detailed, I don’t think the alternatives are as good as the proposed solution.
I proposed a default set of success statuses (2XX) and transient failure statuses (408, 429, 500, 502, 503, 504) for convenience in the common case. However, the proposal also mentions easy ways to provide a custom set of statuses both at the client level and at the operation level.
Right! The OpenAPI generator is well-positioned to solve this problem because it is at a higher layer than a general-purpose HTTP library.
The middleware solution would make it less likely to happen but the proposed solution avoids the issue completely.
Ah yes, that is another disadvantage of the middleware solution: it would be inconvenient to customize the response interpretation per operation.
The second example is less convenient than the proposed solution because the developer needs to create their own error type.
An app may want to do more than just log a failure. Here’s an example from RFC 7807:
I haven’t put much thought into it but at the very least, a retry middleware and a logging middleware might look at the status code.
As I mentioned, the response interpretation middleware wouldn’t relieve other middleware from having to do their own response interpretation because they wouldn’t be able to rely on the response interpretation middleware being available. We could only avoid doing response interpretation in middleware if the client itself does the response interpretation.
Indeed! I am trying to make the case that we should provide some conveniences for this problem, which all users need to solve. |
Okay, while I'm not sure how it'd integrate today and about some of the concerns I brought up, I'm happy to keep this issue open and let others chime in over time. |
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 ofswift-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:HTTPResponse
value.Interpreting HTTP Responses
The package also extends
HTTPResponse
to add two methods:throwIfFailed(successStatuses:transientFailureStatuses:)
andthrowIfFailed(successStatuses:transientFailureStatuses:makeResponseBody:)
.The methods have default values for the
successStatuses
andtransientFailureStatuses
parameters so that the methods can be called conveniently:The
successStatuses
andtransientFailureStatuses
parameters allow for a customized interpretation if needed:The second
throwIfFailed
method accepts anasync
closure to attach a response body to the error:The response body can be accessed later like so:
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:
If the HTTP response status was not
.ok
, then theresponse.ok
property access would throwRuntimeError.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
orAsyncHTTPClient
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:Limited Error Handling Ability
The ability to handle such an error is limited by the following:
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
version2.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 ofHTTPApplicationErrorProtocol
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:
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 hypotheticalResponseInterpretationMiddleware
that does the response interpretation. Developers would addResponseInterpretationMiddleware
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
valueIn 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 theHTTPApplicationError
type, thethrowIfFailed
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!The text was updated successfully, but these errors were encountered: