Skip to content

Commit ee4ab5b

Browse files
committed
ociregistry: implement MarshalError and use it in ociserver
This provides a standard way for code to convert from a regular Go error to the form appropriate for OCI error response bodies. It seems to make sense to put this inside the top level ociregistry package because the JSON-oriented `WireError` types are already implemented there, and the logic for stripping error code prefixes is directly related to the logic in the same package that adds them. Also in passing fix an unintended use of the deprecated `io/ioutil` package to use `io` instead. Also add a test for error stuttering to `ociclient` and fix the code to avoid stuttering the HTTP status code as exposed by that test. Fixes #31. Signed-off-by: Roger Peppe <[email protected]> Change-Id: I22408dd3320b73bc52f37181987f79003dbaa115 Reviewed-on: https://review.gerrithub.io/c/cue-labs/oci/+/1191146 TryBot-Result: CUE porcuepine <[email protected]> Reviewed-by: Daniel Martí <[email protected]>
1 parent 0c018d8 commit ee4ab5b

File tree

7 files changed

+278
-111
lines changed

7 files changed

+278
-111
lines changed

ociregistry/error.go

Lines changed: 128 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,33 @@
1515
package ociregistry
1616

1717
import (
18-
"bytes"
1918
"encoding/json"
2019
"errors"
20+
"fmt"
2121
"net/http"
2222
"strconv"
2323
"strings"
2424
"unicode"
2525
)
2626

27+
var errorStatuses = map[string]int{
28+
ErrBlobUnknown.Code(): http.StatusNotFound,
29+
ErrBlobUploadInvalid.Code(): http.StatusRequestedRangeNotSatisfiable,
30+
ErrBlobUploadUnknown.Code(): http.StatusNotFound,
31+
ErrDigestInvalid.Code(): http.StatusBadRequest,
32+
ErrManifestBlobUnknown.Code(): http.StatusNotFound,
33+
ErrManifestInvalid.Code(): http.StatusBadRequest,
34+
ErrManifestUnknown.Code(): http.StatusNotFound,
35+
ErrNameInvalid.Code(): http.StatusBadRequest,
36+
ErrNameUnknown.Code(): http.StatusNotFound,
37+
ErrSizeInvalid.Code(): http.StatusBadRequest,
38+
ErrUnauthorized.Code(): http.StatusUnauthorized,
39+
ErrDenied.Code(): http.StatusForbidden,
40+
ErrUnsupported.Code(): http.StatusBadRequest,
41+
ErrTooManyRequests.Code(): http.StatusTooManyRequests,
42+
ErrRangeInvalid.Code(): http.StatusRequestedRangeNotSatisfiable,
43+
}
44+
2745
// WireErrors is the JSON format used for error responses in
2846
// the OCI HTTP API. It should always contain at least one
2947
// error.
@@ -70,26 +88,18 @@ func (e *WireError) Is(err error) bool {
7088

7189
// Error implements the [error] interface.
7290
func (e *WireError) Error() string {
73-
var buf strings.Builder
74-
for _, r := range e.Code_ {
75-
if r == '_' {
76-
buf.WriteByte(' ')
77-
} else {
78-
buf.WriteRune(unicode.ToLower(r))
79-
}
80-
}
81-
if buf.Len() == 0 {
82-
buf.WriteString("(no code)")
83-
}
91+
buf := make([]byte, 0, 128)
92+
buf = appendErrorCodePrefix(buf, e.Code_)
93+
8494
if e.Message != "" {
85-
buf.WriteString(": ")
86-
buf.WriteString(e.Message)
95+
buf = append(buf, ": "...)
96+
buf = append(buf, e.Message...)
8797
}
88-
if len(e.Detail_) != 0 && !bytes.Equal(e.Detail_, []byte("null")) {
89-
buf.WriteString("; detail: ")
90-
buf.Write(e.Detail_)
91-
}
92-
return buf.String()
98+
// TODO: it would be nice to have some way to surface the detail
99+
// in a message, but it's awkward to do so here because we don't
100+
// really want the detail to be duplicated in the "message"
101+
// and "detail" fields.
102+
return string(buf)
93103
}
94104

95105
// Code implements [Error.Code].
@@ -198,16 +208,14 @@ func (e *httpError) Is(err error) bool {
198208

199209
// Error implements [error.Error].
200210
func (e *httpError) Error() string {
201-
var buf strings.Builder
202-
buf.WriteString(strconv.Itoa(e.statusCode))
203-
buf.WriteString(" ")
204-
buf.WriteString(http.StatusText(e.statusCode))
211+
buf := make([]byte, 0, 128)
212+
buf = appendHTTPStatusPrefix(buf, e.statusCode)
205213
if e.underlying != nil {
206-
buf.WriteString(": ")
207-
buf.WriteString(e.underlying.Error())
214+
buf = append(buf, ": "...)
215+
buf = append(buf, e.underlying.Error()...)
208216
}
209217
// TODO if underlying is nil, include some portion of e.body in the message?
210-
return buf.String()
218+
return string(buf)
211219
}
212220

213221
// StatusCode implements [HTTPError.StatusCode].
@@ -225,6 +233,79 @@ func (e *httpError) ResponseBody() []byte {
225233
return e.body
226234
}
227235

236+
// MarshalError marshals the given error as JSON according
237+
// to the OCI distribution specification. It also returns
238+
// the associated HTTP status code, or [http.StatusInternalServerError]
239+
// if no specific code can be found.
240+
//
241+
// If err is or wraps [Error], that code will be used for the "code"
242+
// field in the marshaled error.
243+
//
244+
// If err wraps [HTTPError] and no HTTP status code is known
245+
// for the error code, [HTTPError.StatusCode] will be used.
246+
func MarshalError(err error) (errorBody []byte, httpStatus int) {
247+
var e WireError
248+
// TODO perhaps we should iterate through all the
249+
// errors instead of just choosing one.
250+
// See https://github.com/golang/go/issues/66455
251+
var ociErr Error
252+
if errors.As(err, &ociErr) {
253+
e.Code_ = ociErr.Code()
254+
e.Detail_ = ociErr.Detail()
255+
}
256+
if e.Code_ == "" {
257+
// This is contrary to spec, but it's what the Docker registry
258+
// does, so it can't be too bad.
259+
e.Code_ = "UNKNOWN"
260+
}
261+
// Use the HTTP status code from the error only when there isn't
262+
// one implied from the error code. This means that the HTTP status
263+
// is always consistent with the error code, but still allows a registry
264+
// to choose custom HTTP status codes for other codes.
265+
httpStatus = http.StatusInternalServerError
266+
if status, ok := errorStatuses[e.Code_]; ok {
267+
httpStatus = status
268+
} else {
269+
var httpErr HTTPError
270+
if errors.As(err, &httpErr) {
271+
httpStatus = httpErr.StatusCode()
272+
}
273+
}
274+
// Prevent the message from containing a redundant
275+
// error code prefix by stripping it before sending over the
276+
// wire. This won't always work, but is enough to prevent
277+
// adjacent stuttering of code prefixes when a client
278+
// creates a WireError from an error response.
279+
e.Message = trimErrorCodePrefix(err, httpStatus, e.Code_)
280+
data, err := json.Marshal(WireErrors{
281+
Errors: []WireError{e},
282+
})
283+
if err != nil {
284+
panic(fmt.Errorf("cannot marshal error: %v", err))
285+
}
286+
return data, httpStatus
287+
}
288+
289+
// trimErrorCodePrefix returns err's string
290+
// with any prefix codes added by [HTTPError]
291+
// or [WireError] removed.
292+
func trimErrorCodePrefix(err error, httpStatus int, errorCode string) string {
293+
msg := err.Error()
294+
buf := make([]byte, 0, 128)
295+
if httpStatus != 0 {
296+
buf = appendHTTPStatusPrefix(buf, httpStatus)
297+
buf = append(buf, ": "...)
298+
msg = strings.TrimPrefix(msg, string(buf))
299+
}
300+
if errorCode != "" {
301+
buf = buf[:0]
302+
buf = appendErrorCodePrefix(buf, errorCode)
303+
buf = append(buf, ": "...)
304+
msg = strings.TrimPrefix(msg, string(buf))
305+
}
306+
return msg
307+
}
308+
228309
// The following values represent the known error codes.
229310
var (
230311
ErrBlobUnknown = NewError("blob unknown to registry", "BLOB_UNKNOWN", nil)
@@ -252,6 +333,27 @@ var (
252333
ErrRangeInvalid = NewError("invalid content range", "RANGE_INVALID", nil)
253334
)
254335

336+
func appendHTTPStatusPrefix(buf []byte, statusCode int) []byte {
337+
buf = strconv.AppendInt(buf, int64(statusCode), 10)
338+
buf = append(buf, ' ')
339+
buf = append(buf, http.StatusText(statusCode)...)
340+
return buf
341+
}
342+
343+
func appendErrorCodePrefix(buf []byte, code string) []byte {
344+
if code == "" {
345+
return append(buf, "(no code)"...)
346+
}
347+
for _, r := range code {
348+
if r == '_' {
349+
buf = append(buf, ' ')
350+
} else {
351+
buf = append(buf, string(unicode.ToLower(r))...)
352+
}
353+
}
354+
return buf
355+
}
356+
255357
func ref[T any](x T) *T {
256358
return &x
257359
}

ociregistry/error_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package ociregistry
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"testing"
9+
10+
"github.com/go-quicktest/qt"
11+
)
12+
13+
var errorTests = []struct {
14+
testName string
15+
err error
16+
wantMsg string
17+
wantMarshalData rawJSONMessage
18+
wantMarshalHTTPStatus int
19+
}{{
20+
testName: "RegularGoError",
21+
err: fmt.Errorf("unknown error"),
22+
wantMsg: "unknown error",
23+
wantMarshalData: `{"errors":[{"code":"UNKNOWN","message":"unknown error"}]}`,
24+
wantMarshalHTTPStatus: http.StatusInternalServerError,
25+
}, {
26+
testName: "RegistryError",
27+
err: ErrBlobUnknown,
28+
wantMsg: "blob unknown: blob unknown to registry",
29+
wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown to registry"}]}`,
30+
wantMarshalHTTPStatus: http.StatusNotFound,
31+
}, {
32+
testName: "WrappedRegistryErrorWithContextAtStart",
33+
err: fmt.Errorf("some context: %w", ErrBlobUnknown),
34+
wantMsg: "some context: blob unknown: blob unknown to registry",
35+
wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"some context: blob unknown: blob unknown to registry"}]}`,
36+
wantMarshalHTTPStatus: http.StatusNotFound,
37+
}, {
38+
testName: "WrappedRegistryErrorWithContextAtEnd",
39+
err: fmt.Errorf("%w: some context", ErrBlobUnknown),
40+
wantMsg: "blob unknown: blob unknown to registry: some context",
41+
wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown to registry: some context"}]}`,
42+
wantMarshalHTTPStatus: http.StatusNotFound,
43+
}, {
44+
testName: "HTTPStatusIgnoredWithKnownCode",
45+
err: NewHTTPError(fmt.Errorf("%w: some context", ErrBlobUnknown), http.StatusUnauthorized, nil, nil),
46+
wantMsg: "401 Unauthorized: blob unknown: blob unknown to registry: some context",
47+
// Note: the "401 Unauthorized" remains intact because it's not redundant with respect
48+
// to the 404 HTTP response code.
49+
wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"401 Unauthorized: blob unknown: blob unknown to registry: some context"}]}`,
50+
wantMarshalHTTPStatus: http.StatusNotFound,
51+
}, {
52+
testName: "HTTPStatusUsedWithUnknownCode",
53+
err: NewHTTPError(NewError("a message with a code", "SOME_CODE", nil), http.StatusUnauthorized, nil, nil),
54+
wantMsg: "401 Unauthorized: some code: a message with a code",
55+
wantMarshalData: `{"errors":[{"code":"SOME_CODE","message":"a message with a code"}]}`,
56+
wantMarshalHTTPStatus: http.StatusUnauthorized,
57+
}, {
58+
testName: "ErrorWithDetail",
59+
err: NewError("a message with some detail", "SOME_CODE", json.RawMessage(`{"foo": true}`)),
60+
wantMsg: `some code: a message with some detail`,
61+
wantMarshalData: `{"errors":[{"code":"SOME_CODE","message":"a message with some detail","detail":{"foo":true}}]}`,
62+
wantMarshalHTTPStatus: http.StatusInternalServerError,
63+
}}
64+
65+
func TestError(t *testing.T) {
66+
for _, test := range errorTests {
67+
t.Run(test.testName, func(t *testing.T) {
68+
qt.Check(t, qt.ErrorMatches(test.err, test.wantMsg))
69+
data, httpStatus := MarshalError(test.err)
70+
qt.Check(t, qt.Equals(httpStatus, test.wantMarshalHTTPStatus))
71+
qt.Check(t, qt.JSONEquals(data, test.wantMarshalData), qt.Commentf("marshal data: %s", data))
72+
73+
// Check that the marshaled error unmarshals into WireError OK and
74+
// that the code matches appropriately.
75+
var errs *WireErrors
76+
err := json.Unmarshal(data, &errs)
77+
qt.Assert(t, qt.IsNil(err))
78+
if ociErr := Error(nil); errors.As(test.err, &ociErr) {
79+
qt.Assert(t, qt.IsTrue(errors.Is(errs, NewError("something", ociErr.Code(), nil))))
80+
}
81+
})
82+
}
83+
}
84+
85+
type rawJSONMessage string
86+
87+
func (m rawJSONMessage) MarshalJSON() ([]byte, error) {
88+
return []byte(m), nil
89+
}
90+
91+
func (m *rawJSONMessage) UnmarshalJSON(data []byte) error {
92+
*m = rawJSONMessage(data)
93+
return nil
94+
}

ociregistry/ociclient/error.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ func makeError1(resp *http.Response, bodyData []byte) error {
5555
// When we've made a HEAD request, we can't see any of
5656
// the actual error, so we'll have to make up something
5757
// from the HTTP status.
58+
// TODO would we do better if we interpreted the HTTP status
59+
// relative to the actual method that was called in order
60+
// to come up with a more plausible error?
5861
var err error
5962
switch resp.StatusCode {
6063
case http.StatusNotFound:
@@ -71,7 +74,7 @@ func makeError1(resp *http.Response, bodyData []byte) error {
7174
// Our caller will turn this into a non-nil error.
7275
return nil
7376
}
74-
return fmt.Errorf("error response: %v: %w", resp.Status, err)
77+
return err
7578
}
7679
if ctype := resp.Header.Get("Content-Type"); !isJSONMediaType(ctype) {
7780
return fmt.Errorf("non-JSON error response %q; body %q", ctype, bodyData)

ociregistry/ociclient/error_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,39 @@ import (
1010
"testing"
1111

1212
"cuelabs.dev/go/oci/ociregistry"
13+
"cuelabs.dev/go/oci/ociregistry/ociserver"
1314
"github.com/go-quicktest/qt"
1415
"github.com/opencontainers/go-digest"
1516
)
1617

18+
func TestErrorStuttering(t *testing.T) {
19+
// This checks that the stuttering observed in issue #31
20+
// isn't an issue when ociserver wraps ociclient.
21+
srv := httptest.NewServer(ociserver.New(&ociregistry.Funcs{
22+
NewError: func(ctx context.Context, methodName, repo string) error {
23+
return ociregistry.ErrManifestUnknown
24+
},
25+
}, nil))
26+
defer srv.Close()
27+
28+
srvURL, _ := url.Parse(srv.URL)
29+
r, err := New(srvURL.Host, &Options{
30+
Insecure: true,
31+
})
32+
qt.Assert(t, qt.IsNil(err))
33+
_, err = r.GetTag(context.Background(), "foo", "sometag")
34+
qt.Check(t, qt.ErrorIs(err, ociregistry.ErrManifestUnknown))
35+
qt.Check(t, qt.ErrorMatches(err, `404 Not Found: manifest unknown: manifest unknown to registry`))
36+
37+
// ResolveTag uses HEAD rather than GET, so here we're testing
38+
// the path where a response with no body gets turned back into
39+
// something vaguely resembling the original error, which is why
40+
// the code and message have changed.
41+
_, err = r.ResolveTag(context.Background(), "foo", "sometag")
42+
qt.Check(t, qt.ErrorIs(err, ociregistry.ErrNameUnknown))
43+
qt.Check(t, qt.ErrorMatches(err, `404 Not Found: name unknown: repository name not known to registry`))
44+
}
45+
1746
func TestNonJSONErrorResponse(t *testing.T) {
1847
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
1948
w.WriteHeader(http.StatusTeapot)

0 commit comments

Comments
 (0)