distribution/internal/client/errors.go
Markus Thömmes e8820b2564 Don't try to parse error responses with no body
HEAD requests for instance return no body while still having all the relevant Content-Type headers set, causing unnecessary parsing errors. This skips further parsing for all requests that don't have any body to begin with.

Signed-off-by: Markus Thömmes <markusthoemmes@me.com>
2024-03-20 11:46:14 +01:00

186 lines
5.5 KiB
Go

package client
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"github.com/distribution/distribution/v3/internal/client/auth/challenge"
"github.com/distribution/distribution/v3/registry/api/errcode"
)
// ErrNoErrorsInBody is returned when an HTTP response body parses to an empty
// errcode.Errors slice.
var ErrNoErrorsInBody = errors.New("no error details found in HTTP response body")
// UnexpectedHTTPStatusError is returned when an unexpected HTTP status is
// returned when making a registry api call.
type UnexpectedHTTPStatusError struct {
Status string
}
func (e *UnexpectedHTTPStatusError) Error() string {
return fmt.Sprintf("received unexpected HTTP status: %s", e.Status)
}
// UnexpectedHTTPResponseError is returned when an expected HTTP status code
// is returned, but the content was unexpected and failed to be parsed.
type UnexpectedHTTPResponseError struct {
ParseErr error
StatusCode int
Response []byte
}
func (e *UnexpectedHTTPResponseError) Error() string {
return fmt.Sprintf("error parsing HTTP %d response body: %s: %q", e.StatusCode, e.ParseErr.Error(), string(e.Response))
}
func parseHTTPErrorResponse(resp *http.Response) error {
var errors errcode.Errors
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
statusCode := resp.StatusCode
// A HEAD request for example validly does not contain any body, while
// still returning a JSON content-type.
if len(body) == 0 {
return makeError(statusCode, "")
}
ctHeader := resp.Header.Get("Content-Type")
if ctHeader == "" {
return makeError(statusCode, string(body))
}
contentType, _, err := mime.ParseMediaType(ctHeader)
if err != nil {
return fmt.Errorf("failed parsing content-type: %w", err)
}
if contentType != "application/json" && contentType != "application/vnd.api+json" {
return makeError(statusCode, string(body))
}
// For backward compatibility, handle irregularly formatted
// messages that contain a "details" field.
var detailsErr struct {
Details string `json:"details"`
}
err = json.Unmarshal(body, &detailsErr)
if err == nil && detailsErr.Details != "" {
return makeError(statusCode, detailsErr.Details)
}
if err := json.Unmarshal(body, &errors); err != nil {
return &UnexpectedHTTPResponseError{
ParseErr: err,
StatusCode: statusCode,
Response: body,
}
}
if len(errors) == 0 {
// If there was no error specified in the body, return
// UnexpectedHTTPResponseError.
return &UnexpectedHTTPResponseError{
ParseErr: ErrNoErrorsInBody,
StatusCode: statusCode,
Response: body,
}
}
return errors
}
func makeError(statusCode int, details string) error {
switch statusCode {
case http.StatusUnauthorized:
return errcode.ErrorCodeUnauthorized.WithMessage(details)
case http.StatusForbidden:
return errcode.ErrorCodeDenied.WithMessage(details)
case http.StatusTooManyRequests:
return errcode.ErrorCodeTooManyRequests.WithMessage(details)
default:
return errcode.ErrorCodeUnknown.WithMessage(details)
}
}
func makeErrorList(err error) []error {
if errL, ok := err.(errcode.Errors); ok {
return []error(errL)
}
return []error{err}
}
func mergeErrors(err1, err2 error) error {
return errcode.Errors(append(makeErrorList(err1), makeErrorList(err2)...))
}
// HandleHTTPResponseError returns error parsed from HTTP response, if any.
// It returns nil if no error occurred (HTTP status 200-399), or an error
// for unsuccessful HTTP response codes (in the range 400 - 499 inclusive).
// If possible, it returns a typed error, but an UnexpectedHTTPStatusError
// is returned for response code outside the expected range (HTTP status < 200
// and > 500).
func HandleHTTPResponseError(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode <= 399 {
return nil
}
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
// Check for OAuth errors within the `WWW-Authenticate` header first
// See https://tools.ietf.org/html/rfc6750#section-3
for _, c := range challenge.ResponseChallenges(resp) {
if c.Scheme == "bearer" {
var err errcode.Error
// codes defined at https://tools.ietf.org/html/rfc6750#section-3.1
switch c.Parameters["error"] {
case "invalid_token":
err.Code = errcode.ErrorCodeUnauthorized
case "insufficient_scope":
err.Code = errcode.ErrorCodeDenied
default:
continue
}
if description := c.Parameters["error_description"]; description != "" {
err.Message = description
} else {
err.Message = err.Code.Message()
}
return mergeErrors(err, parseHTTPErrorResponse(resp))
}
}
err := parseHTTPErrorResponse(resp)
if uErr, ok := err.(*UnexpectedHTTPResponseError); ok && resp.StatusCode == 401 {
return errcode.ErrorCodeUnauthorized.WithDetail(uErr.Response)
}
return err
}
return &UnexpectedHTTPStatusError{Status: resp.Status}
}
// HandleErrorResponse returns error parsed from HTTP response for an
// unsuccessful HTTP response code (in the range 400 - 499 inclusive). An
// UnexpectedHTTPStatusError returned for response code outside of expected
// range.
//
// Deprecated: use [HandleHTTPResponseError] and check the error.
func HandleErrorResponse(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode <= 399 {
return &UnexpectedHTTPStatusError{Status: resp.Status}
}
return HandleHTTPResponseError(resp)
}
// SuccessStatus returns true if the argument is a successful HTTP response
// code (in the range 200 - 399 inclusive).
//
// Deprecated: use [HandleHTTPResponseError] and check the error.
func SuccessStatus(status int) bool {
return status >= 200 && status <= 399
}