@@ -24,6 +24,9 @@ import (
2424const (
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) {
353359type 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
358369func (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+
370464func 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
0 commit comments