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
26 changes: 23 additions & 3 deletions internal/errorpages/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,38 @@ func Handler() http.Handler {
})

mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
status, page := pageFor(strings.TrimPrefix(req.URL.Path, "/"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
status, body := pageFor(strings.TrimPrefix(req.URL.Path, "/"))
contentType := "text/html; charset=utf-8"
if !wantsHTML(req) {
contentType, body = "application/json; charset=utf-8", errorJSON(status)
}
w.Header().Set("Content-Type", contentType)
// These pages must never be cached: the same URL serves real content
// again as soon as the workload recovers.
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write([]byte(page))
_, _ = w.Write([]byte(body))
})

return mux
}

// wantsHTML reports whether the caller is a browser navigation. Traefik's errors
// middleware copies the original request's headers onto the request it makes
// here, so Accept still describes the real client. The match is on an explicit
// text/html: axios sends "application/json, text/plain, */*", so matching */*
// too would hand every API client an HTML body it cannot parse.
func wantsHTML(req *http.Request) bool {
return strings.Contains(req.Header.Get("Accept"), "text/html")
}

// errorJSON renders the failure as the API error envelope the dashboard already
// parses, so a 5xx Traefik swapped out reaches the UI in the shape a real API
// error would. StatusText is ASCII from a fixed table, so %q is enough quoting.
func errorJSON(status int) string {
return fmt.Sprintf(`{"type":"Error","reason":%q}`, http.StatusText(status))
}

// pageFor resolves a request path to a status code and page body. An
// unrecognised path is a catch-all 404; a status with no page of its own falls
// back to the 500 page while keeping its real status code.
Expand Down
36 changes: 35 additions & 1 deletion internal/errorpages/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ var _ = Describe("Handler", func() {

BeforeEach(func() { handler = Handler() })

// The pages are what a browser gets, so the shared helper asks for HTML the
// way one does. The content-negotiation specs below set Accept themselves.
get := func(path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Accept", "text/html")
handler.ServeHTTP(rec, req)
return rec
}

Expand Down Expand Up @@ -56,6 +60,36 @@ var _ = Describe("Handler", func() {
Expect(get("/some/deep/path").Body.String()).To(Equal(Assets["404.html"]))
})

// The middleware wraps every router on both entrypoints, so the dashboard's
// own /api/v1 calls reach these pages too. Traefik copies the caller's
// headers onto the request it makes here, so Accept picks the body shape.
DescribeTable("picks the body shape from the caller's Accept header",
func(accept, wantType, wantBody string) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/500", nil)
if accept != "" {
req.Header.Set("Accept", accept)
}
Handler().ServeHTTP(rec, req)

Expect(rec.Code).To(Equal(http.StatusInternalServerError))
Expect(rec.Header().Get("Content-Type")).To(ContainSubstring(wantType))
Expect(rec.Body.String()).To(Equal(wantBody))
},
Entry("a browser navigation gets the page",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"text/html", Assets["500.html"]),
// The */* here is why the match is on an explicit text/html: treating
// */* as "browser" would hand axios an HTML body it cannot parse.
Entry("axios gets the API error envelope",
"application/json, text/plain, */*",
"application/json", `{"type":"Error","reason":"Internal Server Error"}`),
// Browsers always name text/html when navigating, so no Accept at all
// is a plain HTTP client.
Entry("a client sending no Accept gets the envelope",
"", "application/json", `{"type":"Error","reason":"Internal Server Error"}`),
)

It("answers its own health check without a page body", func() {
rec := get("/healthz")

Expand Down
Loading