diff --git a/.changeset/neat-doors-smile.md b/.changeset/neat-doors-smile.md new file mode 100644 index 000000000..ce92689e6 --- /dev/null +++ b/.changeset/neat-doors-smile.md @@ -0,0 +1,5 @@ +--- +'grafana-infinity-datasource': minor +--- + +Parse HTTP response content in the event of HTTP error and send it back to browser as feedback diff --git a/pkg/infinity/client.go b/pkg/infinity/client.go index fd43635f2..009d320ea 100644 --- a/pkg/infinity/client.go +++ b/pkg/infinity/client.go @@ -234,7 +234,7 @@ func (client *Client) req(ctx context.Context, url string, body io.Reader, setti return nil, http.StatusInternalServerError, duration, errorsource.DownstreamError(fmt.Errorf("invalid response received for the URL %s", url), false) } if res.StatusCode >= http.StatusBadRequest { - err = fmt.Errorf("%w. %s", ErrUnsuccessfulHTTPResponseStatus, res.Status) + err = ParseErrorResponse(res) // Infinity can query anything and users are responsible for ensuring that endpoint/auth is correct // therefore any incoming error is considered downstream return nil, res.StatusCode, duration, errorsource.DownstreamError(err, false) diff --git a/pkg/infinity/http_error.go b/pkg/infinity/http_error.go new file mode 100644 index 000000000..ff4caa434 --- /dev/null +++ b/pkg/infinity/http_error.go @@ -0,0 +1,79 @@ +package infinity + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strings" +) + +// HTTPResponseError represents an error response from an HTTP request. +type HTTPResponseError struct { + StatusCode int // HTTP status code + Message string // Extracted error message from the HTTP response if any + TraceID string // Extracted trace ID from the HTTP response if any +} + +// Error implements the error interface for HTTPResponseError. +func (h *HTTPResponseError) Error() string { + err := ErrUnsuccessfulHTTPResponseStatus + if h.StatusCode >= http.StatusBadRequest { + err = errors.Join(err, fmt.Errorf("HTTP status code: %d %s", h.StatusCode, http.StatusText(h.StatusCode))) + } + if h.Message != "" { + err = errors.Join(err, fmt.Errorf("Error message from HTTP response: %s", h.Message)) + } + if h.TraceID != "" { + err = errors.Join(err, fmt.Errorf("TraceID from HTTP response: %s", h.TraceID)) + } + return err.Error() +} + +// ParseErrorResponse parses the HTTP response and extracts relevant error information. +// It reads the response body and attempts to determine the error message and trace ID +// based on the content type of the response. It handles various content types such as +// JSON, plain text, and others. If the response body contains a recognizable error message +// or trace ID, it populates the HTTPResponseError struct with this information. +func ParseErrorResponse(res *http.Response) error { + err := &HTTPResponseError{} + if res == nil { + return err + } + err.StatusCode = res.StatusCode + bodyBytes, responseReadErr := io.ReadAll(res.Body) + if responseReadErr != nil { + return err + } + mediaType, _, _ := mime.ParseMediaType(res.Header.Get("Content-Type")) + mediaType = strings.ToLower(mediaType) + for _, key := range []string{"text/html", "text/xml", "application/html", "application/xml", "application/xhtml+xml", "application/rss+xml", "image/svg+xml", "application/octet-stream"} { + if strings.Contains(mediaType, key) { + return err + } + } + if strings.Contains(mediaType, "text/plain") { + if errMsg := strings.TrimSpace(string(bodyBytes)); errMsg != "" { + err.Message = errMsg + } + return err + } + var out map[string]any + unmarshalErr := json.Unmarshal(bodyBytes, &out) + if unmarshalErr != nil { + return err + } + for _, key := range []string{"trace_id", "traceId", "traceID"} { + if traceId, ok := out[key].(string); ok && traceId != "" { + err.TraceID = traceId + } + } + for _, key := range []string{"error", "message", "status"} { + if errMsg, ok := out[key].(string); ok && errMsg != "" { + err.Message = errMsg + } + } + return err +} diff --git a/pkg/infinity/http_error_test.go b/pkg/infinity/http_error_test.go new file mode 100644 index 000000000..8f3dadd3f --- /dev/null +++ b/pkg/infinity/http_error_test.go @@ -0,0 +1,107 @@ +package infinity_test + +import ( + "io" + "net/http" + "strings" + "testing" + + i "github.com/grafana/grafana-infinity-datasource/pkg/infinity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseErrorResponse(t *testing.T) { + tests := []struct { + name string + res *http.Response + wantErr string + }{ + { + name: "No response", + wantErr: "unsuccessful HTTP response", + }, + { + name: "Internal server error with plain text response and no mime header", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("foo")), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error", + }, + { + name: "Internal server error with text error message and mime header", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Header: map[string][]string{"Content-Type": {"text/plain"}}, + Body: io.NopCloser(strings.NewReader("A text error message")), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error\nError message from HTTP response: A text error message", + }, + { + name: "Internal server error with HTML content", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Header: map[string][]string{"Content-Type": {"text/html"}}, + Body: io.NopCloser(strings.NewReader("<>html")), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error", + }, + { + name: "Internal server error with empty JSON object", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("{}")), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error", + }, + { + name: "Internal server error with empty JSON array", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("[]")), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error", + }, + { + name: "Internal server error with JSON message", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{ "message" : "foo" }`)), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error\nError message from HTTP response: foo", + }, + { + name: "Internal server error with JSON traceId", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{ "traceId" : "bar" }`)), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error\nTraceID from HTTP response: bar", + }, + { + name: "Internal server error with JSON message and traceId", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{ "error" : "foo", "traceId" : "bar" }`)), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error\nError message from HTTP response: foo\nTraceID from HTTP response: bar", + }, + { + name: "Invalid JSON content response", + res: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{ "error" : "foo", "traceId" : "bar" `)), + }, + wantErr: "unsuccessful HTTP response\nHTTP status code: 500 Internal Server Error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotErr := i.ParseErrorResponse(tt.res) + require.NotNil(t, gotErr) + require.NotNil(t, tt.wantErr) + assert.Equal(t, tt.wantErr, gotErr.Error()) + }) + } +} diff --git a/pkg/testsuite/handler_querydata_errors_test.go b/pkg/testsuite/handler_querydata_errors_test.go index 32f39a1c8..ee0001991 100644 --- a/pkg/testsuite/handler_querydata_errors_test.go +++ b/pkg/testsuite/handler_querydata_errors_test.go @@ -42,8 +42,7 @@ func TestErrors(t *testing.T) { }, *client, map[string]string{}, backend.PluginContext{}) require.NotNil(t, res.Error) require.Equal(t, backend.ErrorSourceDownstream, res.ErrorSource) - require.ErrorIs(t, res.Error, infinity.ErrUnsuccessfulHTTPResponseStatus) - require.Equal(t, "error while performing the infinity query. unsuccessful HTTP response. 403 Forbidden", res.Error.Error()) + require.Equal(t, "error while performing the infinity query. unsuccessful HTTP response\nHTTP status code: 403 Forbidden", res.Error.Error()) }) t.Run("fail with incorrect response from server", func(t *testing.T) { server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/testsuite/handler_querydata_test.go b/pkg/testsuite/handler_querydata_test.go index 8a5916445..fd195ff1f 100644 --- a/pkg/testsuite/handler_querydata_test.go +++ b/pkg/testsuite/handler_querydata_test.go @@ -112,7 +112,7 @@ func TestAuthentication(t *testing.T) { metaData := res.Frames[0].Meta.Custom.(*infinity.CustomMeta) require.NotNil(t, res.Error) require.NotNil(t, metaData) - require.Equal(t, "unsuccessful HTTP response. 401 Unauthorized", metaData.Error) + require.Equal(t, "unsuccessful HTTP response\nHTTP status code: 401 Unauthorized\nError message from HTTP response: UnAuthorized", metaData.Error) require.Equal(t, http.StatusUnauthorized, metaData.ResponseCodeFromServer) }) }) @@ -352,7 +352,7 @@ func TestAuthentication(t *testing.T) { }`, "http://httpbin.org/digest-auth/auth/foo/bar/MD5")), }, *client, map[string]string{}, backend.PluginContext{}) require.NotNil(t, res.Error) - require.Equal(t, "error while performing the infinity query. unsuccessful HTTP response. 401 UNAUTHORIZED", res.Error.Error()) + require.Equal(t, "error while performing the infinity query. unsuccessful HTTP response\nHTTP status code: 401 Unauthorized", res.Error.Error()) }) t.Run("should fail with incorrect auth method", func(t *testing.T) { client, err := infinity.NewClient(context.TODO(), models.InfinitySettings{ @@ -370,7 +370,7 @@ func TestAuthentication(t *testing.T) { }`, "http://httpbin.org/digest-auth/auth/foo/bar/MD5")), }, *client, map[string]string{}, backend.PluginContext{}) require.NotNil(t, res.Error) - require.Equal(t, "error while performing the infinity query. unsuccessful HTTP response. 401 UNAUTHORIZED", res.Error.Error()) + require.Equal(t, "error while performing the infinity query. unsuccessful HTTP response\nHTTP status code: 401 Unauthorized", res.Error.Error()) }) }) }