├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── checkers ├── diskspace.go ├── diskspace_test.go ├── heartbeat.go └── heartbeat_test.go ├── coverage.txt ├── go.mod ├── health.go ├── health_test.go └── logo.png /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | branches: [ "master" ] 19 | schedule: 20 | - cron: '38 8 * * 0' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners 29 | # Consider using larger runners for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | actions: read 34 | contents: read 35 | security-events: write 36 | 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | language: [ 'go' ] 41 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 42 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 43 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 44 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 45 | 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v3 49 | 50 | # Initializes the CodeQL tools for scanning. 51 | - name: Initialize CodeQL 52 | uses: github/codeql-action/init@v2 53 | with: 54 | languages: ${{ matrix.language }} 55 | # If you wish to specify custom queries, you can do so here or in a config file. 56 | # By default, queries listed here will override any specified in a config file. 57 | # Prefix the list here with "+" to use these queries and those in the config file. 58 | 59 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 60 | # queries: security-extended,security-and-quality 61 | 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@v2 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 70 | 71 | # If the Autobuild fails above, remove it and uncomment the following three lines. 72 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 73 | 74 | # - run: | 75 | # echo "Run, Build Application using script" 76 | # ./location_of_script_within_repo/buildscript.sh 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v2 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - "1.12" 4 | 5 | before_install: 6 | - go get -t -v ./... 7 | 8 | script: 9 | - go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 10 | 11 | after_success: 12 | - bash <(curl -s https://codecov.io/bash) 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 etherlabsio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Healthcheck 4 | 5 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 6 | 7 | [![Build Status](https://travis-ci.com/etherlabsio/healthcheck.svg)](https://travis-ci.com/etherlabsio/healthcheck) [![Go Report Card](https://goreportcard.com/badge/github.com/etherlabsio/healthcheck)](https://goreportcard.com/report/github.com/etherlabsio/healthcheck) [![GoDoc](https://godoc.org/github.com/etherlabsio/healthcheck?status.svg)](https://godoc.org/github.com/etherlabsio/healthcheck) [![codecov](https://codecov.io/gh/etherlabsio/healthcheck/branch/master/graph/badge.svg)](https://codecov.io/gh/etherlabsio/healthcheck) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fetherlabsio%2Fhealthcheck.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fetherlabsio%2Fhealthcheck?ref=badge_shield) 8 | 9 | A simple and extensible RESTful Healthcheck API implementation for Go services. 10 | 11 | Health provides an `http.Handlefunc` for use as a healthcheck endpoint used by external services or load balancers. The function is used to determine the health of the application and to remove unhealthy application hosts or containers from rotation. 12 | 13 | Instead of blindly returning a `200` HTTP status code, a healthcheck endpoint should test all the mandatory dependencies that are essential for proper functioning of a web service. 14 | 15 | Implementing the `Checker` interface and passing it on to healthcheck allows you to test the the dependencies such as a database connection, caches, files and even external services you rely on. You may choose to not fail the healthcheck on failure of certain dependencies such as external services that you are not always dependent on. 16 | 17 | ## Example 18 | 19 | ```GO 20 | package main 21 | 22 | import ( 23 | "context" 24 | "database/sql" 25 | "net/http" 26 | "time" 27 | 28 | "github.com/etherlabsio/healthcheck/v2" 29 | "github.com/etherlabsio/healthcheck/v2/checkers" 30 | _ "github.com/go-sql-driver/mysql" 31 | "github.com/gorilla/mux" 32 | ) 33 | 34 | func main() { 35 | // For brevity, error check is being omitted here. 36 | db, _ := sql.Open("mysql", "user:password@/dbname") 37 | defer db.Close() 38 | 39 | r := mux.NewRouter() 40 | r.Handle("/healthcheck", healthcheck.Handler( 41 | 42 | // WithTimeout allows you to set a max overall timeout. 43 | healthcheck.WithTimeout(5*time.Second), 44 | 45 | // Checkers fail the status in case of any error. 46 | healthcheck.WithChecker( 47 | "heartbeat", checkers.Heartbeat("$PROJECT_PATH/heartbeat"), 48 | ), 49 | 50 | healthcheck.WithChecker( 51 | "database", healthcheck.CheckerFunc( 52 | func(ctx context.Context) error { 53 | return db.PingContext(ctx) 54 | }, 55 | ), 56 | ), 57 | 58 | // Observers do not fail the status in case of error. 59 | healthcheck.WithObserver( 60 | "diskspace", checkers.DiskSpace("/var/log", 90), 61 | ), 62 | )) 63 | 64 | http.ListenAndServe(":8080", r) 65 | } 66 | ``` 67 | 68 | Based on the example provided above, `curl localhost:8080/healthcheck | jq` should yield on error a response with an HTTP statusCode of `503`. 69 | 70 | ```JSON 71 | { 72 | "status": "Service Unavailable", 73 | "errors": { 74 | "database": "dial tcp 127.0.0.1:3306: getsockopt: connection refused", 75 | "heartbeat": "heartbeat not found. application should be out of rotation" 76 | } 77 | } 78 | ``` 79 | 80 | ## License 81 | 82 | This project is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file. 83 | 84 | 85 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fetherlabsio%2Fhealthcheck.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fetherlabsio%2Fhealthcheck?ref=badge_large) 86 | -------------------------------------------------------------------------------- /checkers/diskspace.go: -------------------------------------------------------------------------------- 1 | package checkers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "syscall" 9 | 10 | "github.com/etherlabsio/healthcheck/v2" 11 | ) 12 | 13 | type diskspace struct { 14 | dir string 15 | threshold uint64 16 | statfs func(string, *syscall.Statfs_t) error 17 | } 18 | 19 | //Check test if the filesystem disk usage is above threshold 20 | func (ds *diskspace) Check(ctx context.Context) error { 21 | if _, err := os.Stat(ds.dir); err != nil { 22 | return fmt.Errorf("filesystem not found: %v", err) 23 | } 24 | 25 | fs := syscall.Statfs_t{} 26 | err := ds.statfs(ds.dir, &fs) 27 | if err != nil { 28 | return fmt.Errorf("error looking for %s filesystem stats: %v", ds.dir, err) 29 | } 30 | 31 | total := fs.Blocks * uint64(fs.Bsize) 32 | free := fs.Bfree * uint64(fs.Bsize) 33 | used := total - free 34 | usedPercentage := 100 * used / total 35 | if usedPercentage > ds.threshold { 36 | return fmt.Errorf("used: %d%% threshold: %d%% location: %s", usedPercentage, ds.threshold, ds.dir) 37 | } 38 | return nil 39 | } 40 | 41 | // DiskSpace returns a diskspace health checker, which checks if filesystem usage is above the threshold which is defined in percentage. 42 | func DiskSpace(dir string, threshold uint64) healthcheck.Checker { 43 | return &diskspace{ 44 | dir: dir, 45 | threshold: threshold, 46 | statfs: syscall.Statfs, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /checkers/diskspace_test.go: -------------------------------------------------------------------------------- 1 | package checkers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "syscall" 7 | "testing" 8 | ) 9 | 10 | func Test_diskspace_Check(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | dir string 14 | threshold uint64 15 | totalBlocks uint64 16 | freeBlocks uint64 17 | err error 18 | }{ 19 | { 20 | "Filesystem Empty", 21 | "/", 22 | 80, 23 | 100, 24 | 100, 25 | nil, 26 | }, 27 | { 28 | "Filesystem full", 29 | "/", 30 | 80, 31 | 100, 32 | 0, 33 | fmt.Errorf("used: 100%% threshold: 80%% location: /"), 34 | }, 35 | { 36 | "Filesystem at 50%. Threshold 60%", 37 | "/", 38 | 60, 39 | 100, 40 | 50, 41 | nil, 42 | }, 43 | { 44 | "Filesystem at 50%. Threshold 40%", 45 | "/", 46 | 40, 47 | 100, 48 | 50, 49 | fmt.Errorf("used: 50%% threshold: 40%% location: /"), 50 | }, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | ds := &diskspace{ 55 | dir: tt.dir, 56 | threshold: tt.threshold, 57 | statfs: func(fs string, stat *syscall.Statfs_t) error { 58 | stat.Bsize = 1 59 | stat.Bfree = tt.freeBlocks 60 | stat.Blocks = tt.totalBlocks 61 | return nil 62 | }, 63 | } 64 | if err := ds.Check(context.Background()); err != tt.err { 65 | if err == nil || tt.err == nil || err.Error() != tt.err.Error() { 66 | t.Errorf("diskspace.Check() returned error = \"%v\" but expected \"%v\"", err, tt.err) 67 | } 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /checkers/heartbeat.go: -------------------------------------------------------------------------------- 1 | package checkers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/etherlabsio/healthcheck/v2" 11 | ) 12 | 13 | type heartbeat struct { 14 | path string 15 | } 16 | 17 | func (h *heartbeat) Check(ctx context.Context) error { 18 | if _, err := os.Stat(h.path); err != nil { 19 | return errors.New("heartbeat not found. application should be out of rotation") 20 | } 21 | return nil 22 | } 23 | 24 | // Heartbeat returns a heartbeat health checker. Heartbeat files are generally used to take hosts out of rotation from the loadbalancers. 25 | // Removing the heartbeat file allows you to debug the application host in case of failures. 26 | func Heartbeat(filepath string) healthcheck.Checker { 27 | return &heartbeat{absFilePath(filepath)} 28 | } 29 | 30 | func absFilePath(inPath string) string { 31 | if strings.HasPrefix(inPath, "$") { 32 | end := strings.Index(inPath, string(os.PathSeparator)) 33 | inPath = os.Getenv(inPath[1:end]) + inPath[end:] 34 | } 35 | if filepath.IsAbs(inPath) { 36 | return filepath.Clean(inPath) 37 | } 38 | if p, err := filepath.Abs(inPath); err == nil { 39 | return filepath.Clean(p) 40 | } 41 | return "" 42 | } 43 | -------------------------------------------------------------------------------- /checkers/heartbeat_test.go: -------------------------------------------------------------------------------- 1 | package checkers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func Test_heartbeat_Check(t *testing.T) { 11 | cwd, err := os.Getwd() 12 | if err != nil { 13 | t.Fatalf("cwd unknown: %+v", err) 14 | } 15 | _, err = os.Create("heartbeat.txt") 16 | if err != nil { 17 | t.Fatalf("heartbeat file create failed: %+v", err) 18 | } 19 | fileName := "heartbeat.txt" 20 | filePath := fmt.Sprintf("%s/%s", cwd, fileName) 21 | defer os.Remove(filePath) 22 | type fields struct { 23 | path string 24 | } 25 | tests := []struct { 26 | name string 27 | fields fields 28 | wantErr bool 29 | }{ 30 | { 31 | "if heartbeat file exists, no error should be returned", 32 | fields{ 33 | absFilePath(filePath), 34 | }, 35 | false, 36 | }, 37 | { 38 | "if valid heartbeat filepath is set in env variable, no error should be returned", 39 | fields{ 40 | func() string { 41 | os.Setenv("HBFILE_PATH", cwd) 42 | return absFilePath("$HBFILE_PATH" + "/" + fileName) 43 | }(), 44 | }, 45 | false, 46 | }, 47 | { 48 | "if heartbeat file does not exist, error should be returned", 49 | fields{"/etc/hosts1"}, 50 | true, 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | h := &heartbeat{ 56 | path: tt.fields.path, 57 | } 58 | if err := h.Check(context.Background()); (err != nil) != tt.wantErr { 59 | t.Errorf("heartbeat.Check() returned error = %v but expected %v", err, tt.wantErr) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /coverage.txt: -------------------------------------------------------------------------------- 1 | mode: atomic 2 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:20.55,21.43 1 4 3 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:25.2,27.16 3 4 4 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:31.2,35.35 5 4 5 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:38.2,38.12 1 2 6 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:21.43,23.3 1 0 7 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:27.16,29.3 1 0 8 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:35.35,37.3 1 2 9 | github.com/etherlabsio/healthcheck/v2/checkers/diskspace.go:42.66,48.2 1 0 10 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:17.54,18.43 1 3 11 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:21.2,21.12 1 2 12 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:18.43,20.3 1 1 13 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:26.53,28.2 1 0 14 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:30.40,31.36 1 2 15 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:35.2,35.28 1 2 16 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:38.2,38.48 1 0 17 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:41.2,41.11 1 0 18 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:31.36,34.3 2 1 19 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:35.28,37.3 1 2 20 | github.com/etherlabsio/healthcheck/v2/checkers/heartbeat.go:38.48,40.3 1 0 21 | github.com/etherlabsio/healthcheck/v2/health.go:34.55,36.2 1 6 22 | github.com/etherlabsio/healthcheck/v2/health.go:39.43,45.27 2 5 23 | github.com/etherlabsio/healthcheck/v2/health.go:48.2,48.10 1 5 24 | github.com/etherlabsio/healthcheck/v2/health.go:45.27,47.3 1 7 25 | github.com/etherlabsio/healthcheck/v2/health.go:52.51,54.2 1 5 26 | github.com/etherlabsio/healthcheck/v2/health.go:60.49,61.25 1 4 27 | github.com/etherlabsio/healthcheck/v2/health.go:61.25,63.3 1 4 28 | github.com/etherlabsio/healthcheck/v2/health.go:67.50,68.25 1 2 29 | github.com/etherlabsio/healthcheck/v2/health.go:68.25,70.3 1 2 30 | github.com/etherlabsio/healthcheck/v2/health.go:74.48,75.25 1 1 31 | github.com/etherlabsio/healthcheck/v2/health.go:75.25,77.3 1 1 32 | github.com/etherlabsio/healthcheck/v2/health.go:80.68,86.46 4 5 33 | github.com/etherlabsio/healthcheck/v2/health.go:87.2,87.19 1 5 34 | github.com/etherlabsio/healthcheck/v2/health.go:90.2,96.39 5 5 35 | github.com/etherlabsio/healthcheck/v2/health.go:107.2,107.41 1 5 36 | github.com/etherlabsio/healthcheck/v2/health.go:118.2,125.4 4 5 37 | github.com/etherlabsio/healthcheck/v2/health.go:86.47,86.48 0 0 38 | github.com/etherlabsio/healthcheck/v2/health.go:87.19,89.3 1 5 39 | github.com/etherlabsio/healthcheck/v2/health.go:96.39,97.40 1 4 40 | github.com/etherlabsio/healthcheck/v2/health.go:97.40,98.45 1 4 41 | github.com/etherlabsio/healthcheck/v2/health.go:104.4,104.13 1 4 42 | github.com/etherlabsio/healthcheck/v2/health.go:98.45,103.5 4 4 43 | github.com/etherlabsio/healthcheck/v2/health.go:107.41,108.41 1 2 44 | github.com/etherlabsio/healthcheck/v2/health.go:108.41,109.46 1 2 45 | github.com/etherlabsio/healthcheck/v2/health.go:114.4,114.13 1 2 46 | github.com/etherlabsio/healthcheck/v2/health.go:109.46,113.5 3 2 47 | github.com/etherlabsio/healthcheck/v2/health.go:132.59,134.12 2 6 48 | github.com/etherlabsio/healthcheck/v2/health.go:137.2,137.9 1 6 49 | github.com/etherlabsio/healthcheck/v2/health.go:134.12,136.3 1 6 50 | github.com/etherlabsio/healthcheck/v2/health.go:138.28,139.13 1 5 51 | github.com/etherlabsio/healthcheck/v2/health.go:140.20,141.47 1 1 52 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/etherlabsio/healthcheck/v2 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /health.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type response struct { 13 | Status string `json:"status,omitempty"` 14 | Errors map[string]string `json:"errors,omitempty"` 15 | } 16 | 17 | type health struct { 18 | checkers map[string]Checker 19 | observers map[string]Checker 20 | timeout time.Duration 21 | } 22 | 23 | // Checker checks the status of the dependency and returns error. 24 | // In case the dependency is working as expected, return nil. 25 | type Checker interface { 26 | Check(ctx context.Context) error 27 | } 28 | 29 | // CheckerFunc is a convenience type to create functions that implement the Checker interface. 30 | type CheckerFunc func(ctx context.Context) error 31 | 32 | // Check Implements the Checker interface to allow for any func() error method 33 | // to be passed as a Checker 34 | func (c CheckerFunc) Check(ctx context.Context) error { 35 | return c(ctx) 36 | } 37 | 38 | // Handler returns an http.Handler 39 | func Handler(opts ...Option) http.Handler { 40 | h := &health{ 41 | checkers: make(map[string]Checker), 42 | observers: make(map[string]Checker), 43 | timeout: 30 * time.Second, 44 | } 45 | for _, opt := range opts { 46 | opt(h) 47 | } 48 | return h 49 | } 50 | 51 | // HandlerFunc returns an http.HandlerFunc to mount the API implementation at a specific route 52 | func HandlerFunc(opts ...Option) http.HandlerFunc { 53 | return Handler(opts...).ServeHTTP 54 | } 55 | 56 | // Option adds optional parameter for the HealthcheckHandlerFunc 57 | type Option func(*health) 58 | 59 | // WithChecker adds a status checker that needs to be added as part of healthcheck. i.e database, cache or any external dependency 60 | func WithChecker(name string, s Checker) Option { 61 | return func(h *health) { 62 | h.checkers[name] = &timeoutChecker{s} 63 | } 64 | } 65 | 66 | // WithObserver adds a status checker but it does not fail the entire status. 67 | func WithObserver(name string, s Checker) Option { 68 | return func(h *health) { 69 | h.observers[name] = &timeoutChecker{s} 70 | } 71 | } 72 | 73 | // WithTimeout configures the global timeout for all individual checkers. 74 | func WithTimeout(timeout time.Duration) Option { 75 | return func(h *health) { 76 | h.timeout = timeout 77 | } 78 | } 79 | 80 | func (h *health) ServeHTTP(w http.ResponseWriter, r *http.Request) { 81 | nCheckers := len(h.checkers) + len(h.observers) 82 | 83 | code := http.StatusOK 84 | errorMsgs := make(map[string]string, nCheckers) 85 | 86 | ctx, cancel := context.Background(), func() {} 87 | if h.timeout > 0 { 88 | ctx, cancel = context.WithTimeout(ctx, h.timeout) 89 | } 90 | defer cancel() 91 | 92 | var mutex sync.Mutex 93 | var wg sync.WaitGroup 94 | wg.Add(nCheckers) 95 | 96 | for key, checker := range h.checkers { 97 | go func(key string, checker Checker) { 98 | if err := checker.Check(ctx); err != nil { 99 | mutex.Lock() 100 | errorMsgs[key] = err.Error() 101 | code = http.StatusServiceUnavailable 102 | mutex.Unlock() 103 | } 104 | wg.Done() 105 | }(key, checker) 106 | } 107 | for key, observer := range h.observers { 108 | go func(key string, observer Checker) { 109 | if err := observer.Check(ctx); err != nil { 110 | mutex.Lock() 111 | errorMsgs[key] = err.Error() 112 | mutex.Unlock() 113 | } 114 | wg.Done() 115 | }(key, observer) 116 | } 117 | 118 | wg.Wait() 119 | 120 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 121 | w.WriteHeader(code) 122 | json.NewEncoder(w).Encode(response{ 123 | Status: http.StatusText(code), 124 | Errors: errorMsgs, 125 | }) 126 | } 127 | 128 | type timeoutChecker struct { 129 | checker Checker 130 | } 131 | 132 | func (t *timeoutChecker) Check(ctx context.Context) error { 133 | checkerChan := make(chan error) 134 | go func() { 135 | checkerChan <- t.checker.Check(ctx) 136 | }() 137 | select { 138 | case err := <-checkerChan: 139 | return err 140 | case <-ctx.Done(): 141 | return errors.New("max check time exceeded") 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /health_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestNewHandlerFunc(t *testing.T) { 15 | type args struct { 16 | opts []Option 17 | } 18 | tests := []struct { 19 | name string 20 | args []Option 21 | statusCode int 22 | response response 23 | }{ 24 | { 25 | name: "returns 200 status if no errors", 26 | statusCode: http.StatusOK, 27 | response: response{ 28 | Status: http.StatusText(http.StatusOK), 29 | }, 30 | }, 31 | { 32 | name: "returns 503 status if errors", 33 | statusCode: http.StatusServiceUnavailable, 34 | args: []Option{ 35 | WithChecker("database", CheckerFunc(func(ctx context.Context) error { 36 | return fmt.Errorf("connection to db timed out") 37 | })), 38 | WithChecker("testService", CheckerFunc(func(ctx context.Context) error { 39 | return fmt.Errorf("connection refused") 40 | })), 41 | }, 42 | response: response{ 43 | Status: http.StatusText(http.StatusServiceUnavailable), 44 | Errors: map[string]string{ 45 | "database": "connection to db timed out", 46 | "testService": "connection refused", 47 | }, 48 | }, 49 | }, 50 | { 51 | name: "returns 503 status if checkers timeout", 52 | statusCode: http.StatusServiceUnavailable, 53 | args: []Option{ 54 | WithTimeout(1 * time.Millisecond), 55 | WithChecker("database", CheckerFunc(func(ctx context.Context) error { 56 | time.Sleep(10 * time.Millisecond) 57 | return nil 58 | })), 59 | }, 60 | response: response{ 61 | Status: http.StatusText(http.StatusServiceUnavailable), 62 | Errors: map[string]string{ 63 | "database": "max check time exceeded", 64 | }, 65 | }, 66 | }, 67 | { 68 | name: "returns 200 status if errors are observable", 69 | statusCode: http.StatusOK, 70 | args: []Option{ 71 | WithObserver("observableService", CheckerFunc(func(ctx context.Context) error { 72 | return fmt.Errorf("i fail but it is okay") 73 | })), 74 | }, 75 | response: response{ 76 | Status: http.StatusText(http.StatusOK), 77 | Errors: map[string]string{ 78 | "observableService": "i fail but it is okay", 79 | }, 80 | }, 81 | }, 82 | { 83 | name: "returns 503 status if errors with observable fails", 84 | statusCode: http.StatusServiceUnavailable, 85 | args: []Option{ 86 | WithObserver("database", CheckerFunc(func(ctx context.Context) error { 87 | return fmt.Errorf("connection to db timed out") 88 | })), 89 | WithChecker("testService", CheckerFunc(func(ctx context.Context) error { 90 | return fmt.Errorf("connection refused") 91 | })), 92 | }, 93 | response: response{ 94 | Status: http.StatusText(http.StatusServiceUnavailable), 95 | Errors: map[string]string{ 96 | "database": "connection to db timed out", 97 | "testService": "connection refused", 98 | }, 99 | }, 100 | }, 101 | } 102 | for _, tt := range tests { 103 | t.Run(tt.name, func(t *testing.T) { 104 | res := httptest.NewRecorder() 105 | req, err := http.NewRequest("GET", "http://localhost/health", nil) 106 | if err != nil { 107 | t.Errorf("Failed to create request.") 108 | } 109 | HandlerFunc(tt.args...)(res, req) 110 | if res.Code != tt.statusCode { 111 | t.Errorf("expected code %d, got %d", tt.statusCode, res.Code) 112 | } 113 | var respBody response 114 | if err := json.NewDecoder(res.Body).Decode(&respBody); err != nil { 115 | t.Fatal("failed to parse the body") 116 | } 117 | if !reflect.DeepEqual(respBody, tt.response) { 118 | t.Errorf("NewHandlerFunc() = %v, want %v", respBody, tt.response) 119 | } 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etherlabsio/healthcheck/505bb25053eb76626b893169e61b424d51ee76f0/logo.png --------------------------------------------------------------------------------