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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ message has already been sent.

## API Routes

All routes are prefixed with `/fmsg`. `POST /fmsg/token` accepts an API key and
All routes except `/healthz` are prefixed with `/fmsg`. `POST /fmsg/token` accepts an API key and
returns a JWT. Other routes require a valid `Authorization: Bearer <token>`
header. The WebSocket route `/fmsg/ws` additionally accepts the token via an
`access_token` query parameter (browsers cannot set headers on a WebSocket).
Expand All @@ -335,6 +335,7 @@ the application.

| Method | Path | Description |
| -------- | ------------------------------------------- | ------------------------ |
| `GET` | `/healthz` | Health check (no auth): `200 ok`, or `503` when the database is unreachable |
| `GET` | `/fmsg` | List messages for the authenticated identity |
| `GET` | `/fmsg/sent` | List authored messages (sent + drafts) |
| `GET` | `/fmsg/ws` | WebSocket for pushed event notifications |
Expand Down
7 changes: 5 additions & 2 deletions cmd/fmsg-webapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ func main() {
log.Fatalf("failed to initialise JWT verifier: %v", err)
}

// Create Gin router.
router := gin.Default()
// Create Gin router. Like gin.Default, but health checks, which monitors
// make every few seconds, are left out of the request log.
router := gin.New()
router.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/healthz"}}), gin.Recovery())
if trustedProxies := parseCSV(os.Getenv("FMSG_TRUSTED_PROXIES")); len(trustedProxies) > 0 {
if err := router.SetTrustedProxies(trustedProxies); err != nil {
log.Fatalf("invalid FMSG_TRUSTED_PROXIES: %v", err)
Expand Down Expand Up @@ -171,6 +173,7 @@ func main() {
tokenHandler = handlers.NewTokenHandler(apiStore, tokenIssuer, idURL)
subAccountHandler = handlers.NewSubAccountHandler(apiStore, idURL)
}
router.GET("/healthz", handlers.Healthz(database.Pool))
registerRoutes(router, jwtMiddleware, msgHandler, attHandler, tokenHandler, subAccountHandler, pushHandler, wsHandler)

srv := &http.Server{
Expand Down
34 changes: 34 additions & 0 deletions internal/handlers/health.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package handlers

import (
"context"
"net/http"
"time"

"github.com/gin-gonic/gin"
)

// Pinger reports whether a dependency answers; *pgxpool.Pool satisfies it.
type Pinger interface {
Ping(ctx context.Context) error
}

// healthTimeout bounds the database check so a stuck database fails the
// health check instead of hanging it.
const healthTimeout = 2 * time.Second

// Healthz handles GET /healthz for deploy checks and uptime monitors. It needs
// no authentication and reveals nothing beyond whether the service is up: 200
// "ok" when the database answers within healthTimeout, otherwise 503.
func Healthz(db Pinger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
ctx, cancel := context.WithTimeout(c.Request.Context(), healthTimeout)
defer cancel()
if err := db.Ping(ctx); err != nil {
c.String(http.StatusServiceUnavailable, "database unavailable")
return
}
c.String(http.StatusOK, "ok")
}
}
41 changes: 41 additions & 0 deletions internal/handlers/health_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package handlers

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

"github.com/gin-gonic/gin"
)

type fakePinger struct{ err error }

func (f fakePinger) Ping(ctx context.Context) error { return f.err }

func TestHealthz(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
err error
code int
body string
}{
{"database up", nil, http.StatusOK, "ok"},
{"database down", errors.New("connection refused"), http.StatusServiceUnavailable, "database unavailable"},
} {
t.Run(tc.name, func(t *testing.T) {
router := gin.New()
router.GET("/healthz", Healthz(fakePinger{tc.err}))
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if w.Code != tc.code || w.Body.String() != tc.body {
t.Fatalf("got %d %q, want %d %q", w.Code, w.Body.String(), tc.code, tc.body)
}
if got := w.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
})
}
}
Loading