diff --git a/docs/api/app.md b/docs/api/app.md index 198c83ce1d..cb38f7051f 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -654,4 +654,4 @@ Hooks is a method to return [hooks](../guide/hooks.md) property. ```go title="Signature" func (app *App) Hooks() *Hooks -``` +``` \ No newline at end of file diff --git a/docs/api/middleware/healthcheck.md b/docs/api/middleware/healthcheck.md new file mode 100644 index 0000000000..3f2bb6c5fb --- /dev/null +++ b/docs/api/middleware/healthcheck.md @@ -0,0 +1,105 @@ +--- +id: healthcheck +title: healthcheck +--- + +Liveness and readiness probes middleware for [Fiber](https://github.com/gofiber/fiber) that provides two endpoints for checking the liveness and readiness state of HTTP applications. + +## Overview + +- **Liveness Probe**: Checks if the server is up and running. + - **Default Endpoint**: `/livez` + - **Behavior**: By default returns `true` immediately when the server is operational. + +- **Readiness Probe**: Assesses if the application is ready to handle requests. + - **Default Endpoint**: `/readyz` + - **Behavior**: By default returns `true` immediately when the server is operational. + +- **HTTP Status Codes**: + - `200 OK`: Returned when the checker function evaluates to `true`. + - `503 Service Unavailable`: Returned when the checker function evaluates to `false`. + +## Signatures + +```go +func New(config Config) fiber.Handler +``` + +## Examples + +Import the middleware package that is part of the Fiber web framework +```go +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/healthcheck" +) +``` + +After you initiate your Fiber app, you can use the following possibilities: + +```go +// Provide a minimal config +app.Use(healthcheck.New()) + +// Or extend your config for customization +app.Use(healthcheck.New(healthcheck.Config{ + LivenessProbe: func(c *fiber.Ctx) bool { + return true + }, + LivenessEndpoint: "/live", + ReadinessProbe: func(c *fiber.Ctx) bool { + return serviceA.Ready() && serviceB.Ready() && ... + }, + ReadinessEndpoint: "/ready", +})) +``` + +## Config + +```go +type Config struct { + // Next defines a function to skip this middleware when returned true. + // + // Optional. Default: nil + Next func(c *fiber.Ctx) bool + + // Function used for checking the liveness of the application. Returns true if the application + // is running and false if it is not. The liveness probe is typically used to indicate if + // the application is in a state where it can handle requests (e.g., the server is up and running). + // + // Optional. Default: func(c *fiber.Ctx) bool { return true } + LivenessProbe HealthChecker + + // HTTP endpoint at which the liveness probe will be available. + // + // Optional. Default: "/livez" + LivenessEndpoint string + + // Function used for checking the readiness of the application. Returns true if the application + // is ready to process requests and false otherwise. The readiness probe typically checks if all necessary + // services, databases, and other dependencies are available for the application to function correctly. + // + // Optional. Default: func(c *fiber.Ctx) bool { return true } + ReadinessProbe HealthChecker + + // HTTP endpoint at which the readiness probe will be available. + // Optional. Default: "/readyz" + ReadinessEndpoint string +} +``` + +## Default Config + +The default configuration used by this middleware is defined as follows: +```go +func defaultLivenessProbe(*fiber.Ctx) bool { return true } + +func defaultReadinessProbe(*fiber.Ctx) bool { return true } + +var ConfigDefault = Config{ + LivenessProbe: defaultLivenessProbe, + ReadinessProbe: defaultReadinessProbe, + LivenessEndpoint: "/livez", + ReadinessEndpoint: "/readyz", +} +``` \ No newline at end of file diff --git a/middleware/healthcheck/config.go b/middleware/healthcheck/config.go new file mode 100644 index 0000000000..d4e5fac1b0 --- /dev/null +++ b/middleware/healthcheck/config.go @@ -0,0 +1,84 @@ +package healthcheck + +import ( + "github.com/gofiber/fiber/v2" +) + +// Config defines the configuration options for the healthcheck middleware. +type Config struct { + // Next defines a function to skip this middleware when returned true. + // + // Optional. Default: nil + Next func(c *fiber.Ctx) bool + + // Function used for checking the liveness of the application. Returns true if the application + // is running and false if it is not. The liveness probe is typically used to indicate if + // the application is in a state where it can handle requests (e.g., the server is up and running). + // + // Optional. Default: func(c *fiber.Ctx) bool { return true } + LivenessProbe HealthChecker + + // HTTP endpoint at which the liveness probe will be available. + // + // Optional. Default: "/livez" + LivenessEndpoint string + + // Function used for checking the readiness of the application. Returns true if the application + // is ready to process requests and false otherwise. The readiness probe typically checks if all necessary + // services, databases, and other dependencies are available for the application to function correctly. + // + // Optional. Default: func(c *fiber.Ctx) bool { return true } + ReadinessProbe HealthChecker + + // HTTP endpoint at which the readiness probe will be available. + // Optional. Default: "/readyz" + ReadinessEndpoint string +} + +const ( + DefaultLivenessEndpoint = "/livez" + DefaultReadinessEndpoint = "/readyz" +) + +func defaultLivenessProbe(*fiber.Ctx) bool { return true } + +func defaultReadinessProbe(*fiber.Ctx) bool { return true } + +// ConfigDefault is the default config +var ConfigDefault = Config{ + LivenessProbe: defaultLivenessProbe, + ReadinessProbe: defaultReadinessProbe, + LivenessEndpoint: DefaultLivenessEndpoint, + ReadinessEndpoint: DefaultReadinessEndpoint, +} + +// defaultConfig returns a default config for the healthcheck middleware. +func defaultConfig(config ...Config) Config { + if len(config) < 1 { + return ConfigDefault + } + + cfg := config[0] + + if cfg.Next == nil { + cfg.Next = ConfigDefault.Next + } + + if cfg.LivenessProbe == nil { + cfg.LivenessProbe = defaultLivenessProbe + } + + if cfg.ReadinessProbe == nil { + cfg.ReadinessProbe = defaultReadinessProbe + } + + if cfg.LivenessEndpoint == "" { + cfg.LivenessEndpoint = DefaultLivenessEndpoint + } + + if cfg.ReadinessEndpoint == "" { + cfg.ReadinessEndpoint = DefaultReadinessEndpoint + } + + return cfg +} diff --git a/middleware/healthcheck/healthcheck.go b/middleware/healthcheck/healthcheck.go new file mode 100644 index 0000000000..14ff33430c --- /dev/null +++ b/middleware/healthcheck/healthcheck.go @@ -0,0 +1,52 @@ +package healthcheck + +import ( + "github.com/gofiber/fiber/v2" +) + +// HealthChecker defines a function to check liveness or readiness of the application +type HealthChecker func(*fiber.Ctx) bool + +// ProbeCheckerHandler defines a function that returns a ProbeChecker +type HealthCheckerHandler func(HealthChecker) fiber.Handler + +func healthCheckerHandler(checker HealthChecker) fiber.Handler { + return func(c *fiber.Ctx) error { + if checker == nil { + return c.Next() + } + + if checker(c) { + return c.SendStatus(fiber.StatusOK) + } + + return c.SendStatus(fiber.StatusServiceUnavailable) + } +} + +func New(config ...Config) fiber.Handler { + cfg := defaultConfig(config...) + + isLiveHandler := healthCheckerHandler(cfg.LivenessProbe) + isReadyHandler := healthCheckerHandler(cfg.ReadinessProbe) + + return func(c *fiber.Ctx) error { + // Don't execute middleware if Next returns true + if cfg.Next != nil && cfg.Next(c) { + return c.Next() + } + + if c.Method() != fiber.MethodGet { + return c.Next() + } + + switch c.Path() { + case cfg.ReadinessEndpoint: + return isReadyHandler(c) + case cfg.LivenessEndpoint: + return isLiveHandler(c) + } + + return c.Next() + } +} diff --git a/middleware/healthcheck/healthcheck_test.go b/middleware/healthcheck/healthcheck_test.go new file mode 100644 index 0000000000..df0165f158 --- /dev/null +++ b/middleware/healthcheck/healthcheck_test.go @@ -0,0 +1,117 @@ +package healthcheck + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/utils" + "github.com/valyala/fasthttp" +) + +func Test_HealthCheck_Default(t *testing.T) { + t.Parallel() + + app := fiber.New() + app.Use(New()) + + req, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/readyz", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusOK, req.StatusCode) + + req, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/livez", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusOK, req.StatusCode) +} + +func Test_HealthCheck_Custom(t *testing.T) { + t.Parallel() + + app := fiber.New() + + c1 := make(chan struct{}, 1) + go func() { + time.Sleep(1 * time.Second) + c1 <- struct{}{} + }() + + app.Use(New(Config{ + LivenessProbe: func(c *fiber.Ctx) bool { + return true + }, + LivenessEndpoint: "/live", + ReadinessProbe: func(c *fiber.Ctx) bool { + select { + case <-c1: + return true + default: + return false + } + }, + ReadinessEndpoint: "/ready", + })) + + // Live should return 200 with GET request + req, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/live", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusOK, req.StatusCode) + + // Live should return 404 with POST request + req, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/live", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusNotFound, req.StatusCode) + + // Ready should return 404 with POST request + req, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/ready", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusNotFound, req.StatusCode) + + // Ready should return 503 with GET request before the channel is closed + req, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/ready", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusServiceUnavailable, req.StatusCode) + + time.Sleep(1 * time.Second) + + // Ready should return 200 with GET request after the channel is closed + req, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/ready", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusOK, req.StatusCode) +} + +func Test_HealthCheck_Next(t *testing.T) { + t.Parallel() + + app := fiber.New() + + app.Use(New(Config{ + Next: func(c *fiber.Ctx) bool { + return true + }, + })) + + req, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/livez", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusNotFound, req.StatusCode) +} + +func Benchmark_HealthCheck(b *testing.B) { + app := fiber.New() + + app.Use(New()) + + h := app.Handler() + fctx := &fasthttp.RequestCtx{} + fctx.Request.Header.SetMethod(fiber.MethodGet) + fctx.Request.SetRequestURI("/livez") + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + h(fctx) + } + + utils.AssertEqual(b, fiber.StatusOK, fctx.Response.Header.StatusCode()) +}