Skip to content

Commit a074250

Browse files
committed
ociregistry: breaking change: improve errors with respect to HTTP status
Currently the standard wire representation of an error is duplicated across both client and server, and there is no way for: - a client to access the actual HTTP status of a response - an `Interface` implementation to cause the `ociserver` to return a specific HTTP error status for an error code that it doesn't know about. This change addresses that by moving the wire representation into the top level `ociregistry` package, splitting `HTTPError` into its own type, and making both `ociclient` and `ociserver` aware of them. One deliberate decision made here guards against some scenarios when nesting an `ociclient` implementation inside `ociserver`. There is a risk that, due to the fact we're using the same `HTTPError` in `httpclient` and `httpserve, if `ociclient` talks to a misbehaving server that returns inappropriate HTTP response codes, those codes could propagate back through `ociserver`, causing that to return inappropriate codes too. So in `ociserver` we only use the `HTTPError` code if there is no known HTTP status for the error code. This seems like a reasonable half-way-house compromise. We also change the `ociregistry.Error` interface so that `Detail` returns `json.RawMessage` instead of `any`. This means that the error detail is consistent no matter if the error has come from `ociclient` or from a Go-implemented `ociregistry.Interface` implementation, and means we can use `ociregistry.WireError` as the base error implementation used by all the exported specific error values. This in turn means that the error message changes because `WireError` includes the error code (lower-cased) in the message, but that consistency seems like a good thing. Fixes #26. Signed-off-by: Roger Peppe <[email protected]> Change-Id: I5a79c1c6fec9f22c1f565830d73486b406dd181d Reviewed-on: https://review.gerrithub.io/c/cue-labs/oci/+/1186153 Reviewed-by: Tianon Gravi <[email protected]> TryBot-Result: CUE porcuepine <[email protected]> Reviewed-by: Paul Jolly <[email protected]>
1 parent 43b81f4 commit a074250

File tree

11 files changed

+503
-215
lines changed

11 files changed

+503
-215
lines changed

ociregistry/error.go

+197-25
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,100 @@
1414

1515
package ociregistry
1616

17+
import (
18+
"bytes"
19+
"encoding/json"
20+
"errors"
21+
"net/http"
22+
"strconv"
23+
"strings"
24+
"unicode"
25+
)
26+
27+
// WireErrors is the JSON format used for error responses in
28+
// the OCI HTTP API. It should always contain at least one
29+
// error.
30+
type WireErrors struct {
31+
Errors []WireError `json:"errors"`
32+
}
33+
34+
// Unwrap allows [errors.Is] and [errors.As] to
35+
// see the errors inside e.
36+
func (e *WireErrors) Unwrap() []error {
37+
// TODO we could do this only once.
38+
errs := make([]error, len(e.Errors))
39+
for i := range e.Errors {
40+
errs[i] = &e.Errors[i]
41+
}
42+
return errs
43+
}
44+
45+
func (e *WireErrors) Error() string {
46+
var buf strings.Builder
47+
buf.WriteString(e.Errors[0].Error())
48+
for i := range e.Errors[1:] {
49+
buf.WriteString("; ")
50+
buf.WriteString(e.Errors[i+1].Error())
51+
}
52+
return buf.String()
53+
}
54+
55+
// WireError holds a single error in an OCI HTTP response.
56+
type WireError struct {
57+
Code_ string `json:"code"`
58+
Message string `json:"message,omitempty"`
59+
// Detail_ holds the JSON detail for the message.
60+
// It's assumed to be valid JSON if non-empty.
61+
Detail_ json.RawMessage `json:"detail,omitempty"`
62+
}
63+
64+
// Is makes it possible for users to write `if errors.Is(err, ociregistry.ErrBlobUnknown)`
65+
// even when the error hasn't exactly wrapped that error.
66+
func (e *WireError) Is(err error) bool {
67+
var rerr Error
68+
return errors.As(err, &rerr) && rerr.Code() == e.Code()
69+
}
70+
71+
// Error implements the [error] interface.
72+
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+
}
84+
if e.Message != "" {
85+
buf.WriteString(": ")
86+
buf.WriteString(e.Message)
87+
}
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()
93+
}
94+
95+
// Code implements [Error.Code].
96+
func (e *WireError) Code() string {
97+
return e.Code_
98+
}
99+
100+
// Detail implements [Error.Detail].
101+
func (e *WireError) Detail() json.RawMessage {
102+
return e.Detail_
103+
}
104+
17105
// NewError returns a new error with the given code, message and detail.
18-
func NewError(msg string, code string, detail any) Error {
19-
return &registryError{
20-
code: code,
21-
message: msg,
22-
detail: detail,
106+
func NewError(msg string, code string, detail json.RawMessage) Error {
107+
return &WireError{
108+
Code_: code,
109+
Message: msg,
110+
Detail_: detail,
23111
}
24112
}
25113

@@ -31,12 +119,110 @@ type Error interface {
31119
// error.Error provides the error message.
32120
error
33121

34-
// Code returns the error code. See
122+
// Code returns the error code.
35123
Code() string
36124

37-
// Detail returns any detail to be associated with the error; it should
38-
// be JSON-marshable.
39-
Detail() any
125+
// Detail returns any detail associated with the error,
126+
// or nil if there is none.
127+
// The caller should not mutate the returned slice.
128+
Detail() json.RawMessage
129+
}
130+
131+
// HTTPError is optionally implemented by an error when
132+
// the error has originated from an HTTP request
133+
// or might be returned from one.
134+
type HTTPError interface {
135+
error
136+
137+
// StatusCode returns the HTTP status code of the response.
138+
StatusCode() int
139+
140+
// Response holds the HTTP response that caused the HTTPError to
141+
// be created. It will return nil if the error was not created
142+
// as a result of an HTTP response.
143+
//
144+
// The caller should not read the response body or otherwise
145+
// change the response (mutation of errors is a Bad Thing).
146+
//
147+
// Use the ResponseBody method to obtain the body of the
148+
// response if needed.
149+
Response() *http.Response
150+
151+
// ResponseBody returns the contents of the response body. It
152+
// will return nil if the error was not created as a result of
153+
// an HTTP response.
154+
//
155+
// The caller should not change or append to the returned data.
156+
ResponseBody() []byte
157+
}
158+
159+
// NewHTTPError returns an error that wraps err to make an [HTTPError]
160+
// that represents the given status code, response and response body.
161+
// Both response and body may be nil.
162+
//
163+
// A shallow copy is made of the response.
164+
func NewHTTPError(err error, statusCode int, response *http.Response, body []byte) HTTPError {
165+
herr := &httpError{
166+
underlying: err,
167+
statusCode: statusCode,
168+
}
169+
if response != nil {
170+
herr.response = ref(*response)
171+
herr.response.Body = nil
172+
herr.body = body
173+
}
174+
return herr
175+
}
176+
177+
type httpError struct {
178+
underlying error
179+
statusCode int
180+
response *http.Response
181+
body []byte
182+
}
183+
184+
// Unwrap implements the [errors] Unwrap interface.
185+
func (e *httpError) Unwrap() error {
186+
return e.underlying
187+
}
188+
189+
// Is makes it possible for users to write `if errors.Is(err, ociregistry.ErrRangeInvalid)`
190+
// even when the error hasn't exactly wrapped that error.
191+
func (e *httpError) Is(err error) bool {
192+
switch e.statusCode {
193+
case http.StatusRequestedRangeNotSatisfiable:
194+
return err == ErrRangeInvalid
195+
}
196+
return false
197+
}
198+
199+
// Error implements [error.Error].
200+
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))
205+
if e.underlying != nil {
206+
buf.WriteString(": ")
207+
buf.WriteString(e.underlying.Error())
208+
}
209+
// TODO if underlying is nil, include some portion of e.body in the message?
210+
return buf.String()
211+
}
212+
213+
// StatusCode implements [HTTPError.StatusCode].
214+
func (e *httpError) StatusCode() int {
215+
return e.statusCode
216+
}
217+
218+
// Response implements [HTTPError.Response].
219+
func (e *httpError) Response() *http.Response {
220+
return e.response
221+
}
222+
223+
// ResponseBody implements [HTTPError.ResponseBody].
224+
func (e *httpError) ResponseBody() []byte {
225+
return e.body
40226
}
41227

42228
// The following values represent the known error codes.
@@ -66,20 +252,6 @@ var (
66252
ErrRangeInvalid = NewError("invalid content range", "RANGE_INVALID", nil)
67253
)
68254

69-
type registryError struct {
70-
code string
71-
message string
72-
detail any
73-
}
74-
75-
func (e *registryError) Code() string {
76-
return e.code
77-
}
78-
79-
func (e *registryError) Error() string {
80-
return e.message
81-
}
82-
83-
func (e *registryError) Detail() any {
84-
return e.detail
255+
func ref[T any](x T) *T {
256+
return &x
85257
}

ociregistry/internal/ocirequest/request_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ var parseRequestTests = []struct {
6262
testName: "getBlobInvalidRepo",
6363
method: "GET",
6464
url: "/v2/foo/bAr/blobs/sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
65-
wantError: `invalid repository name`,
65+
wantError: `name invalid: invalid repository name`,
6666
}, {
6767
testName: "startUpload",
6868
method: "POST",

ociregistry/ociauth/auth.go

+10-24
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"strings"
1313
"sync"
1414
"time"
15+
16+
"cuelabs.dev/go/oci/ociregistry"
1517
)
1618

1719
// TODO decide on a good value for this.
@@ -298,8 +300,8 @@ func (r *registry) acquireAccessToken(ctx context.Context, requiredScope, wantSc
298300
scope := requiredScope.Union(wantScope)
299301
tok, err := r.acquireToken(ctx, scope)
300302
if err != nil {
301-
var rerr *responseError
302-
if !errors.As(err, &rerr) || rerr.statusCode != http.StatusUnauthorized {
303+
var herr ociregistry.HTTPError
304+
if !errors.As(err, &herr) || herr.StatusCode() != http.StatusUnauthorized {
303305
return "", err
304306
}
305307
// The documentation says this:
@@ -372,8 +374,8 @@ func (r *registry) acquireToken(ctx context.Context, scope Scope) (*wireToken, e
372374
if err == nil {
373375
return tok, nil
374376
}
375-
var rerr *responseError
376-
if !errors.As(err, &rerr) || rerr.statusCode != http.StatusNotFound {
377+
var herr ociregistry.HTTPError
378+
if !errors.As(err, &herr) || herr.StatusCode() != http.StatusNotFound {
377379
return tok, err
378380
}
379381
// The request to the endpoint returned 404 from the POST request,
@@ -448,12 +450,12 @@ func (r *registry) doTokenRequest(req *http.Request) (*wireToken, error) {
448450
return nil, err
449451
}
450452
defer resp.Body.Close()
453+
data, bodyErr := io.ReadAll(resp.Body)
451454
if resp.StatusCode != http.StatusOK {
452-
return nil, errorFromResponse(resp)
455+
return nil, ociregistry.NewHTTPError(nil, resp.StatusCode, resp, data)
453456
}
454-
data, err := io.ReadAll(resp.Body)
455-
if err != nil {
456-
return nil, fmt.Errorf("cannot read response body: %v", err)
457+
if bodyErr != nil {
458+
return nil, fmt.Errorf("error reading response body: %v", err)
457459
}
458460
var tok wireToken
459461
if err := json.Unmarshal(data, &tok); err != nil {
@@ -462,22 +464,6 @@ func (r *registry) doTokenRequest(req *http.Request) (*wireToken, error) {
462464
return &tok, nil
463465
}
464466

465-
type responseError struct {
466-
statusCode int
467-
msg string
468-
}
469-
470-
func errorFromResponse(resp *http.Response) error {
471-
// TODO include body of response in error message.
472-
return &responseError{
473-
statusCode: resp.StatusCode,
474-
}
475-
}
476-
477-
func (e *responseError) Error() string {
478-
return fmt.Sprintf("unexpected HTTP response %d", e.statusCode)
479-
}
480-
481467
// deleteExpiredTokens removes all tokens from r that expire after the given
482468
// time.
483469
// TODO ask the store to remove expired tokens?

ociregistry/ociclient/badname_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func TestBadRepoName(t *testing.T) {
1717
})
1818
qt.Assert(t, qt.IsNil(err))
1919
_, err = r.GetBlob(ctx, "Invalid--Repo", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
20-
qt.Check(t, qt.ErrorMatches(err, "invalid OCI request: invalid repository name"))
20+
qt.Check(t, qt.ErrorMatches(err, "invalid OCI request: name invalid: invalid repository name"))
2121
_, err = r.GetBlob(ctx, "okrepo", "bad-digest")
2222
qt.Check(t, qt.ErrorMatches(err, "invalid OCI request: badly formed digest"))
2323
_, err = r.ResolveTag(ctx, "okrepo", "bad-Tag!")

0 commit comments

Comments
 (0)