From 8b13ac902558b8d9f4b1a855e2944fe2a5f16694 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 6 Nov 2026 21:01:38 +0000 Subject: [PATCH 1/2] fix: report non-JSON error responses by their HTTP status An error response whose body is not JSON, such as a proxy's HTML page or an empty 401 or 404, was reported by the JSON decoder's complaint alone, e.g. "invalid character '<' looking for beginning of value". The status was carried on the APIError but never reached the message, so the user could not tell a firewall block from a missing resource. The message is now the status, such as "HTTP 403 Forbidden". When the body is not empty it adds that the response is not JSON and, unless --debug is already on, that rerunning with --debug shows it, since the body is logged at debug level only. --- internal/requests/requests.go | 19 +++++++++++++++---- internal/requests/requests_test.go | 8 ++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 133f598fe..1e195aeae 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -388,10 +388,7 @@ func (c *Client) Do(p *RequestParams) (*HTTPResponse, error) { err := json.Unmarshal([]byte(body), &respBody) if err != nil { c.Logger.Debug("response body from %s (status %d):\n%s", req.URL, resp.StatusCode, string(body)) - // Still carry the status code so callers can distinguish e.g. a 404 - // with an empty or non-JSON body (a proxy/CDN page). Keep the - // (JSON parse) error text as the message. - return nil, &APIError{StatusCode: resp.StatusCode, Message: err.Error()} + return nil, &APIError{StatusCode: resp.StatusCode, Message: nonJSONErrorMessage(resp.StatusCode, body, c.Debug)} } cleanedErrorMessage := "" hasServerMessage := false @@ -550,6 +547,20 @@ func isSuccess(status int) bool { return false } +// nonJSONErrorMessage reports an error response whose body is not JSON, such as +// a proxy's HTML page, by its status: the decoder's complaint about the body +// tells the user nothing. +func nonJSONErrorMessage(status int, body []byte, debug bool) string { + switch { + case len(body) == 0: + return httpStatus(status) + case debug: + return httpStatus(status) + ": the response is not JSON" + default: + return httpStatus(status) + ": the response is not JSON, rerun with --debug to see it" + } +} + // giveUpErrorHandler keeps the last response's status, which retryablehttp's // default handler drops. The *url.Error that http.Client wraps it in names the // method and URL. diff --git a/internal/requests/requests_test.go b/internal/requests/requests_test.go index 158e169c1..e6932c953 100644 --- a/internal/requests/requests_test.go +++ b/internal/requests/requests_test.go @@ -323,7 +323,7 @@ func (suite *RequestsTestSuite) TestDo() { URL: suite.fakeService.ResolveURL("/no-content/"), }, wantError: true, - expectedErrorMsg: "unexpected end of JSON input", + expectedErrorMsg: "HTTP 204 No Content", }, { name: "206 is not a success: a partial body is not the answer", @@ -332,7 +332,7 @@ func (suite *RequestsTestSuite) TestDo() { URL: suite.fakeService.ResolveURL("/partial/"), }, wantError: true, - expectedErrorMsg: "unexpected end of JSON input", + expectedErrorMsg: "HTTP 206 Partial Content: the response is not JSON, rerun with --debug to see it", }, { name: "GET request to 404 endpoint", @@ -436,7 +436,7 @@ func (suite *RequestsTestSuite) TestDo() { URL: suite.fakeService.ResolveURL("/html"), }, wantError: true, - expectedErrorMsg: "unexpected end of JSON input", + expectedErrorMsg: "HTTP 404 Not Found", }, { name: "non-JSON error response body is logged with debug enabled", @@ -446,7 +446,7 @@ func (suite *RequestsTestSuite) TestDo() { }, debug: true, wantError: true, - expectedErrorMsg: "invalid character '<' looking for beginning of value", + expectedErrorMsg: "HTTP 403 Forbidden: the response is not JSON", expectedLog: `

403 Forbidden

Blocked by proxy

`, }, } { From bd6e126357583dbbca532aa91095ffe60c9e41af Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 6 Nov 2026 21:05:04 +0000 Subject: [PATCH 2/2] fix: treat a whitespace-only error body as empty Some proxies answer a 401 or 404 with a lone newline. That body counted as non-empty, so the user was told to rerun with --debug to see it, and --debug then showed nothing. The body is now trimmed before the check. Also renames the test case for an unrouted GET, which gets an empty 404 rather than the HTML body its old name described. --- internal/requests/requests.go | 2 +- internal/requests/requests_test.go | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 1e195aeae..f094b3336 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -552,7 +552,7 @@ func isSuccess(status int) bool { // tells the user nothing. func nonJSONErrorMessage(status int, body []byte, debug bool) string { switch { - case len(body) == 0: + case len(bytes.TrimSpace(body)) == 0: return httpStatus(status) case debug: return httpStatus(status) + ": the response is not JSON" diff --git a/internal/requests/requests_test.go b/internal/requests/requests_test.go index e6932c953..dd2bae34b 100644 --- a/internal/requests/requests_test.go +++ b/internal/requests/requests_test.go @@ -79,6 +79,10 @@ func (suite *RequestsTestSuite) SetupSuite() { Get("/fail/"). Reply(500). BodyString("server broken") + suite.fakeService.NewHandler(). + Get("/blank/"). + Reply(401). + BodyString("\r\n") suite.fakeService.NewHandler(). Get("/proxy-block/"). Reply(403). @@ -430,7 +434,7 @@ func (suite *RequestsTestSuite) TestDo() { expectedErrorMsg: "Denied", }, { - name: "GET request to a PUT endpoint fails because of invalid response", + name: "GET to an unrouted path reports the status of the empty 404", params: &RequestParams{ Method: http.MethodGet, URL: suite.fakeService.ResolveURL("/html"), @@ -438,6 +442,15 @@ func (suite *RequestsTestSuite) TestDo() { wantError: true, expectedErrorMsg: "HTTP 404 Not Found", }, + { + name: "a whitespace-only body reports the status alone", + params: &RequestParams{ + Method: http.MethodGet, + URL: suite.fakeService.ResolveURL("/blank/"), + }, + wantError: true, + expectedErrorMsg: "HTTP 401 Unauthorized", + }, { name: "non-JSON error response body is logged with debug enabled", params: &RequestParams{