Skip to content

Commit af7e81e

Browse files
committed
ociregistry: 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 code for a 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. Fixes #26. Signed-off-by: Roger Peppe <[email protected]> Change-Id: I5a79c1c6fec9f22c1f565830d73486b406dd181d
1 parent 43b81f4 commit af7e81e

File tree

5 files changed

+324
-150
lines changed

5 files changed

+324
-150
lines changed

Diff for: ociregistry/error.go

+193-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,98 @@
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_ json.RawMessage `json:"detail,omitempty"`
60+
}
61+
62+
// Is makes it possible for users to write `if errors.Is(err, ociregistry.ErrBlobUnknown)`
63+
// even when the error hasn't exactly wrapped that error.
64+
func (e *WireError) Is(err error) bool {
65+
var rerr Error
66+
return errors.As(err, &rerr) && rerr.Code() == e.Code()
67+
}
68+
69+
// Error implements the [error] interface.
70+
func (e *WireError) Error() string {
71+
var buf strings.Builder
72+
for _, r := range e.Code_ {
73+
if r == '_' {
74+
buf.WriteByte(' ')
75+
} else {
76+
buf.WriteRune(unicode.ToLower(r))
77+
}
78+
}
79+
if buf.Len() == 0 {
80+
buf.WriteString("(no code)")
81+
}
82+
if e.Message != "" {
83+
buf.WriteString(": ")
84+
buf.WriteString(e.Message)
85+
}
86+
if len(e.Detail_) != 0 && !bytes.Equal(e.Detail_, []byte("null")) {
87+
buf.WriteString("; detail: ")
88+
buf.Write(e.Detail_)
89+
}
90+
return buf.String()
91+
}
92+
93+
// Code implements [Error.Code].
94+
func (e *WireError) Code() string {
95+
return e.Code_
96+
}
97+
98+
// Detail implements [Error.Detail].
99+
func (e *WireError) Detail() any {
100+
if len(e.Detail_) == 0 {
101+
return nil
102+
}
103+
// TODO do this once only?
104+
var d any
105+
json.Unmarshal(e.Detail_, &d)
106+
return d
107+
}
108+
17109
// NewError returns a new error with the given code, message and detail.
18110
func NewError(msg string, code string, detail any) Error {
19111
return &registryError{
@@ -31,14 +123,110 @@ type Error interface {
31123
// error.Error provides the error message.
32124
error
33125

34-
// Code returns the error code. See
126+
// Code returns the error code.
35127
Code() string
36128

37129
// Detail returns any detail to be associated with the error; it should
38130
// be JSON-marshable.
39131
Detail() any
40132
}
41133

134+
// HTTPError is optionally implemented by an error when
135+
// the error has originated from an HTTP request
136+
// or might be returned from one.
137+
type HTTPError interface {
138+
error
139+
140+
// StatusCode returns the HTTP status code of the response.
141+
StatusCode() int
142+
143+
// Response holds the HTTP response that caused the HTTPError to
144+
// be created. It will return nil if the error was not created
145+
// as a result of an HTTP response.
146+
//
147+
// The caller should not read the response body or otherwise
148+
// change the response (mutation of errors is a Bad Thing).
149+
//
150+
// Use the ResponseBody method to obtain the body of the
151+
// response if needed.
152+
Response() *http.Response
153+
154+
// ResponseBody returns the contents of the response body. It
155+
// will return nil if the error was not created as a result of
156+
// an HTTP response.
157+
//
158+
// The caller should not change or append to the returned data.
159+
ResponseBody() []byte
160+
}
161+
162+
// NewHTTPError returns an error that wraps err to make an [HTTPError]
163+
// that represents the given status code, response and response body.
164+
// Both response and body may be nil.
165+
//
166+
// A shallow copy is made of the response.
167+
func NewHTTPError(err error, statusCode int, response *http.Response, body []byte) HTTPError {
168+
herr := &httpError{
169+
underlying: err,
170+
statusCode: statusCode,
171+
}
172+
if response != nil {
173+
herr.response = ref(*response)
174+
herr.response.Body = nil
175+
herr.body = body
176+
}
177+
return herr
178+
}
179+
180+
type httpError struct {
181+
underlying error
182+
statusCode int
183+
response *http.Response
184+
body []byte
185+
}
186+
187+
// Unwrap implements the [errors] Unwrap interface.
188+
func (e *httpError) Unwrap() error {
189+
return e.underlying
190+
}
191+
192+
// Is makes it possible for users to write `if errors.Is(err, ociregistry.ErrRangeInvalid)`
193+
// even when the error hasn't exactly wrapped that error.
194+
func (e *httpError) Is(err error) bool {
195+
switch e.statusCode {
196+
case http.StatusRequestedRangeNotSatisfiable:
197+
return err == ErrRangeInvalid
198+
}
199+
return false
200+
}
201+
202+
// Error implements [error.Error].
203+
func (e *httpError) Error() string {
204+
var buf strings.Builder
205+
buf.WriteString(strconv.Itoa(e.statusCode))
206+
buf.WriteString(" ")
207+
buf.WriteString(http.StatusText(e.statusCode))
208+
if e.underlying != nil {
209+
buf.WriteString(": ")
210+
buf.WriteString(e.underlying.Error())
211+
}
212+
return buf.String()
213+
}
214+
215+
// StatusCode implements [HTTPEror.StatusCode].
216+
func (e *httpError) StatusCode() int {
217+
return e.statusCode
218+
}
219+
220+
// Response implements [HTTPEror.Response].
221+
func (e *httpError) Response() *http.Response {
222+
return e.response
223+
}
224+
225+
// ResponseBody implements [HTTPEror.ResponseBody].
226+
func (e *httpError) ResponseBody() []byte {
227+
return e.body
228+
}
229+
42230
// The following values represent the known error codes.
43231
var (
44232
ErrBlobUnknown = NewError("blob unknown to registry", "BLOB_UNKNOWN", nil)
@@ -83,3 +271,7 @@ func (e *registryError) Error() string {
83271
func (e *registryError) Detail() any {
84272
return e.detail
85273
}
274+
275+
func ref[T any](x T) *T {
276+
return &x
277+
}

Diff for: ociregistry/ociclient/error.go

+20-106
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,12 @@
1515
package ociclient
1616

1717
import (
18-
"bytes"
1918
"encoding/json"
20-
"errors"
2119
"fmt"
2220
"io"
2321
"mime"
2422
"net/http"
25-
"strconv"
2623
"strings"
27-
"unicode"
2824

2925
"cuelabs.dev/go/oci/ociregistry"
3026
)
@@ -34,98 +30,26 @@ import (
3430
// bytes. Hence, 8 KiB should be sufficient.
3531
const errorBodySizeLimit = 8 * 1024
3632

37-
type wireError struct {
38-
Code_ string `json:"code"`
39-
Message string `json:"message,omitempty"`
40-
Detail_ json.RawMessage `json:"detail,omitempty"`
41-
}
42-
43-
func (e *wireError) Error() string {
44-
var buf strings.Builder
45-
for _, r := range e.Code_ {
46-
if r == '_' {
47-
buf.WriteByte(' ')
33+
func makeError(resp *http.Response) error {
34+
var data []byte
35+
var err error
36+
if resp.Body != nil {
37+
data, err = io.ReadAll(io.LimitReader(resp.Body, errorBodySizeLimit+1))
38+
if err != nil {
39+
err = fmt.Errorf("cannot read error body: %v", err)
40+
} else if len(data) > errorBodySizeLimit {
41+
// TODO include some part of the body
42+
err = fmt.Errorf("error body too large")
4843
} else {
49-
buf.WriteRune(unicode.ToLower(r))
44+
err = makeError1(resp, data)
5045
}
5146
}
52-
if buf.Len() == 0 {
53-
buf.WriteString("(no code)")
54-
}
55-
if e.Message != "" {
56-
buf.WriteString(": ")
57-
buf.WriteString(e.Message)
58-
}
59-
if len(e.Detail_) != 0 && !bytes.Equal(e.Detail_, []byte("null")) {
60-
buf.WriteString("; detail: ")
61-
buf.Write(e.Detail_)
62-
}
63-
return buf.String()
64-
}
65-
66-
// Code implements [ociregistry.Error.Code].
67-
func (e *wireError) Code() string {
68-
return e.Code_
69-
}
70-
71-
// Detail implements [ociregistry.Error.Detail].
72-
func (e *wireError) Detail() any {
73-
if len(e.Detail_) == 0 {
74-
return nil
75-
}
76-
// TODO do this once only?
77-
var d any
78-
json.Unmarshal(e.Detail_, &d)
79-
return d
80-
}
81-
82-
// Is makes it possible for users to write `if errors.Is(err, ociregistry.ErrBlobUnknown)`
83-
// even when the error hasn't exactly wrapped that error.
84-
func (e *wireError) Is(err error) bool {
85-
var rerr ociregistry.Error
86-
return errors.As(err, &rerr) && rerr.Code() == e.Code()
87-
}
88-
89-
type wireErrors struct {
90-
httpStatusCode int
91-
Errors []wireError `json:"errors"`
92-
}
93-
94-
func (e *wireErrors) Unwrap() []error {
95-
// TODO we could do this only once.
96-
errs := make([]error, len(e.Errors))
97-
for i := range e.Errors {
98-
errs[i] = &e.Errors[i]
99-
}
100-
return errs
101-
}
102-
103-
// Is makes it possible for users to write `if errors.Is(err, ociregistry.ErrRangeInvalid)`
104-
// even when the error hasn't exactly wrapped that error.
105-
func (e *wireErrors) Is(err error) bool {
106-
switch e.httpStatusCode {
107-
case http.StatusRequestedRangeNotSatisfiable:
108-
return err == ociregistry.ErrRangeInvalid
109-
}
110-
return false
111-
}
112-
113-
func (e *wireErrors) Error() string {
114-
var buf strings.Builder
115-
buf.WriteString(strconv.Itoa(e.httpStatusCode))
116-
buf.WriteString(" ")
117-
buf.WriteString(http.StatusText(e.httpStatusCode))
118-
buf.WriteString(": ")
119-
buf.WriteString(e.Errors[0].Error())
120-
for i := range e.Errors[1:] {
121-
buf.WriteString("; ")
122-
buf.WriteString(e.Errors[i+1].Error())
123-
}
124-
return buf.String()
47+
// We always include the status code and response in the error.
48+
return ociregistry.NewHTTPError(err, resp.StatusCode, resp, data)
12549
}
12650

12751
// makeError forms an error from a non-OK response.
128-
func makeError(resp *http.Response) error {
52+
func makeError1(resp *http.Response, bodyData []byte) error {
12953
if resp.Request.Method == "HEAD" {
13054
// When we've made a HEAD request, we can't see any of
13155
// the actual error, so we'll have to make up something
@@ -143,31 +67,21 @@ func makeError(resp *http.Response) error {
14367
case http.StatusBadRequest:
14468
err = ociregistry.ErrUnsupported
14569
default:
146-
return fmt.Errorf("error response: %v", resp.Status)
70+
// Our caller will turn this into a non-nil error.
71+
return nil
14772
}
14873
return fmt.Errorf("error response: %v: %w", resp.Status, err)
14974
}
150-
if !isJSONMediaType(resp.Header.Get("Content-Type")) || resp.Request.Method == "HEAD" {
151-
// TODO include some of the body in this case?
152-
data, _ := io.ReadAll(resp.Body)
153-
return fmt.Errorf("error response: %v; body: %q", resp.Status, data)
154-
}
155-
data, err := io.ReadAll(io.LimitReader(resp.Body, errorBodySizeLimit+1))
156-
if err != nil {
157-
return fmt.Errorf("%s: cannot read error body: %v", resp.Status, err)
158-
}
159-
if len(data) > errorBodySizeLimit {
160-
// TODO include some part of the body
161-
return fmt.Errorf("error body too large")
75+
if ctype := resp.Header.Get("Content-Type"); !isJSONMediaType(ctype) {
76+
return fmt.Errorf("non-JSON error response %q", ctype)
16277
}
163-
var errs wireErrors
164-
if err := json.Unmarshal(data, &errs); err != nil {
78+
var errs ociregistry.WireErrors
79+
if err := json.Unmarshal(bodyData, &errs); err != nil {
16580
return fmt.Errorf("%s: malformed error response: %v", resp.Status, err)
16681
}
16782
if len(errs.Errors) == 0 {
16883
return fmt.Errorf("%s: no errors in body (probably a server issue)", resp.Status)
16984
}
170-
errs.httpStatusCode = resp.StatusCode
17185
return &errs
17286
}
17387

0 commit comments

Comments
 (0)