-
Notifications
You must be signed in to change notification settings - Fork 18.8k
api: Endpoints can now return a tree of errors #46099
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
base: master
Are you sure you want to change the base?
Conversation
api/types/error_response.go
Outdated
// The list of unformatted error messages. | ||
RawMessages []string `json:"raw_messages"` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we anticipate we'd need more structured information at some point? Thinking of the structure that the OCI distribution spec chose; https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
Maybe we don't need it, but "now" is the moment to choose :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Discussing in the sync; and we can use a struct (currently with just one field); could be message
or detail
(see OCI spec)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
message
probably is most appropriate for our use 👍
7521b4d
to
8aa1a7f
Compare
api/server/errorhandler.go
Outdated
stringErrs = append(stringErrs, err.Error()) | ||
} | ||
} else if unwrapHTTP && errdefs.IsHTTPError(err) { | ||
return unwrapErrors(errors.Unwrap(err), false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think unwrapping only once is enough but I might be wrong. /cc @thaJeztah
Also, this makes me wonder whether the fallback format should be handled by our own implementation of joinError
. Otherwise, if we start using an Error tree, only the root will be well-formatted and other levels will use the line break format, as can be seen here: https://go.dev/play/p/MCUaNfjd5Xx.
f2528da
to
c825ae0
Compare
a7a26c8
to
d75b091
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd love to have a structured error format which provides sufficient detail to be able to marshal and unmarshal a tree of rich errors in full fidelity. But I don't think we need to design that now as I can see lots of ways to extend the schema in a backwards-compatible way.
For example:
Taking inspiration from the error
interface and github.com/containerd/typeurl, we could encode trees of rich errors by nesting StructuredErrors
recursively, and tagging each error with type information. Just like error.Error()
, the message at each level is the complete error message, which may or may not be the concatenation of the nested errors' messages.
{
"message": "2 errors occurred:\n\t* something bad happened: wrapped error: another\n\t* could not pull image 'library/hello-world' from registry: 503 Service Unavailable",
"errors": [
{
"message": "something bad happened: wrapped error: another",
"_typeurl": "mobyproject.org/errors/foo",
"foo": "bar",
"baz": {"quux": 3.14, "xyzzy": 42},
"errors": [
{
"message": "wrapped error: another",
"_typeurl": "mobyproject.org/errors/you-dun-goofed",
"errors": [
{
"message": "another",
}
],
},
]
},
{
"message": "could not pull image 'library/hello-world:latest' from registry: 503 Service Unavailable",
"_typeurl": "mobyproject.org/errors/registry/PullError",
"reference": "library/hello-world:latest",
"registry": "registry-1.docker.io",
"errors": [
{
"message": "503 Service Unavailable",
"_typeurl": "mobyproject.org/registry/HTTPError",
"status": 503,
"host": "registry-1.docker.io"
}
]
}
]
}
api/server/errorhandler.go
Outdated
case version == "" || versions.GreaterThanOrEqualTo(version, firstAPIVersionWithJSONMultiErrors): | ||
response := &types.ErrorResponse{Message: errMsg, Errors: structuredErrs} | ||
_ = httputils.WriteJSON(w, statusCode, response) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it necessary to gate multi-errors on API version? We're adding a new property to the error response JSON document, not changing the semantics of any existing ones.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if that's needed, I can drop it we consider it's not needed.
747351d
to
2640ee1
Compare
2640ee1
to
80080f4
Compare
case wrappingError: | ||
return &types.ErrorResponse{ | ||
Message: err.Error(), | ||
Errors: []*types.ErrorResponse{marshalErrorResponse(err.Unwrap())}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering if we should unconditionally propagate the Errors
array; my thinking here is that the top-level Message
can be considered a "pre-rendered" presentation of the error (or errors if there's more than one), which can be used by clients to present as-is, and the Errors
slice contains the raw, underlying error(s).
Clients that support the new Errors
field would look in the Errors
slice, and use it (to present to their liking), and otherwise fall back to using the top-level Message
"as-is".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's what this code is doing, except for the limiting case where there are no underlying errors. Note the recursion with Errors
: it's the same type all the way down. Any property which could go on an object inside Errors
could also go on the root ErrorResponse
for a knowledgeable client to use to their liking. Take a look at the details block in my earlier non-inline review comment for a worked example of what that hypothetically might look like.
|
||
// marshalErrorResponse returns a tree of [*types.ErrorResponse] representing the argument err. | ||
func marshalErrorResponse(err error) *types.ErrorResponse { | ||
if errdefs.IsHTTPOnlyError(err) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still need this special case now that we marshal recursively? When one HTTPOnlyError wraps another HTTPOnlyError, why are they marshaled differently? The inner one is marshaled as a wrappingError while the outer is discarded.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When one HTTPOnlyError wraps another HTTPOnlyError, why are they marshaled differently? The inner one is marshaled as a wrappingError while the outer is discarded.
Hum, nope they would be both discarded and only the inner non-HTTPOnlyError would be marshaled.
The initial intent was to discard "HTTPOnlyError" as they don't add any context. If I remove this code, here's what it produces:
// Currently
err := errdefs.InvalidParameter(errors.New("foobar"))
expected := &types.ErrorResponse{Message: "foobar"}
err := errdefs.InvalidParameter(errdefs.InvalidParameter(errors.New("foobar")))
expected := &types.ErrorResponse{Message: "foobar"}
// With the IsHTTPOnlyError check removed
err := errdefs.InvalidParameter(errors.New("foobar"))
expected := &types.ErrorResponse{Message: "foobar", Errors: []*types.ErrorResponse{{Message: "foobar"}}}
err := errdefs.InvalidParameter(errdefs.InvalidParameter(errors.New("foobar")))
expected := &types.ErrorResponse{Message: "foobar", Errors: []*types.ErrorResponse{{Message: "foobar"}}}
Do we want to keep this child ErrorResponse
although it doesn't add value?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now try marshaling errdefs.InvalidParameter(errdefs.Conflict(errors.New("foobar")))
.
Lines 38 to 41 in 63d477b
func InvalidParameter(err error) error { | |
if err == nil || IsInvalidParameter(err) { | |
return err | |
} |
Do we want to keep this child
ErrorResponse
although it doesn't add value?
I wouldn't want to keep HTTPOnly errors in the marshaled error tree. I just want them to be marshaled (or discarded) consistently, independent of their position in the error tree.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, indeed. I see your point. So I should probably recursively unwrap HTTP-only errors here.
if errdefs.IsHTTPOnlyError(err) {
if wrappedErr := errors.Unwrap(err); wrappedErr != nil {
return marshalErrorResponse(wrappedErr)
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit confused by this part; so currently, this PR adds no additional information about the unwrapped errors (other than splitting the nested error messages). but wouldn't the type of error produced be actually useful once we add more details?
I don't think we should consider these to be "HTTP-only" error messages, because they aren't. Mapping them to a HTTP status code is one purpose, but that's a translation of the "kind" of error returned (e.g. an object was not found).
If we are considering this response to be used for handling specific conditions, wouldn't that information be exactly what's useful? (e.g., we returned an invalid request error, because an object was not found).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To some extend I'm wondering; we added these nested errors because Go's original formatting (omitting the "2 errors") was not ideal, so we added the option to unwrap those errors, so that they could be formatted in different ways, which a fallback for clients that don't support that (in the form of the existing pre-formatted Message
.
- But currently, there's no such implementation on the client side (should this PR include that part?).
- If we anticipate this structured format to be used for more in-depth error analysing, I'm wondering if a stack-trace for errors would be more useful 🤔 I know I gave that a very quick attempt in api: add DOCKER_DEBUG_TRACE to return stack-traces in API error responses #43252
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The client implementation can be added in a follow-up PR. There's no need to increase the scope of this one. The API change is backwards-compatible, after all.
Stack traces are not either/or. A stack trace is a property of an individual error. This PR adds support for marshaling a list/tree of errors. They are orthogonal concerns.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as the API is concerned, mapping to an HTTP status code is their only purpose. The client can losslessly recover that information from the HTTP status code the error is mapped to, combined with the Message
of the unwrapped error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The client implementation can be added in a follow-up PR. There's no need to increase the scope of this one. The API change is backwards-compatible, after all.
So what's currently the use for these structured errors if there's nothing consuming them? I know this work started because the default formatting of Golang's native "multi-errors" was not great (missing the 2 errors occurred
part. And that's addressed in this PR by using our own formatting (through the formatErrors()
function)
Do we need the other parts already, or should we split the formatErrors()
part to a separate PR, and draft a more in-depth plan what we will need the structured API response for?
80080f4
to
deb5d7a
Compare
There're cases like input validation where it makes sense to gather multiple errors and return them all to the client. Prior to this commit, the API would only return a string `message` field. As a consequence, it requires those errors to be coalesced into a single string using a specific format, whether it's just a newline separator or some more complicated format. This commit adds a new `errors` field containing a list of ErrorResponse. Signed-off-by: Albin Kerouanton <albinker@gmail.com>
deb5d7a
to
c658c82
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
// IsHTTPOnlyError checks whether err is one of the un-exported HTTP error type provided by this package. These errors | ||
// only define what HTTP status code should be returned, but don't add additional context to the actual error they | ||
// wrap. By contrast, error types implementing the interfaces defined in this package are also adding such additional | ||
// context. | ||
func IsHTTPOnlyError(err error) bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also wondering what would make these errors different from, say,
moby/builder/builder-next/builder.go
Lines 42 to 46 in 2b449e0
type errMultipleFilterValues struct{} | |
func (errMultipleFilterValues) Error() string { return "filters expect only one value" } | |
func (errMultipleFilterValues) InvalidParameter() {} |
Which wouldn't be caught by this function. The non-exported errors in this package are mostly for convenience (and "one" implementation of errdefs types), but there's many others, and the information they add is to put an error in a specific category (see my other comment above).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These errors all have the property that errors.Unwrap(err).Error() == err.Error()
, implement one of the errdefs
interfaces (which map 1:1 to HTTP status codes, except for HTTP 500), and nothing else. errMultipleFilterValues
is not caught by this function by design, because it carries additional context. The information carried by the non-exported convenience types is already encoded in the response, in the HTTP status code. Duplicating that in the error tree is therefore just redundant noise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The information carried by the non-exported convenience types is already encoded in the response, in the HTTP status code. Duplicating that in the error tree is therefore just redundant noise.
But that only applies to the final error, and not intermediate ones, and currently we're discarding that information; see for example this test-case;
"error wrapped multiple times with a HTTP-only error": {
err: errdefs.InvalidParameter(errdefs.Conflict(errors.New("foobar"))),
expected: &types.ErrorResponse{Message: "foobar"},
},
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
As we have a hard time figuring out what moby#46099 should look like, this drop-in replacement will solve the initial formatting problem we had. It's made internal such that we can remove it whenever we want and unlike moby#46099 doesn't require thoughtful API changes. Signed-off-by: Albin Kerouanton <albinker@gmail.com>
As we have a hard time figuring out what moby#46099 should look like, this drop-in replacement will solve the initial formatting problem we have. It's made internal such that we can remove it whenever we want and unlike moby#46099 doesn't require thoughtful API changes. Signed-off-by: Albin Kerouanton <albinker@gmail.com>
So, the initial formatting problem isn't gone:
The original intent of nicely formatting the top-level message was to provide a backward-compatible way of supporting As we don't have a clear view of how this error tree should be consumed, there're disagreement about what errors should be skipped or not (see here and here). Also, the formatting issue resurface at the logging level. We didn't discuss it so far, but we have the exact same problem, although less important. We'd have to create our own formatter to process errors set in As an alternative, I'm proposing #46188. It's a temporary drop-in replacement for |
As we have a hard time figuring out what moby#46099 should look like, this drop-in replacement will solve the initial formatting problem we have. It's made internal such that we can remove it whenever we want and unlike moby#46099 doesn't require thoughtful API changes. Signed-off-by: Albin Kerouanton <albinker@gmail.com>
As we have a hard time figuring out what moby#46099 should look like, this drop-in replacement will solve the initial formatting problem we have. It's made internal such that we can remove it whenever we want and unlike moby#46099 doesn't require thoughtful API changes. Signed-off-by: Albin Kerouanton <albinker@gmail.com>
As we have a hard time figuring out what moby#46099 should look like, this drop-in replacement will solve the initial formatting problem we have. It's made internal such that we can remove it whenever we want and unlike moby#46099 doesn't require thoughtful API changes. Signed-off-by: Albin Kerouanton <albinker@gmail.com>
As we have a hard time figuring out what moby#46099 should look like, this drop-in replacement will solve the initial formatting problem we have. It's made internal such that we can remove it whenever we want and unlike moby#46099 doesn't require thoughtful API changes. Signed-off-by: Albin Kerouanton <albinker@gmail.com> Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
- What I did
There're cases like input validation where it makes sense to gather multiple errors and return them all to the client.
Prior to this PR, the API would only return a string
message
field. As a consequence, it requires those errors to be coalesced into a single string using a specific format, whether it's just a newline separator or some more complicated format.This PR adds a new
errors
field containing a list ofErrorResponse
.- A picture of a cute animal (not mandatory but encouraged)