Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
57 changes: 55 additions & 2 deletions echo/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
package echo

import (
"net"
"net/http"
"net/http/httptest"
"testing"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -27,7 +28,7 @@ import (
type helloRegister struct{}

func (h *helloRegister) RegisterRoute(e *echo.Echo) {
e.GET("/hello", func(c echo.Context) error {
e.GET("/hello", func(c *echo.Context) error {
return c.NoContent(http.StatusOK)
})
}
Expand Down Expand Up @@ -90,3 +91,55 @@ func TestBuilder_MetricsRegistrationError(t *testing.T) {
Build()
assert.Error(t, err)
}

type clientRegister struct{}

func (r *clientRegister) RegisterRoute(e *echo.Echo) {
e.GET("/client", func(c *echo.Context) error {
return c.String(http.StatusOK, c.Scheme()+" "+c.RealIP())
})
}

func TestBuilder_IPAndSchemeExtractors(t *testing.T) {
_, proxyNetwork, err := net.ParseCIDR("203.0.113.0/24")
require.NoError(t, err)
tests := []struct {
name string
configure func(b *Builder)
// expected is the scheme and the IP address of the client, as seen by the handler.
expected string
}{
{
// The forwarding headers can be spoofed by any client, so they are not trusted from a public address by default.
name: "default",
configure: func(*Builder) {},
expected: "http 203.0.113.10",
},
{
name: "trusted proxy",
configure: func(b *Builder) {
b.IPExtractor(echo.ExtractIPFromXFFHeader(echo.TrustIPRange(proxyNetwork))).
SchemeExtractor(echo.ExtractSchemeFromHeaders(echo.TrustIPRange(proxyNetwork)))
},
expected: "https 198.51.100.7",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := NewBuilder().APIRegistration(&clientRegister{}).OverrideDefaultMiddleware(true)
tt.configure(builder)
handler, err := builder.BuildHandler()
require.NoError(t, err)

// The request comes from a proxy with a public address (like a cloud load balancer).
req := httptest.NewRequest(http.MethodGet, "/client", nil)
req.RemoteAddr = "203.0.113.10:1234"
req.Header.Set(echo.HeaderXForwardedFor, "198.51.100.7")
req.Header.Set(echo.HeaderXForwardedProto, "https")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, tt.expected, rec.Body.String())
})
}
}
85 changes: 85 additions & 0 deletions echo/error_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package echo

import (
"context"
"errors"

"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/sirupsen/logrus"

persesMiddleware "github.com/perses/common/echo/middleware"
)

var defaultHTTPErrorHandler = echo.DefaultHTTPErrorHandler(false)

// contextKeyRequestContext is the key of the echo context in which saveRequestContext saves the context of the request.
const contextKeyRequestContext = "github.com/perses/common/echo.requestContext"

// saveRequestContext is a middleware, executed before every other one, that saves the context of the request as received
// by the server. The middlewares and the handlers can replace the context of the request (like the middleware ContextTimeout,
// which cancels its own context once the request is handled), so only this one tells if the client went away.
func saveRequestContext(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
c.Set(contextKeyRequestContext, c.Request().Context())
return next(c)
}
}

// requestContext returns the context of the request as received by the server (see saveRequestContext).
func requestContext(c *echo.Context) context.Context {
if ctx, ok := c.Get(contextKeyRequestContext).(context.Context); ok {
return ctx
}
return c.Request().Context()
}

// handleError is the central error handler of the server, called with the error returned by the middleware chain.
// The panics recovered by the middleware Recover and the errors that don't carry an HTTP status (unexpected errors)
// are logged, as nothing turned them into a response. Then, like the default echo handler, it writes the error as a JSON
// response without exposing the internal errors.
func handleError(c *echo.Context, err error) {
// A panic is checked first: its value can carry an HTTP status (like panic(echo.NewHTTPError(...))).
if panicErr, ok := errors.AsType[*middleware.PanicStackError](err); ok {
requestLogEntry(c).WithError(panicErr.Err).WithField("stack", string(panicErr.Stack)).Error("panic recovered while handling the request")
} else if echo.StatusCode(err) == 0 {
requestLogEntry(c).WithError(err).Log(unexpectedErrorLevel(c, err), "unexpected error while handling the request")
}
defaultHTTPErrorHandler(c, err)
}

// requestLogEntry returns a log entry describing the request like the access log: with the method and the escaped path
// of the request as received, as the handlers can rewrite them (see persesMiddleware.RequestMethod and RequestPath).
func requestLogEntry(c *echo.Context) *logrus.Entry {
return logrus.WithFields(logrus.Fields{
"method": persesMiddleware.RequestMethod(c),
"uri": persesMiddleware.RequestPath(c),
})
}

// unexpectedErrorLevel returns the level of the log of an unexpected error.
// The error is less severe when it is caused by the client that went away, or when the response is already sent.
func unexpectedErrorLevel(c *echo.Context, err error) logrus.Level {
if errors.Is(err, context.Canceled) && requestContext(c).Err() != nil {
// The request is canceled when the client closes the connection, and the error is caused by this cancellation.
return logrus.DebugLevel
}
if response, _ := echo.UnwrapResponse(c.Response()); response != nil && response.Committed {
// The client already received the status of the response, which the error cannot change anymore.
return logrus.WarnLevel
}
return logrus.ErrorLevel
}
Loading
Loading