Skip to content
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

Allow handlers to return user-defined error types #1180

Open
wants to merge 40 commits into
base: main
Choose a base branch
from

Conversation

hawkw
Copy link
Member

@hawkw hawkw commented Nov 20, 2024

Currently, all endpoint handler functions must return a
Result<T, HttpError>, where T implements HttpResponse. This is
unfortunate, as it limits how error return values are represented in
the API. There isn't presently a mechanism for an endpoint to return
a structured error value of its own which is part of the OpenAPI spec
for that endpoint. This is discussed at length in issue #39.

This branch relaxes this requirement, and instead allows endpoint
handler functions to return Result<T, E>, where E is any type that
implements a new HttpResponseError trait.

The HttpResponseError trait defines how to produce an error response
for an error value. This is implemented by dropshot's HttpError
type, but it may also be implemented by user errors. Types implementing
this trait must implement HttpResponseContent, to determine how to
generate the response body and define its schema, and they must also
implement a method HttpResponseError::status_code to provide the
status code to use for the error response. This is somewhat different
from the existing HttpCodedResponse trait, which allows successful
responses to indicate at compile time that they will always have a
particular status code, such as 201 Created. Errors are a bit different:
we would like to be able to return any number of different error status
codes, but would still like to ensure that they represent errors, in
order to correctly generate an OpenAPI document where the error schemas
are returned only for error responses (see this comment for
details). As discussed here, we ensure this by providing new
ErrorStatusCode and ClientErrorStatusCode types, which are newtypes
around http::StatusCode that only contain a 4xx or 5xx status (in the
case of ErrorStatusCode), or only contain a 4xx (in the case of
ClientErrorStatusCode). These types may be fallibly converted from an
http::StatusCode at runtime, but we also provide constants for
well-known 4xx and 5xx statuses, which can be used infallibly. The
HttpResponseError::status_code method returns an ErrorStatusCode
rather than a http::StatusCode, allowing us to ensure that error types
always have error statuses and generate a correct OpenAPI document.

Additionally, while adding ErrorStatusCodes, I've gone ahead and
changed the dropshot::HttpError type to also use it, and changed the
HttpError::for_client_error and HttpError::for_status constructors
to take a ClientErrorStatusCode. Although this is a breaking change,
it resolves a long-standing issue with these APIs: currently, they
assert that the provided status code is a 4xx internally, which is often
surprising to the user. Thus, this PR fixes #693.

Fixes #39
Fixes #693
Fixes #801

This branch is a second attempt at the change originally proposed in PR
#1164, so this closes #1164. This design is substantially simpler, and
only addresses the ability for handler functions to return
user-defined types. Other changes made in #1164, such as a way to
specify a global handler for dropshot-generated errors, and adding
headers to HttpError responses, can be addressed separately. For now,
all extractors and internal errors still produce dropshot::HttpErrors.
A subsequent change will implement a mechanism for providing alternate
presentation for such errors (such as an HTML 404 page).

@hawkw hawkw marked this pull request as ready for review November 21, 2024 00:04
.envrc Outdated Show resolved Hide resolved
@hawkw
Copy link
Member Author

hawkw commented Nov 21, 2024

it occurs to me that HttpResponseHeaders might want to forward an implementation of HttpResponseError when the wrapped type implements that. I'll add one.

Copy link
Collaborator

@ahl ahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for doing this

dropshot/examples/custom-error.rs Outdated Show resolved Hide resolved
@@ -919,27 +925,59 @@ impl<Context: ServerContext> ApiDescription<Context> {
}
};

// If the endpoint defines an error type, emit that for
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could have added to components.responses as before and then referenced that. I can see the inline approach you've taken as potentially simpler, though it does bloat up the json output...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I'd like to put them in components.responses, too. The reason I didn't is that it might be a bit annoying to determine the name for each response schema. schemars internally disambiguates colliding schema names by turning subsequent ones into like Error2 or whatever, but (AFAICT) we only get that when we actually generate the schema and it gives us back a reference (into components.schemas). We could then try to parse that reference and get the name back out to then use it to generate a components.responses entry for that response, which seems possible, I just thought it seemed annoying enough that I didn't really want to bother with it. Do you think it's worth doing?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we name the response based on <T as JsonSchema>::schema_name()? Might that work?

Do I think it's worth doing? I think it's worth trying. It might make the code worse, but it might make the output simpler. At a minimum it will make the diffs against current json simpler. These together--I think--at least warrant giving it a shot.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe that deduplication is applied to JsonSchema::schema_name(); as
far as I can tell, it only happens once a schema has already been generated,
because that's when the generator can check if the name already exists in the
set of schemas that have been generated so far?

dropshot/src/error.rs Outdated Show resolved Hide resolved
dropshot/src/error.rs Outdated Show resolved Hide resolved
dropshot/src/error.rs Outdated Show resolved Hide resolved
dropshot/src/router.rs Outdated Show resolved Hide resolved
@@ -943,9 +946,9 @@ async fn http_request_handle<C: ServerContext>(
),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we be firing a USDT probe here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We weren't previously, but yeah, we probably should. I can add it in this PR if that makes sense?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably doesn't... (unless you've done it already). We can file an issue

dropshot/tests/fail/bad_endpoint10.stderr Outdated Show resolved Hide resolved
dropshot/tests/fail/bad_endpoint12.stderr Outdated Show resolved Hide resolved
dropshot_endpoint/src/endpoint.rs Show resolved Hide resolved
@hawkw hawkw requested a review from ahl November 21, 2024 22:59
dropshot/tests/fail/bad_endpoint10.stderr Outdated Show resolved Hide resolved
dropshot/tests/fail/bad_endpoint12.stderr Outdated Show resolved Hide resolved
dropshot/src/handler.rs Outdated Show resolved Hide resolved
dropshot/tests/fail/bad_trait_endpoint12.stderr Outdated Show resolved Hide resolved
See #1180 (comment)

This does mean we can no longer have infallible endpoint handlers, but I
never actually wanted that in the first place --- it's just a side
benefit.
Copy link
Collaborator

@ahl ahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this direction

dropshot/tests/fail/bad_endpoint10.stderr Show resolved Hide resolved
@hawkw hawkw requested a review from ahl November 25, 2024 20:32
Copy link
Collaborator

@ahl ahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice work

dropshot/src/handler.rs Outdated Show resolved Hide resolved
dropshot/src/handler.rs Outdated Show resolved Hide resolved
dropshot/tests/fail/bad_endpoint10.stderr Show resolved Hide resolved
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EnumError"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neat!

Copy link
Collaborator

@davepacheco davepacheco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice! Thanks again for both taking this on and sticking with it through all the design iteration. I don't have anything major here but a few things to fix up, plus: for all breaking changes I generally ask:

  • We should update CHANGELOG.adoc. it should have super easy-to-follow instructions there for updating across this change. There should be plenty of examples -- usually we try to say how to know you're affected and exactly what code changes to make, with an example or two.
  • I think it's worth doing a test-update of Omicron to a dropshot with this change. We're going to have to do it anyway and doing it first sometimes smokes out cases where the breaking change was worse than we thought to move past or stuff like that.

I did not review the bad-endpoint error output, the OpenAPI spec changes, or the dropshot_endpoint changes FWIW.

dropshot/src/error.rs Outdated Show resolved Hide resolved
dropshot/src/error.rs Outdated Show resolved Hide resolved
@@ -245,7 +266,7 @@ impl HttpError {
/// Found").
pub fn for_status(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's great that the type now reflects this constraint and I think I'd still rename it to something that conveys that this is only for client errors (e.g., for_client_error_with_status or something). That'll be another breaking change we should document but I think it's worth it -- we want to make sure all callers of this aren't actually going to panic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, good call --- while we're breaking this interface, we may as well make the name clearer, too!

@@ -316,6 +337,649 @@ impl Error for HttpError {
}
}

/// An HTTP 4xx (client error) or 5xx (server error) status code.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, feel free to ignore: I feel like this is all big enough and separable enough to put into a separate file / module.

/// [`ErrorStatusCode::INTERNAL_SERVER_ERROR`], and so on, including those in
/// the IANA HTTP Status Code Registry](
/// https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml).
/// Using these constants avoids the fallible conversion from an [`http::StatusCode`].
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a bunch of these comment lines are longer than 80 columns. Some are just long links but several others could be fixed. (IIRC you can have rustfmt fix them if you put unstable = true and format_comments = true or something like that into rustfmt.toml.)

/// we can use to invoke this endpoint from the client. This essentially
/// appends the path to a base URL constructed from the server's IP address
/// and port.
pub fn url(&self, path: &str) -> Uri {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are all these just copy/paste of the corresponding methods in ClientTestContext?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, except that the error types are changed. Unfortunately that felt like the simplest way to do this without changing the ClientTestContext interface in ways that I felt made it unpleasant for the user; we could do something like turning these into default methods on a trait which both ClientTestContext and TypedErrorClientTestContext would implement, or something, but then the user has to import that trait...

@@ -467,7 +467,7 @@ fn test_versions_openapi_same_names() {
);

assert_eq!(func_mods_v1, func_overrides_v1);
assert_eq!(func_mods_v1, traits_v1);
assert_eq!(func_mods_v1, traits_v1,);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fine but spurious.

@@ -0,0 +1,304 @@
// Copyright 2024 Oxide Computer Company

//! Test cases for user-defined error types.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about a smoke test that exercises a custom error type using the trait-based interface?

dropshot/src/handler.rs Show resolved Hide resolved
Comment on lines +369 to +370
/// If a handler function's return value is a [`Result`], the error type must
/// implement this trait, so that a response can be generated when the handler
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is always true now

hawkw and others added 2 commits November 27, 2024 16:17
Co-authored-by: David Pacheco <[email protected]>
Co-authored-by: David Pacheco <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants