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

Change error responses from plain text to JSON #4845

Merged
merged 4 commits into from
Dec 2, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Main

* [4845](https://github.com/grafana/loki/pull/4845) **chaudum** Return error responses consistently as JSON
* [4826](https://github.com/grafana/loki/pull/4826) **cyriltovena**: Adds the ability to hedge storage requests.
* [4828](https://github.com/grafana/loki/pull/4282) **chaudum**: Set correct `Content-Type` header in query response
* [4832](https://github.com/grafana/loki/pull/4832) **taisho6339**: Use http prefix path correctly in Promtail
Expand Down
20 changes: 20 additions & 0 deletions docs/sources/upgrading/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,26 @@ The output is incredibly verbose as it shows the entire internal config struct u

## Main / Unreleased

### Loki

#### Error responses from API

The body of HTTP error responses from API endpoints changed from plain text to
JSON. The `Content-Type` header was previously already set incorrectly to
`application/json`. Therefore returning JSON fixes this incosistency

The response body has the following schema:

```json
{
"code": <http status code>,
"message": "<error message>",
"status": "error"
}
```

"404 Not Found" errors still return responses with content type `text/plain`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we special-case 404s? This is going to make writing Loki clients complex.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

404 errors are handled differently than errors that occur in our handlers. However, it should be possible to override the NotFoundHandler of the router.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you look into that please? We should really try have consistency across the board if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was actually pretty easy to achieve: 4820322


### Promtail

#### `gcplog` labels have changed
Expand Down
7 changes: 4 additions & 3 deletions pkg/distributor/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/weaveworks/common/httpgrpc"

"github.com/grafana/loki/pkg/loghttp/push"
serverutil "github.com/grafana/loki/pkg/util/server"
)

// PushHandler reads a snappy-compressed proto from the HTTP body.
Expand All @@ -25,7 +26,7 @@ func (d *Distributor) PushHandler(w http.ResponseWriter, r *http.Request) {
"err", err,
)
}
http.Error(w, err.Error(), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, err.Error())
return
}

Expand Down Expand Up @@ -61,7 +62,7 @@ func (d *Distributor) PushHandler(w http.ResponseWriter, r *http.Request) {
"err", body,
)
}
http.Error(w, body, int(resp.Code))
serverutil.JSONError(w, int(resp.Code), body)
} else {
if d.tenantConfigs.LogPushRequest(userID) {
level.Debug(logger).Log(
Expand All @@ -70,6 +71,6 @@ func (d *Distributor) PushHandler(w http.ResponseWriter, r *http.Request) {
"err", err.Error(),
)
}
http.Error(w, err.Error(), http.StatusInternalServerError)
serverutil.JSONError(w, http.StatusInternalServerError, err.Error())
}
}
31 changes: 9 additions & 22 deletions pkg/lokifrontend/frontend/transport/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@ import (
"strings"
"time"

querier_stats "github.com/cortexproject/cortex/pkg/querier/stats"
"github.com/cortexproject/cortex/pkg/tenant"
"github.com/cortexproject/cortex/pkg/util"
util_log "github.com/cortexproject/cortex/pkg/util/log"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/weaveworks/common/httpgrpc"
"github.com/weaveworks/common/httpgrpc/server"

querier_stats "github.com/cortexproject/cortex/pkg/querier/stats"
"github.com/cortexproject/cortex/pkg/tenant"
"github.com/cortexproject/cortex/pkg/util"
util_log "github.com/cortexproject/cortex/pkg/util/log"
serverutil "github.com/grafana/loki/pkg/util/server"
)

const (
Expand All @@ -32,12 +31,6 @@ const (
ServiceTimingHeaderName = "Server-Timing"
)

var (
errCanceled = httpgrpc.Errorf(StatusClientClosedRequest, context.Canceled.Error())
errDeadlineExceeded = httpgrpc.Errorf(http.StatusGatewayTimeout, context.DeadlineExceeded.Error())
errRequestEntityTooLarge = httpgrpc.Errorf(http.StatusRequestEntityTooLarge, "http: request body too large")
)

// Config for a Handler.
type HandlerConfig struct {
LogQueriesLongerThan time.Duration `yaml:"log_queries_longer_than"`
Expand Down Expand Up @@ -226,17 +219,11 @@ func formatQueryString(queryString url.Values) (fields []interface{}) {
}

func writeError(w http.ResponseWriter, err error) {
switch err {
case context.Canceled:
err = errCanceled
case context.DeadlineExceeded:
err = errDeadlineExceeded
default:
if util.IsRequestBodyTooLarge(err) {
err = errRequestEntityTooLarge
}
if util.IsRequestBodyTooLarge(err) {
serverutil.JSONError(w, http.StatusRequestEntityTooLarge, "http: request body too large")
return
}
server.WriteError(w, err)
serverutil.WriteError(err, w)
}

func writeServiceTimingHeader(queryResponseTime time.Duration, headers http.Header, stats *querier_stats.Stats) {
Expand Down
40 changes: 20 additions & 20 deletions pkg/storage/stores/shipper/compactor/deletion/request_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ package deletion

import (
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/cortexproject/cortex/pkg/tenant"
"github.com/cortexproject/cortex/pkg/util"
util_log "github.com/cortexproject/cortex/pkg/util/log"
"github.com/go-kit/log/level"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql/parser"

"github.com/cortexproject/cortex/pkg/tenant"
"github.com/cortexproject/cortex/pkg/util"
util_log "github.com/cortexproject/cortex/pkg/util/log"
serverutil "github.com/grafana/loki/pkg/util/server"
)

// DeleteRequestHandler provides handlers for delete requests
Expand All @@ -39,21 +39,21 @@ func (dm *DeleteRequestHandler) AddDeleteRequestHandler(w http.ResponseWriter, r
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, err.Error())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too familiar with how HTTP errors are handled, but are they not all handled pkg/util/server/error.go -> WriteError? If we use http.Error here does it kill the request and write this error directly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WriteError handles generic errors as Internal Server Error. Therefore we bypass WriteError and call JSONError directly, so we can specify a status code and an error message.

http.Error sets the status code, content type header (text/plain) and writes the response body. Usually you want to return thereafter.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK thanks for that. I was looking for a way that we could somehow add middleware to catch these http.Error calls, but they write directly to the http.ResponseWriter :(

I would've loved to have stuck with idiomatic gorilla/mux & net/http, but it seems we don't have a good layer of indirection here.

What you've got here looks like a good solution 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An option to catch and resolve errors in a middleware would be, to write an occurring error to the request context and check for it in the middleware handler. I don't think that is very idiomatic, though. Writing the error to the response writer seems to be the common way.

return
}

params := r.URL.Query()
match := params["match[]"]
if len(match) == 0 {
http.Error(w, "selectors not set", http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, "selectors not set")
return
}

for i := range match {
_, err := parser.ParseMetricSelector(match[i])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, err.Error())
return
}
}
Expand All @@ -63,7 +63,7 @@ func (dm *DeleteRequestHandler) AddDeleteRequestHandler(w http.ResponseWriter, r
if startParam != "" {
startTime, err = util.ParseTime(startParam)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, err.Error())
return
}
}
Expand All @@ -74,12 +74,12 @@ func (dm *DeleteRequestHandler) AddDeleteRequestHandler(w http.ResponseWriter, r
if endParam != "" {
endTime, err = util.ParseTime(endParam)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, err.Error())
return
}

if endTime > int64(model.Now()) {
http.Error(w, "deletes in future not allowed", http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, "deletes in future not allowed")
return
}
}
Expand All @@ -91,7 +91,7 @@ func (dm *DeleteRequestHandler) AddDeleteRequestHandler(w http.ResponseWriter, r

if err := dm.deleteRequestsStore.AddDeleteRequest(ctx, userID, model.Time(startTime), model.Time(endTime), match); err != nil {
level.Error(util_log.Logger).Log("msg", "error adding delete request to the store", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
serverutil.JSONError(w, http.StatusInternalServerError, err.Error())
return
}

Expand All @@ -104,20 +104,20 @@ func (dm *DeleteRequestHandler) GetAllDeleteRequestsHandler(w http.ResponseWrite
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, err.Error())
return
}

deleteRequests, err := dm.deleteRequestsStore.GetAllDeleteRequestsForUser(ctx, userID)
if err != nil {
level.Error(util_log.Logger).Log("msg", "error getting delete requests from the store", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
serverutil.JSONError(w, http.StatusInternalServerError, err.Error())
return
}

if err := json.NewEncoder(w).Encode(deleteRequests); err != nil {
level.Error(util_log.Logger).Log("msg", "error marshalling response", "err", err)
http.Error(w, fmt.Sprintf("Error marshalling response: %v", err), http.StatusInternalServerError)
serverutil.JSONError(w, http.StatusInternalServerError, "error marshalling response: %v", err)
}
}

Expand All @@ -126,7 +126,7 @@ func (dm *DeleteRequestHandler) CancelDeleteRequestHandler(w http.ResponseWriter
ctx := r.Context()
userID, err := tenant.TenantID(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, err.Error())
return
}

Expand All @@ -136,28 +136,28 @@ func (dm *DeleteRequestHandler) CancelDeleteRequestHandler(w http.ResponseWriter
deleteRequest, err := dm.deleteRequestsStore.GetDeleteRequest(ctx, userID, requestID)
if err != nil {
level.Error(util_log.Logger).Log("msg", "error getting delete request from the store", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
serverutil.JSONError(w, http.StatusInternalServerError, err.Error())
return
}

if deleteRequest == nil {
http.Error(w, "could not find delete request with given id", http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, "could not find delete request with given id")
return
}

if deleteRequest.Status != StatusReceived {
http.Error(w, "deletion of request which is in process or already processed is not allowed", http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, "deletion of request which is in process or already processed is not allowed")
return
}

if deleteRequest.CreatedAt.Add(dm.deleteRequestCancelPeriod).Before(model.Now()) {
http.Error(w, fmt.Sprintf("deletion of request past the deadline of %s since its creation is not allowed", dm.deleteRequestCancelPeriod.String()), http.StatusBadRequest)
serverutil.JSONError(w, http.StatusBadRequest, "deletion of request past the deadline of %s since its creation is not allowed", dm.deleteRequestCancelPeriod.String())
return
}

if err := dm.deleteRequestsStore.RemoveDeleteRequest(ctx, userID, requestID, deleteRequest.CreatedAt, deleteRequest.StartTime, deleteRequest.EndTime); err != nil {
level.Error(util_log.Logger).Log("msg", "error cancelling the delete request", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
serverutil.JSONError(w, http.StatusInternalServerError, err.Error())
return
}

Expand Down
40 changes: 28 additions & 12 deletions pkg/util/server/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package server

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"

"google.golang.org/grpc/codes"
Expand All @@ -26,6 +28,22 @@ const (
ErrDeadlineExceeded = "Request timed out, decrease the duration of the request or add more label matchers (prefer exact match over regex match) to reduce the amount of data processed."
)

type ErrorResponseBody struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
}

func JSONError(w http.ResponseWriter, code int, message string, args ...interface{}) {
w.WriteHeader(code)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(ErrorResponseBody{
Code: code,
Status: "error",
Message: fmt.Sprintf(message, args...),
})
}

// WriteError write a go error with the correct status code.
func WriteError(err error, w http.ResponseWriter) {
var (
Expand All @@ -35,11 +53,11 @@ func WriteError(err error, w http.ResponseWriter) {

me, ok := err.(util.MultiError)
if ok && me.IsCancel() {
http.Error(w, ErrClientCanceled, StatusClientClosedRequest)
JSONError(w, StatusClientClosedRequest, ErrClientCanceled)
return
}
if ok && me.IsDeadlineExceeded() {
http.Error(w, ErrDeadlineExceeded, http.StatusGatewayTimeout)
JSONError(w, http.StatusGatewayTimeout, ErrDeadlineExceeded)
return
}

Expand All @@ -48,21 +66,19 @@ func WriteError(err error, w http.ResponseWriter) {
case errors.Is(err, context.Canceled) ||
(isRPC && s.Code() == codes.Canceled) ||
(errors.As(err, &promErr) && errors.Is(promErr.Err, context.Canceled)):
http.Error(w, ErrClientCanceled, StatusClientClosedRequest)
JSONError(w, StatusClientClosedRequest, ErrClientCanceled)
case errors.Is(err, context.DeadlineExceeded) ||
(isRPC && s.Code() == codes.DeadlineExceeded):
http.Error(w, ErrDeadlineExceeded, http.StatusGatewayTimeout)
case errors.As(err, &queryErr):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, logqlmodel.ErrLimit) || errors.Is(err, logqlmodel.ErrParse) || errors.Is(err, logqlmodel.ErrPipeline):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, user.ErrNoOrgID):
http.Error(w, err.Error(), http.StatusBadRequest)
JSONError(w, http.StatusGatewayTimeout, ErrDeadlineExceeded)
case errors.As(err, &queryErr),
errors.Is(err, logqlmodel.ErrLimit) || errors.Is(err, logqlmodel.ErrParse) || errors.Is(err, logqlmodel.ErrPipeline),
errors.Is(err, user.ErrNoOrgID):
JSONError(w, http.StatusBadRequest, err.Error())
default:
if grpcErr, ok := httpgrpc.HTTPResponseFromError(err); ok {
http.Error(w, string(grpcErr.Body), int(grpcErr.Code))
JSONError(w, int(grpcErr.Code), string(grpcErr.Body))
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
JSONError(w, http.StatusInternalServerError, err.Error())
}
}
11 changes: 5 additions & 6 deletions pkg/util/server/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package server

import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -54,12 +54,11 @@ func Test_writeError(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
WriteError(tt.err, rec)
res := &ErrorResponseBody{}
json.NewDecoder(rec.Result().Body).Decode(res)
require.Equal(t, tt.expectedStatus, res.Code)
chaudum marked this conversation as resolved.
Show resolved Hide resolved
require.Equal(t, tt.expectedStatus, rec.Result().StatusCode)
b, err := ioutil.ReadAll(rec.Result().Body)
if err != nil {
t.Fatal(err)
}
require.Equal(t, tt.msg, string(b[:len(b)-1]))
require.Equal(t, tt.msg, res.Message)
})
}
}