Skip to content

Commit

Permalink
[api] Return a shapely error for unexpected response (#16743)
Browse files Browse the repository at this point in the history
* Add UnexpectedResultError to nomad/api

This allows users to perform additional status-based behavior by rehydrating the error using `errors.As` inside of consumers.
  • Loading branch information
angrycub authored May 22, 2023
1 parent 7e93f15 commit fc90767
Show file tree
Hide file tree
Showing 13 changed files with 508 additions and 70 deletions.
3 changes: 3 additions & 0 deletions .changelog/16743.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
api: return a structured error for unexpected responses
```
31 changes: 10 additions & 21 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -895,21 +895,28 @@ func (c *Client) websocket(endpoint string, q *QueryOptions) (*websocket.Conn, *
conn, resp, err := dialer.Dial(rhttp.URL.String(), rhttp.Header)

// check resp status code, as it's more informative than handshake error we get from ws library
if resp != nil && resp.StatusCode != 101 {
if resp != nil && resp.StatusCode != http.StatusSwitchingProtocols {
var buf bytes.Buffer

if resp.Header.Get("Content-Encoding") == "gzip" {
greader, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("Unexpected response code: %d", resp.StatusCode)
return nil, nil, newUnexpectedResponseError(
fromStatusCode(resp.StatusCode),
withExpectedStatuses([]int{http.StatusSwitchingProtocols}),
withError(err))
}
io.Copy(&buf, greader)
} else {
io.Copy(&buf, resp.Body)
}
resp.Body.Close()

return nil, nil, fmt.Errorf("Unexpected response code: %d (%s)", resp.StatusCode, buf.Bytes())
return nil, nil, newUnexpectedResponseError(
fromStatusCode(resp.StatusCode),
withExpectedStatuses([]int{http.StatusSwitchingProtocols}),
withBody(fmt.Sprint(buf.Bytes())),
)
}

return conn, resp, err
Expand Down Expand Up @@ -1129,24 +1136,6 @@ func encodeBody(obj interface{}) (io.Reader, error) {
return buf, nil
}

// requireOK is used to wrap doRequest and check for a 200
func requireOK(d time.Duration, resp *http.Response, e error) (time.Duration, *http.Response, error) {
if e != nil {
if resp != nil {
resp.Body.Close()
}
return d, nil, e
}
if resp.StatusCode != 200 {
var buf bytes.Buffer
_, _ = io.Copy(&buf, resp.Body)
_ = resp.Body.Close()
body := strings.TrimSpace(buf.String())
return d, nil, fmt.Errorf("Unexpected response code: %d (%s)", resp.StatusCode, body)
}
return d, resp, nil
}

// Context returns the context used for canceling HTTP requests related to this query
func (o *QueryOptions) Context() context.Context {
if o != nil && o.ctx != nil {
Expand Down
175 changes: 175 additions & 0 deletions api/error_unexpected_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package api

import (
"bytes"
"fmt"
"io"
"net/http"
"strings"
"time"

"golang.org/x/exp/slices"
)

// UnexpectedResponseError tracks the components for API errors encountered when
// requireOK and requireStatusIn's conditions are not met.
type UnexpectedResponseError struct {
expected []int
statusCode int
statusText string
body string
err error
additional error
}

func (e UnexpectedResponseError) HasExpectedStatuses() bool { return len(e.expected) > 0 }
func (e UnexpectedResponseError) ExpectedStatuses() []int { return e.expected }
func (e UnexpectedResponseError) HasStatusCode() bool { return e.statusCode != 0 }
func (e UnexpectedResponseError) StatusCode() int { return e.statusCode }
func (e UnexpectedResponseError) HasStatusText() bool { return e.statusText != "" }
func (e UnexpectedResponseError) StatusText() string { return e.statusText }
func (e UnexpectedResponseError) HasBody() bool { return e.body != "" }
func (e UnexpectedResponseError) Body() string { return e.body }
func (e UnexpectedResponseError) HasError() bool { return e.err != nil }
func (e UnexpectedResponseError) Unwrap() error { return e.err }
func (e UnexpectedResponseError) HasAdditional() bool { return e.additional != nil }
func (e UnexpectedResponseError) Additional() error { return e.additional }
func newUnexpectedResponseError(src unexpectedResponseErrorSource, opts ...unexpectedResponseErrorOption) UnexpectedResponseError {
nErr := src()
for _, opt := range opts {
opt(nErr)
}
if nErr.statusText == "" {
// the stdlib's http.StatusText function is a good place to start
nErr.statusFromCode(http.StatusText)
}

return *nErr
}

// Use textual representation of the given integer code. Called when status text
// is not set using the WithStatusText option.
func (e UnexpectedResponseError) statusFromCode(f func(int) string) {
e.statusText = f(e.statusCode)
if !e.HasStatusText() {
e.statusText = "unknown status code"
}
}

func (e UnexpectedResponseError) Error() string {
var eTxt strings.Builder
eTxt.WriteString("Unexpected response code")
if e.HasBody() || e.HasStatusCode() {
eTxt.WriteString(": ")
}
if e.HasStatusCode() {
eTxt.WriteString(fmt.Sprint(e.statusCode))
if e.HasBody() {
eTxt.WriteRune(' ')
}
}
if e.HasBody() {
eTxt.WriteString(fmt.Sprintf("(%s)", e.body))
}

if e.HasAdditional() {
eTxt.WriteString(fmt.Sprintf(". Additionally, an error occurred while constructing this error (%s); the body might be truncated or missing.", e.additional.Error()))
}

return eTxt.String()
}

// UnexpectedResponseErrorOptions are functions passed to NewUnexpectedResponseError
// to customize the created error.
type unexpectedResponseErrorOption func(*UnexpectedResponseError)

// withError allows the addition of a Go error that may have been encountered
// while processing the response. For example, if there is an error constructing
// the gzip reader to process a gzip-encoded response body.
func withError(e error) unexpectedResponseErrorOption {
return func(u *UnexpectedResponseError) { u.err = e }
}

// withBody overwrites the Body value with the provided custom value
func withBody(b string) unexpectedResponseErrorOption {
return func(u *UnexpectedResponseError) { u.body = b }
}

// withStatusText overwrites the StatusText value the provided custom value
func withStatusText(st string) unexpectedResponseErrorOption {
return func(u *UnexpectedResponseError) { u.statusText = st }
}

// withExpectedStatuses provides a list of statuses that the receiving function
// expected to receive. This can be used by API callers to provide more feedback
// to end-users.
func withExpectedStatuses(s []int) unexpectedResponseErrorOption {
return func(u *UnexpectedResponseError) { u.expected = slices.Clone(s) }
}

// unexpectedResponseErrorSource provides the basis for a NewUnexpectedResponseError.
type unexpectedResponseErrorSource func() *UnexpectedResponseError

// fromHTTPResponse read an open HTTP response, drains and closes its body as
// the data for the UnexpectedResponseError.
func fromHTTPResponse(resp *http.Response) unexpectedResponseErrorSource {
return func() *UnexpectedResponseError {
u := new(UnexpectedResponseError)

if resp != nil {
// collect and close the body
var buf bytes.Buffer
if _, e := io.Copy(&buf, resp.Body); e != nil {
u.additional = e
}

// Body has been tested as safe to close more than once
_ = resp.Body.Close()
body := strings.TrimSpace(buf.String())

// make and return the error
u.statusCode = resp.StatusCode
u.statusText = strings.TrimSpace(strings.TrimPrefix(resp.Status, fmt.Sprint(resp.StatusCode)))
u.body = body
}
return u
}
}

// fromStatusCode attempts to resolve the status code to status text using
// the resolving function provided inside of the NewUnexpectedResponseError
// implementation.
func fromStatusCode(sc int) unexpectedResponseErrorSource {
return func() *UnexpectedResponseError { return &UnexpectedResponseError{statusCode: sc} }
}

// doRequestWrapper is a function that wraps the client's doRequest method
// and can be used to provide error and response handling
type doRequestWrapper = func(time.Duration, *http.Response, error) (time.Duration, *http.Response, error)

// requireOK is used to wrap doRequest and check for a 200
func requireOK(d time.Duration, resp *http.Response, e error) (time.Duration, *http.Response, error) {
f := requireStatusIn(http.StatusOK)
return f(d, resp, e)
}

// requireStatusIn is a doRequestWrapper generator that takes expected HTTP
// response codes and validates that the received response code is among them
func requireStatusIn(statuses ...int) doRequestWrapper {
return func(d time.Duration, resp *http.Response, e error) (time.Duration, *http.Response, error) {
if e != nil {
if resp != nil {
_ = resp.Body.Close()
}
return d, nil, e
}

for _, status := range statuses {
if resp.StatusCode == status {
return d, resp, nil
}
}

return d, nil, newUnexpectedResponseError(fromHTTPResponse(resp), withExpectedStatuses(statuses))
}
}
Loading

0 comments on commit fc90767

Please sign in to comment.