1
0
Fork 0

Adding upstream version 1.6.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-17 05:40:10 +02:00
parent ea7b92a91b
commit 9412e3a223
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
13 changed files with 568 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

3
.github/codecov.yml vendored Normal file
View file

@ -0,0 +1,3 @@
coverage:
status:
patch: off

15
.github/dependabot.yml vendored Normal file
View file

@ -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"

30
.github/workflows/test.yml vendored Normal file
View file

@ -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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.idea
*.iml

21
LICENSE Normal file
View file

@ -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.

5
Makefile Normal file
View file

@ -0,0 +1,5 @@
bench:
go test -bench . -race
test:
go test . -race

92
README.md Normal file
View file

@ -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("<enter reason here>")
```
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(<status>)` where `<status>` 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(<status>, <reason>)` instead of doing
`health.SetStatus(<status>)` and `health.SetReason(<reason>)` 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()
}
```

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/TwiN/health
go 1.19

142
health.go Normal file
View file

@ -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()
}

27
health_bench_test.go Normal file
View file

@ -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()
}

219
health_test.go Normal file
View file

@ -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())
}
}

8
status.go Normal file
View file

@ -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
)