Skip to content

Commit

Permalink
parse error content
Browse files Browse the repository at this point in the history
  • Loading branch information
yesoreyeram committed Dec 1, 2024
1 parent 4458dfa commit 2df5b51
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/neat-doors-smile.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pkg/infinity/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions pkg/infinity/http_error.go
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions pkg/infinity/http_error_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
3 changes: 1 addition & 2 deletions pkg/testsuite/handler_querydata_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions pkg/testsuite/handler_querydata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Expand Down Expand Up @@ -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{
Expand All @@ -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())
})
})
}
Expand Down

0 comments on commit 2df5b51

Please sign in to comment.