Custom Error Pages (#1675)

* custom error pages
This commit is contained in:
Ben Parli 2017-06-30 16:04:18 -07:00 committed by Ludovic Fernandez
parent 2c976227dd
commit 121c057b90
10 changed files with 411 additions and 9 deletions

View file

@ -396,6 +396,39 @@ Here is an example of backends and servers definition:
- `backend2` will forward the traffic to two servers: `http://172.17.0.4:80"` with weight `1` and `http://172.17.0.5:80` with weight `2` using `drr` load-balancing strategy.
- a circuit breaker is added on `backend1` using the expression `NetworkErrorRatio() > 0.5`: watch error ratio over 10 second sliding window
## Custom Error pages
Custom error pages can be returned, in lieu of the default, according to frontend-configured ranges of HTTP Status codes.
In the example below, if a 503 status is returned from the frontend "website", the custom error page at http://2.3.4.5/503.html is returned with the actual status code set in the HTTP header.
Note, the 503.html page itself is not hosted on traefik, but some other infrastructure.
```toml
[frontends]
[frontends.website]
backend = "website"
[errors]
[error.network]
status = ["500-599"]
backend = "error"
query = "/{status}.html"
[frontends.website.routes.website]
rule = "Host: website.mydomain.com"
[backends]
[backends.website]
[backends.website.servers.website]
url = "https://1.2.3.4"
[backends.error]
[backends.error.servers.error]
url = "http://2.3.4.5"
```
In the above example, the error page rendered was based on the status code.
Instead, the query parameter can also be set to some generic error page like so: `query = "/500s.html"`
Now the 500s.html error page is returned for the configured code range.
The configured status code ranges are inclusive; that is, in the above example, the 500s.html page will be returned for status codes 500 through, and including, 599.
# Configuration
Træfik's configuration has two parts:

View file

@ -0,0 +1,69 @@
package main
import (
"net/http"
"os"
"os/exec"
"time"
"github.com/containous/traefik/integration/try"
"github.com/go-check/check"
checker "github.com/vdemeester/shakers"
)
// ErrorPagesSuite test suites (using libcompose)
type ErrorPagesSuite struct{ BaseSuite }
func (ep *ErrorPagesSuite) SetUpSuite(c *check.C) {
ep.createComposeProject(c, "error_pages")
ep.composeProject.Start(c)
}
func (ep *ErrorPagesSuite) TestSimpleConfiguration(c *check.C) {
errorPageHost := ep.composeProject.Container(c, "nginx2").NetworkSettings.IPAddress
backendHost := ep.composeProject.Container(c, "nginx1").NetworkSettings.IPAddress
file := ep.adaptFile(c, "fixtures/error_pages/simple.toml", struct {
Server1 string
Server2 string
}{backendHost, errorPageHost})
defer os.Remove(file)
cmd := exec.Command(traefikBinary, "--configFile="+file)
err := cmd.Start()
c.Assert(err, checker.IsNil)
defer cmd.Process.Kill()
frontendReq, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:80", nil)
c.Assert(err, checker.IsNil)
frontendReq.Host = "test.local"
err = try.Request(frontendReq, 2*time.Second, try.BodyContains("nginx"))
c.Assert(err, checker.IsNil)
}
func (ep *ErrorPagesSuite) TestErrorPage(c *check.C) {
errorPageHost := ep.composeProject.Container(c, "nginx2").NetworkSettings.IPAddress
backendHost := ep.composeProject.Container(c, "nginx1").NetworkSettings.IPAddress
//error.toml contains a mis-configuration of the backend host
file := ep.adaptFile(c, "fixtures/error_pages/error.toml", struct {
Server1 string
Server2 string
}{backendHost, errorPageHost})
defer os.Remove(file)
cmd := exec.Command(traefikBinary, "--configFile="+file)
err := cmd.Start()
c.Assert(err, checker.IsNil)
defer cmd.Process.Kill()
frontendReq, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:80", nil)
c.Assert(err, checker.IsNil)
frontendReq.Host = "test.local"
err = try.Request(frontendReq, 2*time.Second, try.BodyContains("An error occurred."))
c.Assert(err, checker.IsNil)
}

View file

@ -0,0 +1,27 @@
defaultEntryPoints = ["http"]
logLevel = "DEBUG"
[entryPoints]
[entryPoints.http]
address = ":80"
[file]
[backends]
[backends.backend1]
[backends.backend1.servers.server1]
url = "http://{{.Server1}}:8989474"
[backends.error]
[backends.error.servers.error]
url = "http://{{.Server2}}:80"
[frontends]
[frontends.frontend1]
passHostHeader = true
backend = "backend1"
[frontends.frontend1.routes.test_1]
rule = "Host:test.local"
[frontends.frontend1.errors]
[frontends.frontend1.errors.networks]
status = ["500-502", "503-599"]
backend = "error"
query = "/50x.html"

View file

@ -0,0 +1,27 @@
defaultEntryPoints = ["http"]
logLevel = "DEBUG"
[entryPoints]
[entryPoints.http]
address = ":80"
[file]
[backends]
[backends.backend1]
[backends.backend1.servers.server1]
url = "http://{{.Server1}}:80"
[backends.error]
[backends.error.servers.error]
url = "http://{{.Server2}}:80"
[frontends]
[frontends.frontend1]
passHostHeader = true
backend = "backend1"
[frontends.frontend1.routes.test_1]
rule = "Host:test.local"
[frontends.frontend1.errors]
[frontends.frontend1.errors.networks]
status = ["500-502", "503-599"]
backend = "error"
query = "/50x.html"

View file

@ -38,6 +38,7 @@ func init() {
check.Suite(&EurekaSuite{})
check.Suite(&AcmeSuite{})
check.Suite(&DynamoDBSuite{})
check.Suite(&ErrorPagesSuite{})
}
var traefikBinary = "../dist/traefik"

View file

@ -0,0 +1,4 @@
nginx1:
image: nginx:alpine
nginx2:
image: nginx:alpine

View file

@ -0,0 +1,77 @@
package middlewares
import (
"net/http"
"strconv"
"strings"
"github.com/containous/traefik/log"
"github.com/containous/traefik/types"
"github.com/vulcand/oxy/forward"
"github.com/vulcand/oxy/utils"
)
//ErrorPagesHandler is a middleware that provides the custom error pages
type ErrorPagesHandler struct {
HTTPCodeRanges [][2]int
BackendURL string
errorPageForwarder *forward.Forwarder
}
//NewErrorPagesHandler initializes the utils.ErrorHandler for the custom error pages
func NewErrorPagesHandler(errorPage types.ErrorPage, backendURL string) (*ErrorPagesHandler, error) {
fwd, err := forward.New()
if err != nil {
return nil, err
}
//Break out the http status code ranges into a low int and high int
//for ease of use at runtime
var blocks [][2]int
for _, block := range errorPage.Status {
codes := strings.Split(block, "-")
//if only a single HTTP code was configured, assume the best and create the correct configuration on the user's behalf
if len(codes) == 1 {
codes = append(codes, codes[0])
}
lowCode, err := strconv.Atoi(codes[0])
if err != nil {
return nil, err
}
highCode, err := strconv.Atoi(codes[1])
if err != nil {
return nil, err
}
blocks = append(blocks, [2]int{lowCode, highCode})
}
return &ErrorPagesHandler{
HTTPCodeRanges: blocks,
BackendURL: backendURL + errorPage.Query,
errorPageForwarder: fwd},
nil
}
func (ep *ErrorPagesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
recorder := newRetryResponseRecorder()
recorder.responseWriter = w
next.ServeHTTP(recorder, req)
w.WriteHeader(recorder.Code)
//check the recorder code against the configured http status code ranges
for _, block := range ep.HTTPCodeRanges {
if recorder.Code >= block[0] && recorder.Code <= block[1] {
log.Errorf("Caught HTTP Status Code %d, returning error page", recorder.Code)
finalURL := strings.Replace(ep.BackendURL, "{status}", strconv.Itoa(recorder.Code), -1)
if newReq, err := http.NewRequest(http.MethodGet, finalURL, nil); err != nil {
w.Write([]byte(http.StatusText(recorder.Code)))
} else {
ep.errorPageForwarder.ServeHTTP(w, newReq)
}
return
}
}
//did not catch a configured status code so proceed with the request
utils.CopyHeaders(w.Header(), recorder.Header())
w.Write(recorder.Body.Bytes())
}

View file

@ -0,0 +1,140 @@
package middlewares
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/codegangsta/negroni"
"github.com/containous/traefik/types"
"github.com/stretchr/testify/assert"
)
func TestErrorPage(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Test Server")
}))
defer ts.Close()
testErrorPage := &types.ErrorPage{Backend: "error", Query: "/test", Status: []string{"500-501", "503-599"}}
testHandler, err := NewErrorPagesHandler(*testErrorPage, ts.URL)
assert.Equal(t, nil, err, "Should be no error")
assert.Equal(t, testHandler.BackendURL, ts.URL+"/test", "Should be equal")
recorder := httptest.NewRecorder()
req, err := http.NewRequest("GET", ts.URL+"/test", nil)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "traefik")
})
n := negroni.New()
n.Use(testHandler)
n.UseHandler(handler)
n.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code, "HTTP statusOK")
assert.Contains(t, recorder.Body.String(), "traefik")
handler500 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
fmt.Fprintln(w, "oops")
})
recorder500 := httptest.NewRecorder()
n500 := negroni.New()
n500.Use(testHandler)
n500.UseHandler(handler500)
n500.ServeHTTP(recorder500, req)
assert.Equal(t, http.StatusInternalServerError, recorder500.Code, "HTTP status Internal Server Error")
assert.Contains(t, recorder500.Body.String(), "Test Server")
assert.NotContains(t, recorder500.Body.String(), "oops", "Should not return the oops page")
handler502 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(502)
fmt.Fprintln(w, "oops")
})
recorder502 := httptest.NewRecorder()
n502 := negroni.New()
n502.Use(testHandler)
n502.UseHandler(handler502)
n502.ServeHTTP(recorder502, req)
assert.Equal(t, http.StatusBadGateway, recorder502.Code, "HTTP status Bad Gateway")
assert.Contains(t, recorder502.Body.String(), "oops")
assert.NotContains(t, recorder502.Body.String(), "Test Server", "Should return the oops page since we have not configured the 502 code")
}
func TestErrorPageQuery(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.RequestURI() == "/"+strconv.Itoa(503) {
fmt.Fprintln(w, "503 Test Server")
} else {
fmt.Fprintln(w, "Failed")
}
}))
defer ts.Close()
testErrorPage := &types.ErrorPage{Backend: "error", Query: "/{status}", Status: []string{"503-503"}}
testHandler, err := NewErrorPagesHandler(*testErrorPage, ts.URL)
assert.Equal(t, nil, err, "Should be no error")
assert.Equal(t, testHandler.BackendURL, ts.URL+"/{status}", "Should be equal")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(503)
fmt.Fprintln(w, "oops")
})
recorder := httptest.NewRecorder()
req, err := http.NewRequest("GET", ts.URL+"/test", nil)
n := negroni.New()
n.Use(testHandler)
n.UseHandler(handler)
n.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code, "HTTP status Service Unavailable")
assert.Contains(t, recorder.Body.String(), "503 Test Server")
assert.NotContains(t, recorder.Body.String(), "oops", "Should not return the oops page")
}
func TestErrorPageSingleCode(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.RequestURI() == "/"+strconv.Itoa(503) {
fmt.Fprintln(w, "503 Test Server")
} else {
fmt.Fprintln(w, "Failed")
}
}))
defer ts.Close()
testErrorPage := &types.ErrorPage{Backend: "error", Query: "/{status}", Status: []string{"503"}}
testHandler, err := NewErrorPagesHandler(*testErrorPage, ts.URL)
assert.Equal(t, nil, err, "Should be no error")
assert.Equal(t, testHandler.BackendURL, ts.URL+"/{status}", "Should be equal")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(503)
fmt.Fprintln(w, "oops")
})
recorder := httptest.NewRecorder()
req, err := http.NewRequest("GET", ts.URL+"/test", nil)
n := negroni.New()
n.Use(testHandler)
n.UseHandler(handler)
n.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code, "HTTP status Service Unavailable")
assert.Contains(t, recorder.Body.String(), "503 Test Server")
assert.NotContains(t, recorder.Body.String(), "oops", "Should not return the oops page")
}

View file

@ -772,6 +772,22 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo
backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(*hcOpts)
}
}
if len(frontend.Errors) > 0 {
for _, errorPage := range frontend.Errors {
if configuration.Backends[errorPage.Backend] != nil && configuration.Backends[errorPage.Backend].Servers["error"].URL != "" {
errorPageHandler, err := middlewares.NewErrorPagesHandler(errorPage, configuration.Backends[errorPage.Backend].Servers["error"].URL)
if err != nil {
log.Errorf("Error creating custom error page middleware, %v", err)
} else {
negroni.Use(errorPageHandler)
}
} else {
log.Errorf("Error Page is configured for Frontend %s, but either Backend %s is not set or Backend URL is missing", frontendName, errorPage.Backend)
}
}
}
maxConns := configuration.Backends[frontend.Backend].MaxConn
if maxConns != nil && maxConns.Amount != 0 {
extractFunc, err := utils.NewExtractor(maxConns.ExtractorFunc)

View file

@ -54,6 +54,13 @@ type Route struct {
Rule string `json:"rule,omitempty"`
}
//ErrorPage holds custom error page configuration
type ErrorPage struct {
Status []string `json:"status,omitempty"`
Backend string `json:"backend,omitempty"`
Query string `json:"query,omitempty"`
}
// Headers holds the custom header configuration
type Headers struct {
CustomRequestHeaders map[string]string `json:"customRequestHeaders,omitempty"`
@ -108,15 +115,16 @@ func (h Headers) HasSecureHeadersDefined() bool {
// Frontend holds frontend configuration.
type Frontend struct {
EntryPoints []string `json:"entryPoints,omitempty"`
Backend string `json:"backend,omitempty"`
Routes map[string]Route `json:"routes,omitempty"`
PassHostHeader bool `json:"passHostHeader,omitempty"`
PassTLSCert bool `json:"passTLSCert,omitempty"`
Priority int `json:"priority"`
BasicAuth []string `json:"basicAuth"`
WhitelistSourceRange []string `json:"whitelistSourceRange,omitempty"`
Headers Headers `json:"headers,omitempty"`
EntryPoints []string `json:"entryPoints,omitempty"`
Backend string `json:"backend,omitempty"`
Routes map[string]Route `json:"routes,omitempty"`
PassHostHeader bool `json:"passHostHeader,omitempty"`
PassTLSCert bool `json:"passTLSCert,omitempty"`
Priority int `json:"priority"`
BasicAuth []string `json:"basicAuth"`
WhitelistSourceRange []string `json:"whitelistSourceRange,omitempty"`
Headers Headers `json:"headers,omitempty"`
Errors map[string]ErrorPage `json:"errors,omitempty"`
}
// LoadBalancerMethod holds the method of load balancing to use.