Skip to content

Commit 219fad1

Browse files
authored
Merge pull request #4242 from buildkite/backport-pr-4239
[Backport] Describe and retry undecodable API responses
2 parents 6aede0e + 70e1148 commit 219fad1

7 files changed

Lines changed: 401 additions & 15 deletions

File tree

agent/agent_worker.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,9 @@ func (a *AgentWorker) Heartbeat(ctx context.Context) error {
458458
beat, err := roko.DoFunc(ctx, r, func(r *roko.Retrier) (*api.Heartbeat, error) {
459459
b, resp, err := a.apiClient.Heartbeat(ctx)
460460
if err != nil {
461-
if resp != nil && !api.IsRetryableStatus(resp) {
461+
// A response we couldn't decode is not the API telling us to stop,
462+
// whatever status it arrived with, so check the error as well.
463+
if resp != nil && !api.IsRetryableStatus(resp) && !api.IsRetryableError(err) {
462464
r.Break()
463465
return nil, &errUnrecoverable{action: "Heartbeat", response: resp, err: err}
464466
}

agent/agent_worker_ping.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,9 @@ func (a *AgentWorker) Ping(ctx context.Context) (jobID, action string, err error
223223
// If the ping has a non-retryable status, we have to kill the agent, there's no way of recovering
224224
// The reason we do this after the disconnect check is because the backend can (and does) send disconnect actions in
225225
// responses with non-retryable statuses
226-
if resp != nil && !api.IsRetryableStatus(resp) {
226+
// A response we couldn't decode is not the API telling us to stop, whatever
227+
// status it arrived with, so check the error as well
228+
if resp != nil && !api.IsRetryableStatus(resp) && !api.IsRetryableError(pingErr) {
227229
return "", action, &errUnrecoverable{action: "Ping", response: resp, err: pingErr}
228230
}
229231

api/client.go

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import (
2424
const (
2525
defaultEndpoint = "https://agent-edge.buildkite.com/v3"
2626
defaultUserAgent = "buildkite-agent/api"
27+
28+
// Maximum bytes of a response body to quote in an error message.
29+
maxErrorBodySnippet = 512
2730
)
2831

2932
// Config is configuration for the API Client
@@ -340,8 +343,11 @@ func (c *Client) doRequest(req *http.Request, v any) (*Response, error) {
340343
return response, errors.New("msgpack not supported")
341344
}
342345

343-
if err = json.NewDecoder(resp.Body).Decode(v); err != nil {
344-
return response, fmt.Errorf("failed to decode JSON response: %w", err)
346+
// Keep the start of the body as it is decoded, so that a decode
347+
// failure can report what the response actually contained.
348+
head := &headWriter{limit: maxErrorBodySnippet}
349+
if err = json.NewDecoder(io.TeeReader(resp.Body, head)).Decode(v); err != nil {
350+
return response, newUndecodableResponseError(resp, head.String(), err)
345351
}
346352
}
347353
}
@@ -353,6 +359,11 @@ func (c *Client) doRequest(req *http.Request, v any) (*Response, error) {
353359
type ErrorResponse struct {
354360
Response *http.Response // HTTP response that caused this error
355361
Message string `json:"message"` // error message
362+
363+
// Details of a response body that wasn't an API error message. Taken from the
364+
// response, never decoded from it, hence `json:"-"`.
365+
ContentType string `json:"-"`
366+
Snippet string `json:"-"`
356367
}
357368

358369
func (r *ErrorResponse) Error() string {
@@ -364,9 +375,92 @@ func (r *ErrorResponse) Error() string {
364375
s = fmt.Sprintf("%s: %v", s, r.Message)
365376
}
366377

378+
if r.Snippet != "" {
379+
s = fmt.Sprintf("%s: non-JSON body (content-type %q) starting with %q", s, r.ContentType, r.Snippet)
380+
}
381+
367382
return s
368383
}
369384

385+
// UndecodableResponseError is returned when a response had a success status but
386+
// a body that could not be decoded. Usually this means something other than the
387+
// Buildkite Agent API answered the request - a proxy, load balancer, or CDN
388+
// serving its own error page - which makes it worth retrying. See
389+
// IsRetryableError.
390+
type UndecodableResponseError struct {
391+
Method string // request method
392+
URL string // request URL, after any redirects
393+
Status string // response status
394+
ContentType string // response content type
395+
Snippet string // start of the response body; empty when it could hold credentials
396+
Err error // the decoding error
397+
}
398+
399+
func (e *UndecodableResponseError) Error() string {
400+
s := fmt.Sprintf("%s %s: %s: could not decode response body", e.Method, e.URL, e.Status)
401+
402+
if e.ContentType != "" {
403+
s = fmt.Sprintf("%s (content-type %q)", s, e.ContentType)
404+
}
405+
406+
s = fmt.Sprintf("%s: %v", s, e.Err)
407+
408+
if e.Snippet != "" {
409+
s = fmt.Sprintf("%s: body starts with %q", s, e.Snippet)
410+
}
411+
412+
return s
413+
}
414+
415+
func (e *UndecodableResponseError) Unwrap() error { return e.Err }
416+
417+
func newUndecodableResponseError(r *http.Response, head string, err error) *UndecodableResponseError {
418+
e := &UndecodableResponseError{
419+
Status: r.Status,
420+
ContentType: r.Header.Get("Content-Type"),
421+
Err: err,
422+
}
423+
424+
if r.Request != nil {
425+
e.Method = r.Request.Method
426+
e.URL = r.Request.URL.String()
427+
}
428+
429+
// A body that didn't claim to be JSON came from something that isn't the
430+
// Agent API, so quoting it leaks none of our credentials. A malformed JSON
431+
// body did come from the API and could contain a token, so it stays out.
432+
if !isJSONContent(e.ContentType) {
433+
// head is capped by the caller; drop a trailing partial rune.
434+
e.Snippet = strings.ToValidUTF8(head, "")
435+
}
436+
437+
return e
438+
}
439+
440+
// headWriter keeps the first limit bytes written to it, and discards the rest.
441+
type headWriter struct {
442+
buf bytes.Buffer
443+
limit int
444+
}
445+
446+
func (w *headWriter) Write(p []byte) (int, error) {
447+
if rem := w.limit - w.buf.Len(); rem > 0 {
448+
w.buf.Write(p[:min(rem, len(p))]) //nolint:errcheck // bytes.Buffer.Write never errors.
449+
}
450+
return len(p), nil
451+
}
452+
453+
func (w *headWriter) String() string { return w.buf.String() }
454+
455+
// truncate shortens s to at most limit bytes, marking it when it does.
456+
func truncate(s string, limit int) string {
457+
if len(s) <= limit {
458+
return s
459+
}
460+
// Cutting at a byte offset can split a multi-byte rune; drop the remnant.
461+
return strings.ToValidUTF8(s[:limit], "") + "…"
462+
}
463+
370464
func IsErrHavingStatus(err error, code int) bool {
371465
var apierr *ErrorResponse
372466
return errors.As(err, &apierr) && apierr.Response.StatusCode == code
@@ -377,15 +471,21 @@ func checkResponse(r *http.Response) error {
377471
return nil
378472
}
379473

380-
errorResponse := &ErrorResponse{Response: r}
474+
contentType := r.Header.Get("Content-Type")
475+
errorResponse := &ErrorResponse{Response: r, ContentType: contentType}
381476
data, err := io.ReadAll(r.Body)
382477
if err != nil {
383478
return errorResponse
384479
}
385-
if data != nil {
480+
if len(data) > 0 {
386481
// Unmarshaling the error JSON is best-effort, but we could consider
387482
// reporting unmarshaling problems.
388-
json.Unmarshal(data, errorResponse) //nolint:errcheck // ^^
483+
if err := json.Unmarshal(data, errorResponse); err != nil && !isJSONContent(contentType) {
484+
// Not an API error message: most likely an error page from a proxy,
485+
// load balancer, or CDN in front of the API. It's the only clue as to
486+
// what really answered, and holds none of our credentials.
487+
errorResponse.Snippet = truncate(string(data), maxErrorBodySnippet)
488+
}
389489
}
390490

391491
return errorResponse

api/retryable.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,24 @@ func IsRetryableStatus(r *Response) bool {
3838
}
3939
}
4040

41+
// isUndecodableResponse reports whether err is (or wraps) an
42+
// UndecodableResponseError.
43+
func isUndecodableResponse(err error) bool {
44+
var undecodable *UndecodableResponseError
45+
return errors.As(err, &undecodable)
46+
}
47+
4148
// Looks at a bunch of connection related errors, and returns true if the error
4249
// matches one of them.
4350
func IsRetryableError(err error) bool {
51+
// A response we couldn't decode is usually an error page from a proxy, load
52+
// balancer, or CDN in front of the API, and it can arrive with any status,
53+
// including a 2xx. Either way the API's real answer is lost, so the only way
54+
// to get it is to ask again.
55+
if isUndecodableResponse(err) {
56+
return true
57+
}
58+
4459
var neterr net.Error
4560
if errors.As(err, &neterr) {
4661
if neterr.Timeout() {
@@ -70,10 +85,11 @@ func IsRetryableError(err error) bool {
7085
}
7186

7287
// BreakOnNonRetryable calls r.Break() if the error from an API call is not
73-
// worth retrying. An error is retryable if the response has a retryable status
74-
// code (429, 5xx) or if there was no response and the error is a retryable
75-
// network-level error (connection reset, timeout, etc.). All other errors
76-
// — including all non-429 4xx status codes — cause a break.
88+
// worth retrying. An error is retryable if the response body could not be
89+
// decoded, if the response has a retryable status code (429, 5xx), or if there
90+
// was no response and the error is a retryable network-level error (connection
91+
// reset, timeout, etc.). All other errors — including all non-429 4xx status
92+
// codes — cause a break.
7793
//
7894
// This should be called inside roko retry callbacks after every API call.
7995
// If err is nil, this is a no-op.
@@ -84,6 +100,11 @@ func BreakOnNonRetryable(r *roko.Retrier, resp *Response, err error) (broke bool
84100
if err == nil {
85101
return false
86102
}
103+
// An undecodable response is retryable whatever status it arrived with, so
104+
// classify the error before the response.
105+
if isUndecodableResponse(err) {
106+
return false
107+
}
87108
if resp != nil {
88109
if !IsRetryableStatus(resp) {
89110
r.Break()

0 commit comments

Comments
 (0)