From 9412e3a2238704aa76cd695a79d51fcc6c8e41f8 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 17 May 2025 05:40:10 +0200 Subject: [PATCH] Adding upstream version 1.6.0. Signed-off-by: Daniel Baumann --- .gitattributes | 1 + .github/codecov.yml | 3 + .github/dependabot.yml | 15 +++ .github/workflows/test.yml | 30 +++++ .gitignore | 2 + LICENSE | 21 ++++ Makefile | 5 + README.md | 92 ++++++++++++++++ go.mod | 3 + health.go | 142 ++++++++++++++++++++++++ health_bench_test.go | 27 +++++ health_test.go | 219 +++++++++++++++++++++++++++++++++++++ status.go | 8 ++ 13 files changed, 568 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/codecov.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 health.go create mode 100644 health_bench_test.go create mode 100644 health_test.go create mode 100644 status.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..fa348a8 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,3 @@ +coverage: + status: + patch: off diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..44dc0e2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + labels: ["dependencies"] + schedule: + interval: "weekly" + day: "sunday" + - package-ecosystem: "gomod" + directory: "/" + open-pull-requests-limit: 1 + labels: ["dependencies"] + schedule: + interval: "weekly" + day: "sunday" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0107368 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: test +on: + pull_request: + paths-ignore: + - '*.md' + push: + branches: + - master + paths-ignore: + - '*.md' +jobs: + test: + name: test + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/setup-go@v3 + with: + go-version: 1.19 + - uses: actions/checkout@v3 + - name: Benchmark + run: go test -bench . -race + - name: Test (race) + run: go test -mod vendor ./... -race + - name: Test (coverage) + run: go test -mod vendor ./... -coverprofile=coverage.txt -covermode=atomic + - name: Codecov + uses: codecov/codecov-action@v3.1.1 + with: + files: ./coverage.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c38fa4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +*.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ea31ca1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 TwiN + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f51c74a --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +bench: + go test -bench . -race + +test: + go test . -race \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..624d29d --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# health +![test](https://github.com/TwiN/health/workflows/test/badge.svg?branch=master) +[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/health)](https://goreportcard.com/report/github.com/TwiN/health) +[![codecov](https://codecov.io/gh/TwiN/health/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/health) +[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/health.svg)](https://github.com/TwiN/health) +[![Go Reference](https://pkg.go.dev/badge/github.com/TwiN/health.svg)](https://pkg.go.dev/github.com/TwiN/health) + +Health is a library used for creating a very simple health endpoint. + +While implementing a health endpoint is very simple, I've grown tired of implementing +it over and over again. + + +## Installation +```console +go get -u github.com/TwiN/health +``` + + +## Usage +To retrieve the handler, you must use `health.Handler()` and are expected to pass it to the router like so: +```go +router := http.NewServeMux() +router.Handle("/health", health.Handler()) +server := &http.Server{ + Addr: ":8080", + Handler: router, +} +``` + +By default, the handler will return `UP` when the status is up, and `DOWN` when the status is down. +If you prefer using JSON, however, you may initialize the health handler like so: +```go +router.Handle("/health", health.Handler().WithJSON(true)) +``` +The above will cause the response body to become `{"status":"UP"}` and `{"status":"DOWN"}` for both status respectively, +unless there is a reason, in which case a reason set to `because` would return `{"status":"UP", "reason":"because"}` +and `{"status":"DOWN", "reason":"because"}` respectively. + +To set the health status to `DOWN` with a reason: +```go +health.SetUnhealthy("") +``` +The string passed will be automatically set as the reason. + +In a similar fashion, to set the health status to `UP` and clear the reason: +```go +health.SetHealthy() +``` + + +Alternatively, to set the status and the reason individually you can use `health.SetStatus()` where `` is `health.Up` +or `health.Down`: +```go +health.SetStatus(health.Up) +health.SetStatus(health.Down) +``` +As for the reason: +```go +health.SetReason("database is unreachable") +``` + +Generally speaking, you'd only want to include a reason if the status is `Down`, but you can do as you desire. + +For the sake of convenience, you can also use `health.SetStatusAndReason(, )` instead of doing +`health.SetStatus()` and `health.SetReason()` separately. + + +### Complete example +```go +package main + +import ( + "net/http" + "time" + + "github.com/TwiN/health" +) + +func main() { + router := http.NewServeMux() + router.Handle("/health", health.Handler()) + server := &http.Server{ + Addr: "0.0.0.0:8080", + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 15 * time.Second, + } + server.ListenAndServe() +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1cad754 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/TwiN/health + +go 1.19 diff --git a/health.go b/health.go new file mode 100644 index 0000000..56f251e --- /dev/null +++ b/health.go @@ -0,0 +1,142 @@ +package health + +import ( + "encoding/json" + "net/http" + "sync" +) + +var ( + handler = &healthHandler{ + useJSON: false, + status: Up, + } +) + +// responseBody is the body of the response returned by the health handler. +type responseBody struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// healthHandler is the HTTP handler for serving the health endpoint +type healthHandler struct { + useJSON bool + + status Status + reason string + + mutex sync.RWMutex +} + +// WithJSON configures whether the handler should output a response in JSON or in raw text +// +// Defaults to false +func (h *healthHandler) WithJSON(v bool) *healthHandler { + h.useJSON = v + return h +} + +// ServeHTTP serves the HTTP request for the health handler +func (h *healthHandler) ServeHTTP(writer http.ResponseWriter, _ *http.Request) { + statusCode, body, useJSON := h.getResponseStatusCodeAndBodyAndWhetherBodyUsesJSON() + if useJSON { + writer.Header().Set("Content-Type", "application/json") + } + writer.WriteHeader(statusCode) + _, _ = writer.Write(body) +} + +func (h *healthHandler) GetResponseStatusCodeAndBody() (statusCode int, body []byte) { + statusCode, body, _ = h.getResponseStatusCodeAndBodyAndWhetherBodyUsesJSON() + return statusCode, body +} + +func (h *healthHandler) getResponseStatusCodeAndBodyAndWhetherBodyUsesJSON() (statusCode int, body []byte, useJSON bool) { + var status Status + var reason string + h.mutex.RLock() + status, reason, useJSON = h.status, h.reason, h.useJSON + h.mutex.RUnlock() + if status == Up { + statusCode = http.StatusOK + } else { + statusCode = http.StatusInternalServerError + } + if useJSON { + // We can safely ignore the error here because we know that both values are strings, therefore are supported encoders. + body, _ = json.Marshal(responseBody{Status: string(status), Reason: reason}) + } else { + if len(reason) == 0 { + body = []byte(status) + } else { + body = []byte(string(status) + ": " + reason) + } + } + return +} + +// Handler retrieves the health handler +func Handler() *healthHandler { + return handler +} + +// GetStatus retrieves the current status returned by the health handler +func GetStatus() Status { + handler.mutex.RLock() + defer handler.mutex.RUnlock() + return handler.status +} + +// SetStatus sets the status to be returned by the health handler +func SetStatus(status Status) { + handler.mutex.Lock() + handler.status = status + handler.mutex.Unlock() +} + +// GetReason retrieves the current status returned by the health handler +func GetReason() string { + handler.mutex.RLock() + defer handler.mutex.RUnlock() + return handler.reason +} + +// SetReason sets a reason for the current status to be returned by the health handler +func SetReason(reason string) { + handler.mutex.Lock() + handler.reason = reason + handler.mutex.Unlock() +} + +// SetStatusAndReason sets the status and reason to be returned by the health handler +func SetStatusAndReason(status Status, reason string) { + handler.mutex.Lock() + handler.status = status + handler.reason = reason + handler.mutex.Unlock() +} + +// SetStatusAndResetReason sets the status and resets the reason to a blank string +func SetStatusAndResetReason(status Status) { + handler.mutex.Lock() + handler.status = status + handler.reason = "" + handler.mutex.Unlock() +} + +// SetHealthy sets the status to Up and the reason to a blank string +func SetHealthy() { + SetStatusAndResetReason(Up) +} + +// SetUnhealthy sets the status to Down and the reason to the string passed as parameter +// +// Unlike SetHealthy, this function enforces setting a reason, because it's good practice to give at least a bit +// of information as to why an application is unhealthy, and this library attempts to promote good practices. +func SetUnhealthy(reason string) { + handler.mutex.Lock() + handler.status = Down + handler.reason = reason + handler.mutex.Unlock() +} diff --git a/health_bench_test.go b/health_bench_test.go new file mode 100644 index 0000000..e1bc93b --- /dev/null +++ b/health_bench_test.go @@ -0,0 +1,27 @@ +package health_test + +import ( + "math/rand" + "net/http" + "net/http/httptest" + "testing" + + "github.com/TwiN/health" +) + +func BenchmarkHealthHandler_ServeHTTP(b *testing.B) { + h := health.Handler().WithJSON(true) + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + request, _ := http.NewRequest("GET", "/health", http.NoBody) + responseRecorder := httptest.NewRecorder() + h.ServeHTTP(responseRecorder, request) + if n := rand.Intn(100); n < 1 { + health.SetStatus(health.Down) + } else if n < 5 { + health.SetStatus(health.Up) + } + } + }) + b.ReportAllocs() +} diff --git a/health_test.go b/health_test.go new file mode 100644 index 0000000..bfd6e37 --- /dev/null +++ b/health_test.go @@ -0,0 +1,219 @@ +package health_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/TwiN/health" +) + +func TestHealthHandler_ServeHTTP(t *testing.T) { + defer health.SetHealthy() + type Scenario struct { + Name string + useJSON bool + status health.Status + reason string + expectedResponseBody string + expectedResponseCode int + } + scenarios := []Scenario{ + { + Name: "text-up", + useJSON: false, + status: health.Up, + expectedResponseBody: "UP", + expectedResponseCode: 200, + }, + { + Name: "text-up-reason", + useJSON: false, + status: health.Up, + reason: "reason", + expectedResponseBody: "UP: reason", + expectedResponseCode: 200, + }, + { + Name: "text-down", + useJSON: false, + status: health.Down, + expectedResponseBody: "DOWN", + expectedResponseCode: 500, + }, + { + Name: "text-down-reason", + useJSON: false, + status: health.Down, + reason: "reason", + expectedResponseBody: "DOWN: reason", + expectedResponseCode: 500, + }, + { + Name: "json-up", + useJSON: true, + status: health.Up, + expectedResponseBody: `{"status":"UP"}`, + expectedResponseCode: 200, + }, + { + Name: "json-up-reason", + useJSON: true, + status: health.Up, + reason: "Error", + expectedResponseBody: `{"status":"UP","reason":"Error"}`, + expectedResponseCode: 200, + }, + { + Name: "json-down", + useJSON: true, + status: health.Down, + expectedResponseBody: `{"status":"DOWN"}`, + expectedResponseCode: 500, + }, + { + Name: "json-down-reason", + useJSON: true, + status: health.Down, + reason: "Error", + expectedResponseBody: `{"status":"DOWN","reason":"Error"}`, + expectedResponseCode: 500, + }, + { + Name: "json-down-reason-with-quotes", + useJSON: true, + status: health.Down, + reason: `error "with" quotes`, + expectedResponseBody: `{"status":"DOWN","reason":"error \"with\" quotes"}`, + expectedResponseCode: 500, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + handler := health.Handler().WithJSON(scenario.useJSON) + health.SetStatus(scenario.status) + health.SetReason(scenario.reason) + + request, _ := http.NewRequest("GET", "/health", http.NoBody) + responseRecorder := httptest.NewRecorder() + + handler.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.expectedResponseCode { + t.Errorf("expected GET /health to return status code %d, got %d", scenario.expectedResponseCode, responseRecorder.Code) + } + body, _ := io.ReadAll(responseRecorder.Body) + if string(body) != scenario.expectedResponseBody { + t.Errorf("expected GET /health to return %s, got %s", scenario.expectedResponseBody, string(body)) + } + }) + } +} + +func TestHealthHandler_GetResponseStatusCodeAndBody(t *testing.T) { + defer health.SetHealthy() + handler := health.Handler().WithJSON(true) + health.SetStatus(health.Up) + + statusCode, body := handler.GetResponseStatusCodeAndBody() + if statusCode != 200 { + t.Error("expected status code to be 200, got", statusCode) + } + if string(body) != `{"status":"UP"}` { + t.Error("expected body to be {\"status\":\"UP\"}, got", string(body)) + } +} + +func TestSetStatus(t *testing.T) { + defer health.SetHealthy() + health.SetStatus(health.Up) + if health.GetStatus() != health.Up { + t.Error("expected status to be 'Up', got", health.GetStatus()) + } + health.SetStatus(health.Down) + if health.GetStatus() != health.Down { + t.Error("expected status to be 'Down', got", health.GetStatus()) + } + health.SetStatus(health.Up) +} + +func TestSetReason(t *testing.T) { + defer health.SetHealthy() + health.SetReason("hello") + if health.GetReason() != "hello" { + t.Error("expected reason to be 'hello', got", health.GetReason()) + } + health.SetReason("world") + if health.GetReason() != "world" { + t.Error("expected reason to be 'world', got", health.GetReason()) + } + health.SetReason("") + if health.GetReason() != "" { + t.Error("expected reason to be '', got", health.GetReason()) + } +} + +func TestSetStatusAndReason(t *testing.T) { + defer health.SetHealthy() + health.SetStatusAndReason(health.Down, "for what") + if health.GetStatus() != health.Down { + t.Error("expected status to be 'Down', got", health.GetStatus()) + } + if health.GetReason() != "for what" { + t.Error("expected reason to be 'hello', got", health.GetReason()) + } +} + +func TestSetStatusAndResetReason(t *testing.T) { + defer health.SetHealthy() + health.SetStatusAndReason(health.Down, "for what") + if health.GetStatus() != health.Down { + t.Error("expected status to be 'Down', got", health.GetStatus()) + } + if health.GetReason() != "for what" { + t.Error("expected reason to be 'for what', got", health.GetReason()) + } + health.SetStatusAndResetReason(health.Up) + if health.GetStatus() != health.Up { + t.Error("expected status to be 'Up', got", health.GetStatus()) + } + if health.GetReason() != "" { + t.Error("expected reason to be '', got", health.GetReason()) + } +} + +func TestSetHealthy(t *testing.T) { + defer health.SetHealthy() + health.SetStatusAndReason(health.Down, "for what") + if health.GetStatus() != health.Down { + t.Error("expected status to be 'Down', got", health.GetStatus()) + } + if health.GetReason() != "for what" { + t.Error("expected reason to be 'for what', got", health.GetReason()) + } + health.SetHealthy() + if health.GetStatus() != health.Up { + t.Error("expected status to be 'Up', got", health.GetStatus()) + } + if health.GetReason() != "" { + t.Error("expected reason to be '', got", health.GetReason()) + } +} + +func TestSetUnhealthy(t *testing.T) { + defer health.SetHealthy() + health.SetStatusAndReason(health.Up, "") + if health.GetStatus() != health.Up { + t.Error("expected status to be '', got", health.GetStatus()) + } + if health.GetReason() != "" { + t.Error("expected reason to be '', got", health.GetReason()) + } + health.SetUnhealthy("for what") + if health.GetStatus() != health.Down { + t.Error("expected status to be 'Down', got", health.GetStatus()) + } + if health.GetReason() != "for what" { + t.Error("expected reason to be 'for what', got", health.GetReason()) + } +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..1f2fb7c --- /dev/null +++ b/status.go @@ -0,0 +1,8 @@ +package health + +type Status string + +var ( + Down Status = "DOWN" // For when the application is unhealthy + Up Status = "UP" // For when the application is healthy +)