Skip to content

Commit

Permalink
Merge pull request #11 from basecamp/html-errors
Browse files Browse the repository at this point in the history
HTML error responses
  • Loading branch information
kevinmcconnell authored Aug 30, 2024
2 parents 2508d11 + 191af6a commit 3002f5e
Show file tree
Hide file tree
Showing 17 changed files with 465 additions and 105 deletions.
1 change: 1 addition & 0 deletions internal/cmd/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func newStopCommand() *stopCommand {
}

stopCommand.cmd.Flags().DurationVar(&stopCommand.args.DrainTimeout, "drain-timeout", server.DefaultDrainTimeout, "How long to allow in-flight requests to complete")
stopCommand.cmd.Flags().StringVar(&stopCommand.args.Message, "message", server.DefaultStopMessage, "Message to display to clients while stopped")

return stopCommand
}
Expand Down
3 changes: 2 additions & 1 deletion internal/server/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type PauseArgs struct {
type StopArgs struct {
Service string
DrainTimeout time.Duration
Message string
}

type ResumeArgs struct {
Expand Down Expand Up @@ -121,7 +122,7 @@ func (h *CommandHandler) Pause(args PauseArgs, reply *bool) error {
}

func (h *CommandHandler) Stop(args StopArgs, reply *bool) error {
return h.router.StopService(args.Service, args.DrainTimeout)
return h.router.StopService(args.Service, args.DrainTimeout, args.Message)
}

func (h *CommandHandler) Resume(args ResumeArgs, reply *bool) error {
Expand Down
94 changes: 94 additions & 0 deletions internal/server/error_page_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package server

import (
"context"
"embed"
"fmt"
"html/template"
"log/slog"
"net/http"
)

var (
//go:embed pages
pages embed.FS

contextKeyErrorResponse = contextKey("error-response")
)

type errorResponseContent struct {
StatusCode int
TemplateArguments any
}

type ErrorPageMiddleware struct {
template *template.Template
next http.Handler
}

func WithErrorPageMiddleware(next http.Handler) http.Handler {
template, err := template.ParseFS(pages, "pages/*.html")
if err != nil {
slog.Error("Failed to parse error page templates", "error", err)
template = nil
}

return &ErrorPageMiddleware{
template: template,
next: next,
}
}

func SetErrorResponse(w http.ResponseWriter, r *http.Request, statusCode int, templateArguments any) {
errorResponse, ok := r.Context().Value(contextKeyErrorResponse).(*errorResponseContent)
if ok {
errorResponse.StatusCode = statusCode
errorResponse.TemplateArguments = templateArguments
} else {
http.Error(w, http.StatusText(statusCode), statusCode)
}
}

func (h *ErrorPageMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var errorResponseContext errorResponseContent
ctx := context.WithValue(r.Context(), contextKeyErrorResponse, &errorResponseContext)
r = r.WithContext(ctx)

h.next.ServeHTTP(w, r)

if errorResponseContext.StatusCode != 0 {
h.respondWithErrorPage(w, errorResponseContext.StatusCode, errorResponseContext.TemplateArguments)
}
}

// Private

func (h *ErrorPageMiddleware) respondWithErrorPage(w http.ResponseWriter, statusCode int, templateArguments any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusCode)

template := h.getTemplate(statusCode)
if template == nil {
slog.Error("Failed to render error page due to missing template", "status", statusCode)
h.writeErrorWithoutTemplate(w, statusCode)
return
}

err := template.Execute(w, templateArguments)
if err != nil {
slog.Error("Failed to render error page template", "name", template.Name, "error", err)
h.writeErrorWithoutTemplate(w, statusCode)
}
}

func (h *ErrorPageMiddleware) getTemplate(statusCode int) *template.Template {
if h.template == nil {
return nil
}

return h.template.Lookup(fmt.Sprintf("%d.html", statusCode))
}

func (h *ErrorPageMiddleware) writeErrorWithoutTemplate(w http.ResponseWriter, statusCode int) {
fmt.Fprintf(w, "<h1>%d %s</h1>", statusCode, http.StatusText(statusCode))
}
63 changes: 63 additions & 0 deletions internal/server/error_page_middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package server

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

"github.com/stretchr/testify/assert"
)

func TestErrorPageMiddleware(t *testing.T) {
check := func(handler http.HandlerFunc) (int, string, string) {
middleware := WithErrorPageMiddleware(handler)

req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()

middleware.ServeHTTP(resp, req)

return resp.Result().StatusCode, resp.Header().Get("Content-Type"), resp.Body.String()
}

t.Run("When setting a custom error response", func(t *testing.T) {
status, contentType, body := check(func(w http.ResponseWriter, r *http.Request) {
SetErrorResponse(w, r, http.StatusNotFound, nil)
})

assert.Equal(t, http.StatusNotFound, status)
assert.Equal(t, "text/html; charset=utf-8", contentType)
assert.Regexp(t, "Not Found", body)
})

t.Run("When including template arguments in a custom error response", func(t *testing.T) {
status, contentType, body := check(func(w http.ResponseWriter, r *http.Request) {
SetErrorResponse(w, r, http.StatusServiceUnavailable, struct{ Message string }{"Gone to lunch"})
})

assert.Equal(t, http.StatusServiceUnavailable, status)
assert.Equal(t, "text/html; charset=utf-8", contentType)
assert.Regexp(t, "Service Temporarily Unavailable", body)
assert.Regexp(t, "Gone to lunch", body)
})

t.Run("When trying to set an error that we don't have a template for", func(t *testing.T) {
status, contentType, body := check(func(w http.ResponseWriter, r *http.Request) {
SetErrorResponse(w, r, http.StatusTeapot, nil)
})

assert.Equal(t, http.StatusTeapot, status)
assert.Equal(t, "text/html; charset=utf-8", contentType)
assert.Regexp(t, "I'm a teapot", body)
})

t.Run("When the backend returns an error normally", func(t *testing.T) {
status, contentType, body := check(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
})

assert.Equal(t, http.StatusTeapot, status)
assert.Equal(t, "text/plain; charset=utf-8", contentType)
assert.Regexp(t, "I'm a teapot", body)
})
}
33 changes: 33 additions & 0 deletions internal/server/pages/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Not Found</title>

<style>
body {
font-family: system-ui, sans-serif;
font-size: 16px;
display: grid;
height: 80vh;
place-items: center;

background-color: #fff;
color: #333;
}

@media (prefers-color-scheme: dark) {
body {
background-color: #333;
color: #fff;
}
}
</style>
</head>

<body>
<h1>Not Found</h1>
</body>
</html>
33 changes: 33 additions & 0 deletions internal/server/pages/413.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Request Too Large</title>

<style>
body {
font-family: system-ui, sans-serif;
font-size: 16px;
display: grid;
height: 80vh;
place-items: center;

background-color: #fff;
color: #333;
}

@media (prefers-color-scheme: dark) {
body {
background-color: #333;
color: #fff;
}
}
</style>
</head>

<body>
<h1>Request Too Large</h1>
</body>
</html>
33 changes: 33 additions & 0 deletions internal/server/pages/502.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Bad Gateway</title>

<style>
body {
font-family: system-ui, sans-serif;
font-size: 16px;
display: grid;
height: 80vh;
place-items: center;

background-color: #fff;
color: #333;
}

@media (prefers-color-scheme: dark) {
body {
background-color: #333;
color: #fff;
}
}
</style>
</head>

<body>
<h1>Bad Gateway</h1>
</body>
</html>
36 changes: 36 additions & 0 deletions internal/server/pages/503.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Service Temporarily Unavailable</title>

<style>
body {
font-family: system-ui, sans-serif;
font-size: 16px;
display: grid;
height: 80vh;
place-items: center;

background-color: #fff;
color: #333;
}

@media (prefers-color-scheme: dark) {
body {
background-color: #333;
color: #fff;
}
}
</style>
</head>

<body>
<div>
<h1>Service Temporarily Unavailable</h1>
<p>{{ .Message }}</p>
</div>
</body>
</html>
33 changes: 33 additions & 0 deletions internal/server/pages/504.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Gateway Timeout</title>

<style>
body {
font-family: system-ui, sans-serif;
font-size: 16px;
display: grid;
height: 80vh;
place-items: center;

background-color: #fff;
color: #333;
}

@media (prefers-color-scheme: dark) {
body {
background-color: #333;
color: #fff;
}
}
</style>
</head>

<body>
<h1>Gateway Timeout</h1>
</body>
</html>
Loading

0 comments on commit 3002f5e

Please sign in to comment.