Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

parse error content #1076

Draft
wants to merge 2 commits into
base: dev-3.0.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"} {
yesoreyeram marked this conversation as resolved.
Show resolved Hide resolved
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
Loading