├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── api.go ├── api_checks.go ├── api_checks_test.go ├── go.mod ├── go.sum ├── healthcheck.go ├── healthcheck_options.go ├── healthcheck_test.go ├── internal └── logr │ └── ring.go ├── private.go ├── server.go ├── server_mock_test.go ├── server_options.go ├── server_test.go └── structs.go /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: 7 | - '*' 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | test: 13 | name: Test on Go ${{ matrix.go-version }} and ${{ matrix.os }} 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | go-version: [ '1.21', '1.22', '1.23' ] 18 | os: [ ubuntu-latest, macOS-latest ] 19 | 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Go ${{ matrix.go-version }} 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.go-version }} 28 | 29 | - name: Get dependencies 30 | run: go mod download 31 | 32 | - name: Run tests with race detector 33 | run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 34 | 35 | - name: Upload coverage to Codecov 36 | # Only upload coverage once per Go version (from Ubuntu) 37 | if: matrix.os == 'ubuntu-latest' 38 | uses: codecov/codecov-action@v4 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | file: ./coverage.txt 42 | flags: unittests 43 | name: codecov-go${{ matrix.go-version }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | ### Go template 4 | # If you prefer the allow list template instead of the deny list, see community template: 5 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 6 | # 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | 26 | ### macOS template 27 | # General 28 | .DS_Store 29 | .AppleDouble 30 | .LSOverride 31 | 32 | # Icon must end with two \r 33 | Icon 34 | 35 | # Thumbnails 36 | ._* 37 | 38 | # Files that might appear in the root of a volume 39 | .DocumentRevisions-V100 40 | .fseventsd 41 | .Spotlight-V100 42 | .TemporaryItems 43 | .Trashes 44 | .VolumeIcon.icns 45 | .com.apple.timemachine.donotpresent 46 | 47 | # Directories potentially created on remote AFP share 48 | .AppleDB 49 | .AppleDesktop 50 | Network Trash Folder 51 | Temporary Items 52 | .apdisk 53 | 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kirill Zhuravlev 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 | # Healthcheck for Go Applications 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/kazhuravlev/healthcheck.svg)](https://pkg.go.dev/github.com/kazhuravlev/healthcheck) 4 | [![License](https://img.shields.io/github/license/kazhuravlev/healthcheck?color=blue)](https://github.com/kazhuravlev/healthcheck/blob/master/LICENSE) 5 | [![Build Status](https://github.com/kazhuravlev/healthcheck/actions/workflows/tests.yml/badge.svg)](https://github.com/kazhuravlev/healthcheck/actions/workflows/tests.yml) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/kazhuravlev/healthcheck)](https://goreportcard.com/report/github.com/kazhuravlev/healthcheck) 7 | [![CodeCov](https://codecov.io/gh/kazhuravlev/healthcheck/branch/master/graph/badge.svg?token=tNKcOjlxLo)](https://codecov.io/gh/kazhuravlev/healthcheck) 8 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go#utilities) 9 | 10 | A production-ready health check library for Go applications that enables proper monitoring and graceful degradation in 11 | modern cloud environments, 12 | especially [Kubernetes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/). 13 | 14 | ## Why Health Checks Matter 15 | 16 | Health checks are critical for building resilient, self-healing applications in distributed systems. They provide: 17 | 18 | 1. **Automatic Recovery**: In Kubernetes, failed health checks trigger automatic pod restarts, ensuring your application 19 | recovers from transient failures without manual intervention. 20 | 2. **Load Balancer Integration**: Health checks prevent traffic from being routed to unhealthy instances, maintaining 21 | service quality even during partial outages. 22 | 3. **Graceful Degradation**: By monitoring dependencies (databases, caches, external APIs), your application can degrade 23 | gracefully when non-critical services fail. 24 | 4. **Operational Visibility**: Health endpoints provide instant insight into system state, making debugging and incident 25 | response faster. 26 | 5. **Zero-Downtime Deployments**: Readiness checks ensure new deployments only receive traffic when fully initialized. 27 | 28 | ## Features 29 | 30 | - **Multiple Check Types**: Basic (sync), Manual, and Background (async) checks for different use cases 31 | - **Kubernetes Native**: Built-in `/live` and `/ready` endpoints following k8s conventions 32 | - **JSON Status Reports**: Detailed health status with history for debugging 33 | - **Metrics Integration**: Callbacks for Prometheus or other monitoring systems 34 | - **Thread-Safe**: Concurrent-safe operations with proper synchronization 35 | - **Graceful Shutdown**: Proper cleanup of background checks 36 | - **Check History**: Last 5 states stored for each check for debugging 37 | 38 | ## Installation 39 | 40 | ```shell 41 | go get -u github.com/kazhuravlev/healthcheck 42 | ``` 43 | 44 | ## Quick Start 45 | 46 | ```go 47 | package main 48 | 49 | import ( 50 | "context" 51 | "errors" 52 | "math/rand" 53 | "time" 54 | 55 | "github.com/kazhuravlev/healthcheck" 56 | ) 57 | 58 | func main() { 59 | ctx := context.TODO() 60 | 61 | // 1. Create healthcheck instance 62 | hc, _ := healthcheck.New() 63 | 64 | // 2. Register a simple check 65 | hc.Register(ctx, healthcheck.NewBasic("redis", time.Second, func(ctx context.Context) error { 66 | if rand.Float64() > 0.5 { 67 | return errors.New("service is not available") 68 | } 69 | return nil 70 | })) 71 | 72 | // 3. Start HTTP server 73 | server, _ := healthcheck.NewServer(hc, healthcheck.WithPort(8080)) 74 | _ = server.Run(ctx) 75 | 76 | // 4. Check health at http://localhost:8080/ready 77 | select {} 78 | } 79 | ``` 80 | 81 | ## Types of Health Checks 82 | 83 | ### 1. Basic Checks (Synchronous) 84 | 85 | Basic checks run on-demand when the `/ready` endpoint is called. Use these for: 86 | 87 | - Fast operations (< 1 second) 88 | - Checks that need fresh data 89 | - Low-cost operations 90 | 91 | ```go 92 | // Database connectivity check 93 | dbCheck := healthcheck.NewBasic("postgres", time.Second, func (ctx context.Context) error { 94 | return db.PingContext(ctx) 95 | }) 96 | ``` 97 | 98 | ### 2. Background Checks (Asynchronous) 99 | 100 | Background checks run periodically in a separate goroutine (in background mode). Use these for: 101 | 102 | - Expensive operations (API calls, complex queries) 103 | - Checks with rate limits (when checks running rarely than k8s requests to `/ready`) 104 | - Operations that can use slightly stale data 105 | 106 | ```go 107 | // External API health check - runs every 30 seconds 108 | apiCheck := healthcheck.NewBackground( 109 | "payment-api", 110 | nil, // initial error state 111 | 5*time.Second, // initial delay 112 | 30*time.Second, // check interval 113 | 5*time.Second, // timeout per check 114 | func (ctx context.Context) error { 115 | resp, err := client.Get("https://api.payment.com/health") 116 | if err != nil { 117 | return err 118 | } 119 | defer resp.Body.Close() 120 | if resp.StatusCode != 200 { 121 | return errors.New("unhealthy") 122 | } 123 | return nil 124 | }, 125 | ) 126 | ``` 127 | 128 | ### 3. Manual Checks 129 | 130 | Manual checks are controlled by your application logic. Use these for: 131 | 132 | - Initialization states (cache warming, data loading) 133 | - Circuit breaker patterns 134 | - Feature flags 135 | 136 | ```go 137 | // Cache warming check 138 | cacheCheck := healthcheck.NewManual("cache-warmed") 139 | hc.Register(ctx, cacheCheck) 140 | 141 | // Set unhealthy during startup 142 | cacheCheck.SetErr(errors.New("cache warming in progress")) 143 | 144 | // After cache is warmed 145 | cacheCheck.SetErr(nil) 146 | ``` 147 | 148 | ## Best Practices 149 | 150 | ### 1. Choose the Right Check Type 151 | 152 | | Scenario | Check Type | Why | 153 | |---------------------|------------|------------------------------| 154 | | Database ping | Basic | Fast, needs fresh data | 155 | | File system check | Basic | Fast, local operation | 156 | | External API health | Background | Expensive, rate-limited | 157 | | Message queue depth | Background | Metrics query, can be stale | 158 | | Cache warmup status | Manual | Application-controlled state | 159 | 160 | ### 2. Set Appropriate Timeouts 161 | 162 | ```go 163 | // ❌ Bad: Too long timeout blocks readiness. Timeout should less than timeout in k8s 164 | healthcheck.NewBasic("db", 30*time.Second, checkFunc) 165 | 166 | // ✅ Good: Short timeout 167 | healthcheck.NewBasic("db", 1*time.Second, checkFunc) 168 | ``` 169 | 170 | ### 3. Use Status Codes Correctly 171 | 172 | - **Liveness** (`/live`): Should almost always return 200 OK 173 | - Only fail if the application is in an unrecoverable state 174 | - Kubernetes will restart the pod on failure 175 | 176 | - **Readiness** (`/ready`): Should fail when: 177 | - Critical dependencies are unavailable 178 | - Application is still initializing 179 | - Application is shutting down 180 | 181 | ### 4. Add Context to Errors 182 | 183 | ```go 184 | func checkDatabase(ctx context.Context) error { 185 | if err := db.PingContext(ctx); err != nil { 186 | // Use fmt.Errorf to add context. It will be available in /ready report 187 | return fmt.Errorf("postgres connection failed: %w", err) 188 | } 189 | 190 | return nil 191 | } 192 | ``` 193 | 194 | ### 5. Monitor Checks 195 | 196 | ```go 197 | hc, _ := healthcheck.New( 198 | healthcheck.WithCheckStatusHook(func (name string, status healthcheck.Status) { 199 | // hcMetric can be a prometheus metric - it is up to your infrastructure 200 | hcMetric.WithLabelValues(name, string(status)).Set(1) 201 | }), 202 | ) 203 | ``` 204 | 205 | ## Complete Example 206 | 207 | ```go 208 | package main 209 | 210 | import ( 211 | "context" 212 | "database/sql" 213 | "fmt" 214 | "log" 215 | "time" 216 | 217 | "github.com/kazhuravlev/healthcheck" 218 | _ "github.com/lib/pq" 219 | ) 220 | 221 | func main() { 222 | ctx := context.Background() 223 | 224 | // Initialize dependencies 225 | db, err := sql.Open("postgres", "postgres://localhost/myapp") 226 | if err != nil { 227 | log.Fatal(err) 228 | } 229 | 230 | // Create healthcheck 231 | hc, _ := healthcheck.New() 232 | 233 | // 1. Database check - synchronous, critical 234 | hc.Register(ctx, healthcheck.NewBasic("postgres", time.Second, func(ctx context.Context) error { 235 | return db.PingContext(ctx) 236 | })) 237 | 238 | // 2. Cache warmup - manual control 239 | cacheReady := healthcheck.NewManual("cache") 240 | hc.Register(ctx, cacheReady) 241 | cacheReady.SetErr(fmt.Errorf("warming up")) 242 | 243 | // 3. External API - background check 244 | hc.Register(ctx, healthcheck.NewBackground( 245 | "payment-provider", 246 | nil, 247 | 10*time.Second, // initial delay 248 | 30*time.Second, // check interval 249 | 5*time.Second, // timeout 250 | checkPaymentProvider, 251 | )) 252 | 253 | // Start health check server 254 | server, _ := healthcheck.NewServer(hc, healthcheck.WithPort(8080)) 255 | if err := server.Run(ctx); err != nil { 256 | log.Fatal(err) 257 | } 258 | 259 | // Simulate cache warmup completion 260 | go func() { 261 | time.Sleep(5 * time.Second) 262 | cacheReady.SetErr(nil) 263 | log.Println("Cache warmed up") 264 | }() 265 | 266 | log.Println("Health checks available at:") 267 | log.Println(" - http://localhost:8080/live") 268 | log.Println(" - http://localhost:8080/ready") 269 | 270 | select {} 271 | } 272 | 273 | func checkPaymentProvider(ctx context.Context) error { 274 | // Implementation of payment provider check 275 | return nil 276 | } 277 | ``` 278 | 279 | ## Integration with Kubernetes 280 | 281 | ```yaml 282 | apiVersion: v1 283 | kind: Pod 284 | spec: 285 | containers: 286 | - name: app 287 | livenessProbe: 288 | httpGet: 289 | path: /live 290 | port: 8080 291 | initialDelaySeconds: 10 292 | periodSeconds: 10 293 | timeoutSeconds: 5 294 | failureThreshold: 3 295 | readinessProbe: 296 | httpGet: 297 | path: /ready 298 | port: 8080 299 | initialDelaySeconds: 5 300 | periodSeconds: 5 301 | timeoutSeconds: 3 302 | failureThreshold: 2 303 | ``` 304 | 305 | ## Response Format 306 | 307 | The `/ready` endpoint returns detailed JSON with check history: 308 | 309 | ```json 310 | { 311 | "status": "up", 312 | "checks": [ 313 | { 314 | "name": "postgres", 315 | "state": { 316 | "status": "up", 317 | "error": "", 318 | "timestamp": "2024-01-15T10:30:00Z" 319 | }, 320 | "history": [ 321 | { 322 | "status": "up", 323 | "error": "", 324 | "timestamp": "2024-01-15T10:29:55Z" 325 | } 326 | ] 327 | } 328 | ] 329 | } 330 | ``` 331 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "sync" 7 | ) 8 | 9 | // Register will register a check. 10 | // 11 | // All checks should have a name. Will be better that name will contain only lowercase symbols and lodash. 12 | // This is allowing to have the same name for Check and for metrics. 13 | func (s *Healthcheck) Register(ctx context.Context, check ICheck) { 14 | s.checksMu.Lock() 15 | defer s.checksMu.Unlock() 16 | 17 | checkID, ok := name2id(check.id()) 18 | if !ok { 19 | s.opts.logger.WarnContext(ctx, "choose a better name for check. see docs of Register method", 20 | slog.String("name", check.id()), 21 | slog.String("better_name", checkID)) 22 | } 23 | 24 | CheckID: 25 | for i := range s.checks { 26 | if s.checks[i].ID == checkID { 27 | newID := checkID + "_x" 28 | s.opts.logger.WarnContext(ctx, "check name is duplicated. add prefix", 29 | slog.String("name", check.id()), 30 | slog.String("new_name", newID)) 31 | checkID = newID 32 | 33 | goto CheckID 34 | } 35 | } 36 | 37 | switch check := check.(type) { 38 | case *bgCheck: 39 | check.run(ctx) 40 | } 41 | 42 | s.checks = append(s.checks, checkContainer{ 43 | ID: checkID, 44 | Check: check, 45 | }) 46 | } 47 | 48 | // RunAllChecks will run all check immediately. 49 | func (s *Healthcheck) RunAllChecks(ctx context.Context) Report { 50 | s.checksMu.RLock() 51 | checksCopy := make([]checkContainer, len(s.checks)) 52 | copy(checksCopy, s.checks) 53 | s.checksMu.RUnlock() 54 | 55 | checks := make([]Check, len(checksCopy)) 56 | { 57 | wg := new(sync.WaitGroup) 58 | wg.Add(len(checksCopy)) 59 | 60 | // TODO(zhuravlev): do not run goroutines for checks like manual and bg check. 61 | for i := range checksCopy { 62 | go func(i int, check checkContainer) { 63 | defer wg.Done() 64 | 65 | checks[i] = s.runCheck(ctx, check) 66 | }(i, checksCopy[i]) 67 | } 68 | 69 | wg.Wait() 70 | } 71 | 72 | status := StatusUp 73 | for _, check := range checks { 74 | if check.State.Status == StatusDown { 75 | status = StatusDown 76 | break 77 | } 78 | } 79 | 80 | return Report{ 81 | Status: status, 82 | Checks: checks, 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /api_checks.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/kazhuravlev/healthcheck/internal/logr" 7 | "time" 8 | ) 9 | 10 | var ( 11 | _ ICheck = (*basicCheck)(nil) 12 | _ ICheck = (*manualCheck)(nil) 13 | _ ICheck = (*bgCheck)(nil) 14 | ) 15 | 16 | // errInitial used as initial error for some checks. 17 | var errInitial = errors.New("initial") 18 | 19 | type basicCheck struct { 20 | name string 21 | ttl time.Duration 22 | fn CheckFn 23 | logg *logr.Ring 24 | } 25 | 26 | // NewBasic creates a basic check. This check will only be performed when RunAllChecks is called. 27 | // 28 | // hc, _ := healthcheck.New(...) 29 | // hc.Register(healthcheck.NewBasic("postgres", time.Second, func(context.Context) error { ... })) 30 | func NewBasic(name string, timeout time.Duration, fn CheckFn) *basicCheck { 31 | return &basicCheck{ 32 | name: name, 33 | ttl: timeout, 34 | fn: fn, 35 | logg: logr.New(), 36 | } 37 | } 38 | 39 | func (c *basicCheck) id() string { return c.name } 40 | func (c *basicCheck) timeout() time.Duration { return c.ttl } 41 | func (c *basicCheck) check(ctx context.Context) logr.Rec { 42 | res := logr.Rec{ 43 | Time: time.Now(), 44 | Error: c.fn(ctx), 45 | } 46 | c.logg.Put(res) 47 | 48 | return res 49 | } 50 | func (c *basicCheck) log() []logr.Rec { 51 | return c.logg.SlicePrev() 52 | } 53 | 54 | type manualCheck struct { 55 | name string 56 | logg *logr.Ring 57 | } 58 | 59 | // NewManual create new check, that can be managed by client. Marked as failed by default. 60 | // 61 | // hc, _ := healthcheck.New(...) 62 | // check := healthcheck.NewManual("some_subsystem") 63 | // check.SetError(nil) 64 | // hc.Register(check) 65 | // check.SetError(errors.New("service unavailable")) 66 | func NewManual(name string) *manualCheck { 67 | check := &manualCheck{ 68 | name: name, 69 | logg: logr.New(), 70 | } 71 | 72 | check.SetErr(errInitial) 73 | 74 | return check 75 | } 76 | 77 | func (c *manualCheck) SetErr(err error) { 78 | c.logg.Put(logr.Rec{ 79 | Time: time.Now(), 80 | Error: err, 81 | }) 82 | } 83 | 84 | func (c *manualCheck) id() string { return c.name } 85 | func (c *manualCheck) timeout() time.Duration { return time.Hour } 86 | func (c *manualCheck) check(_ context.Context) logr.Rec { 87 | rec, ok := c.logg.GetLast() 88 | if !ok { 89 | panic("manual check must have initial state") 90 | } 91 | 92 | return rec 93 | } 94 | func (c *manualCheck) log() []logr.Rec { 95 | return c.logg.SlicePrev() 96 | } 97 | 98 | type bgCheck struct { 99 | name string 100 | period time.Duration 101 | delay time.Duration 102 | ttl time.Duration 103 | fn CheckFn 104 | logg *logr.Ring 105 | } 106 | 107 | // NewBackground will create a check that runs in background. Usually used for slow or expensive checks. 108 | // Note: period should be greater than timeout. 109 | // 110 | // hc, _ := healthcheck.New(...) 111 | // hc.Register(healthcheck.NewBackground("some_subsystem")) 112 | func NewBackground(name string, initialErr error, delay, period, timeout time.Duration, fn CheckFn) *bgCheck { 113 | check := &bgCheck{ 114 | name: name, 115 | period: period, 116 | delay: delay, 117 | ttl: timeout, 118 | fn: fn, 119 | logg: logr.New(), 120 | } 121 | 122 | check.logg.Put(logr.Rec{ 123 | Time: time.Now(), 124 | Error: initialErr, 125 | }) 126 | 127 | return check 128 | } 129 | 130 | func (c *bgCheck) run(ctx context.Context) { 131 | go func() { 132 | time.Sleep(c.delay) 133 | 134 | t := time.NewTicker(c.period) 135 | defer t.Stop() 136 | 137 | for { 138 | func() { 139 | ctx, cancel := context.WithTimeout(ctx, c.ttl) 140 | defer cancel() 141 | 142 | err := c.fn(ctx) 143 | 144 | c.logg.Put(logr.Rec{ 145 | Time: time.Now(), 146 | Error: err, 147 | }) 148 | }() 149 | 150 | select { 151 | case <-ctx.Done(): 152 | return 153 | case <-t.C: 154 | } 155 | } 156 | }() 157 | } 158 | 159 | func (c *bgCheck) id() string { return c.name } 160 | func (c *bgCheck) timeout() time.Duration { return time.Hour } 161 | func (c *bgCheck) check(_ context.Context) logr.Rec { 162 | val, ok := c.logg.GetLast() 163 | if !ok { 164 | return logr.Rec{ 165 | Time: time.Now(), 166 | Error: nil, 167 | } 168 | } 169 | 170 | return val 171 | } 172 | func (c *bgCheck) log() []logr.Rec { 173 | return c.logg.SlicePrev() 174 | } 175 | -------------------------------------------------------------------------------- /api_checks_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/require" 6 | "io" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestManualCheck2(t *testing.T) { 12 | t.Parallel() 13 | 14 | t.Run("check_constructor", func(t *testing.T) { 15 | t.Parallel() 16 | 17 | check := NewManual("sample") 18 | 19 | require.Equal(t, "sample", check.id()) 20 | require.Equal(t, time.Hour, check.timeout()) 21 | require.ErrorIs(t, check.check(context.TODO()).Error, errInitial) 22 | require.Len(t, check.log(), 0) 23 | }) 24 | 25 | t.Run("set_and_unset_error", func(t *testing.T) { 26 | t.Parallel() 27 | 28 | check := NewManual("sample") 29 | require.Error(t, check.check(context.TODO()).Error) 30 | 31 | check.SetErr(nil) 32 | require.NoError(t, check.check(context.TODO()).Error) 33 | 34 | check.SetErr(io.EOF) 35 | require.ErrorIs(t, check.check(context.TODO()).Error, io.EOF) 36 | 37 | t.Run("check_logs", func(t *testing.T) { 38 | records := check.log() 39 | require.Len(t, records, 2) 40 | 41 | require.NoError(t, records[0].Error) 42 | require.ErrorIs(t, records[1].Error, errInitial) 43 | }) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kazhuravlev/healthcheck 2 | 3 | go 1.22.3 4 | 5 | require ( 6 | github.com/kazhuravlev/just v0.70.0 7 | github.com/prometheus/client_golang v1.20.0 8 | github.com/stretchr/testify v1.9.0 9 | go.uber.org/mock v0.4.0 10 | ) 11 | 12 | require ( 13 | github.com/beorn7/perks v1.0.1 // indirect 14 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/fatih/color v1.17.0 // indirect 17 | github.com/gabriel-vasile/mimetype v1.4.4 // indirect 18 | github.com/go-playground/validator/v10 v10.21.0 // indirect 19 | github.com/goccy/go-yaml v1.12.0 // indirect 20 | github.com/klauspost/compress v1.17.9 // indirect 21 | github.com/kr/text v0.2.0 // indirect 22 | github.com/mattn/go-colorable v0.1.13 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/prometheus/client_model v0.6.1 // indirect 27 | github.com/prometheus/common v0.55.0 // indirect 28 | github.com/prometheus/procfs v0.15.1 // indirect 29 | golang.org/x/crypto v0.31.0 // indirect 30 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect 31 | golang.org/x/sys v0.28.0 // indirect 32 | golang.org/x/text v0.21.0 // indirect 33 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect 34 | google.golang.org/protobuf v1.34.2 // indirect 35 | gopkg.in/yaml.v3 v3.0.1 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 9 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 10 | github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= 11 | github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= 12 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 13 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 14 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 15 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 16 | github.com/go-playground/validator/v10 v10.21.0 h1:4fZA11ovvtkdgaeev9RGWPgc1uj3H8W+rNYyH/ySBb0= 17 | github.com/go-playground/validator/v10 v10.21.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 18 | github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM= 19 | github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= 20 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 21 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 22 | github.com/kazhuravlev/just v0.70.0 h1:Dkakxq943SQ6ratRC5O6gxSBEg/3FyTqGNrLmiqrHAw= 23 | github.com/kazhuravlev/just v0.70.0/go.mod h1:0R3XZwnkP5zvLbQ+ARMopVWSfI17Q+j/8y7EhvbWl0w= 24 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 25 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 26 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 27 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 28 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 29 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 30 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 31 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 32 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 33 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 34 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 35 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 36 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 37 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 38 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 39 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 40 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/prometheus/client_golang v1.20.0 h1:jBzTZ7B099Rg24tny+qngoynol8LtVYlA2bqx3vEloI= 44 | github.com/prometheus/client_golang v1.20.0/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 45 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 46 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 47 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 48 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 49 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 50 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 51 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 52 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 53 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 54 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 55 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 56 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 57 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 58 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 59 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= 60 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= 61 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 62 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 63 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 66 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 67 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 68 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 69 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= 70 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 71 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 72 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 74 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 75 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 76 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 77 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | -------------------------------------------------------------------------------- /healthcheck.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "sync" 7 | ) 8 | 9 | type Healthcheck struct { 10 | opts hcOptions 11 | 12 | checksMu *sync.RWMutex 13 | checks []checkContainer 14 | } 15 | 16 | func New(opts ...func(*hcOptions)) (*Healthcheck, error) { 17 | options := hcOptions{ 18 | logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), 19 | setCheckStatus: func(string, Status) {}, 20 | } 21 | for _, opt := range opts { 22 | opt(&options) 23 | } 24 | 25 | return &Healthcheck{ 26 | opts: options, 27 | checksMu: new(sync.RWMutex), 28 | checks: nil, 29 | }, nil 30 | } 31 | -------------------------------------------------------------------------------- /healthcheck_options.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | type hcOptions struct { 4 | logger ILogger 5 | setCheckStatus func(checkID string, isReady Status) 6 | } 7 | 8 | // WithCheckStatusFn will provide a function that will be called at each check changes. 9 | func WithCheckStatusFn(fn func(checkID string, isReady Status)) func(*hcOptions) { 10 | return func(o *hcOptions) { 11 | o.setCheckStatus = fn 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /healthcheck_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | hc "github.com/kazhuravlev/healthcheck" 13 | ) 14 | 15 | var timeNow = time.Date(1, 2, 3, 4, 5, 6, 7, time.UTC) 16 | 17 | func requireNoError(t *testing.T, err error) { 18 | t.Helper() 19 | if err == nil { 20 | return 21 | } 22 | 23 | t.Errorf("Received unexpected error:\n%+v\n", err) 24 | t.FailNow() 25 | } 26 | 27 | func requireTrue(t *testing.T, val bool, msg string, args ...any) { 28 | t.Helper() 29 | if val { 30 | return 31 | } 32 | 33 | t.Error(fmt.Sprintf(msg, args...)) 34 | t.FailNow() 35 | } 36 | 37 | func requireStateEqual(t *testing.T, exp, actual hc.CheckState) { 38 | t.Helper() 39 | 40 | //requireTrue(t, exp.ActualAt.Equal(actual.ActualAt), "unexpected check actual_at") 41 | requireTrue(t, exp.Status == actual.Status, "unexpected check status: exp %s, actual %s", exp.Status, actual.Status) 42 | requireTrue(t, exp.Error == actual.Error, "unexpected check error") 43 | } 44 | 45 | func requireReportEqual(t *testing.T, expected, actual hc.Report) { 46 | t.Helper() 47 | 48 | requireTrue(t, expected.Status == actual.Status, "unexpected status: exp %s, actual %s", expected.Status, actual.Status) 49 | requireTrue(t, len(expected.Checks) == len(actual.Checks), "unexpected checks count") 50 | 51 | for i := range expected.Checks { 52 | requireTrue(t, expected.Checks[i].Name == actual.Checks[i].Name, "unexpected check name") 53 | requireStateEqual(t, expected.Checks[i].State, actual.Checks[i].State) 54 | expLen := len(expected.Checks[i].Previous) 55 | actLen := len(actual.Checks[i].Previous) 56 | requireTrue(t, expLen == actLen, "unexpected previous count error. Exp (%d) Act (%d)", expLen, actLen) 57 | for ii := range expected.Checks[i].Previous { 58 | requireStateEqual(t, expected.Checks[i].Previous[ii], actual.Checks[i].Previous[ii]) 59 | } 60 | } 61 | } 62 | 63 | func simpleCheck(name string, err error) hc.ICheck { //nolint:ireturn,nolintlint 64 | return hc.NewBasic(name, time.Second, func(ctx context.Context) error { return err }) 65 | } 66 | 67 | func hcWithChecks(t *testing.T, checks ...hc.ICheck) *hc.Healthcheck { 68 | t.Helper() 69 | 70 | hcInst, err := hc.New() 71 | requireNoError(t, err) 72 | 73 | for i := range checks { 74 | hcInst.Register(context.TODO(), checks[i]) 75 | } 76 | 77 | return hcInst 78 | } 79 | 80 | func TestManualCheck(t *testing.T) { 81 | t.Parallel() 82 | 83 | manualCheck := hc.NewManual("some_system") 84 | hcInst := hcWithChecks(t, manualCheck) 85 | 86 | t.Run("failed_by_default", func(t *testing.T) { 87 | res := hcInst.RunAllChecks(context.Background()) 88 | requireReportEqual(t, hc.Report{ 89 | Status: hc.StatusDown, 90 | Checks: []hc.Check{ 91 | {Name: "some_system", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "initial"}}, 92 | }, 93 | }, res) 94 | }) 95 | 96 | t.Run("can_be_marked_as_ok", func(t *testing.T) { 97 | manualCheck.SetErr(nil) 98 | 99 | res := hcInst.RunAllChecks(context.Background()) 100 | requireReportEqual(t, hc.Report{ 101 | Status: hc.StatusUp, 102 | Checks: []hc.Check{ 103 | { 104 | Name: "some_system", 105 | State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}, 106 | Previous: []hc.CheckState{ 107 | {ActualAt: timeNow, Status: hc.StatusDown, Error: "initial"}, // from prev test 108 | }, 109 | }, 110 | }, 111 | }, res) 112 | }) 113 | 114 | t.Run("can_be_marked_as_failed", func(t *testing.T) { 115 | manualCheck.SetErr(fmt.Errorf("the sky was falling: %w", io.EOF)) 116 | 117 | res := hcInst.RunAllChecks(context.Background()) 118 | requireReportEqual(t, hc.Report{ 119 | Status: hc.StatusDown, 120 | Checks: []hc.Check{ 121 | { 122 | Name: "some_system", 123 | State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "the sky was falling: EOF"}, 124 | Previous: []hc.CheckState{ 125 | {ActualAt: timeNow, Status: hc.StatusUp, Error: ""}, // from prev test 126 | {ActualAt: timeNow, Status: hc.StatusDown, Error: "initial"}, // from prev test 127 | }, 128 | }, 129 | }, 130 | }, res) 131 | }) 132 | } 133 | 134 | func TestBackgroundCheck(t *testing.T) { 135 | t.Parallel() 136 | 137 | errNotReady := errors.New("not ready") 138 | 139 | curErrorMu := new(sync.Mutex) 140 | var curError error 141 | 142 | delay := 200 * time.Millisecond 143 | bgCheck := hc.NewBackground( 144 | "some_system", 145 | errNotReady, 146 | delay, 147 | delay, 148 | 10*time.Second, 149 | func(ctx context.Context) error { 150 | curErrorMu.Lock() 151 | defer curErrorMu.Unlock() 152 | 153 | return curError 154 | }, 155 | ) 156 | hcInst := hcWithChecks(t, bgCheck) 157 | 158 | t.Run("initial_error_is_used", func(t *testing.T) { 159 | res := hcInst.RunAllChecks(context.Background()) 160 | requireReportEqual(t, hc.Report{ 161 | Status: hc.StatusDown, 162 | Checks: []hc.Check{ 163 | {Name: "some_system", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "not ready"}}, 164 | }, 165 | }, res) 166 | }) 167 | 168 | // wait for bg check next run 169 | time.Sleep(delay) 170 | 171 | t.Run("check_current_error_nil", func(t *testing.T) { 172 | res := hcInst.RunAllChecks(context.Background()) 173 | requireReportEqual(t, hc.Report{ 174 | Status: hc.StatusUp, 175 | Checks: []hc.Check{ 176 | { 177 | Name: "some_system", 178 | State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}, 179 | Previous: []hc.CheckState{ 180 | {ActualAt: timeNow, Status: hc.StatusDown, Error: "not ready"}, // from prev test 181 | }, 182 | }, 183 | }, 184 | }, res) 185 | }) 186 | 187 | // set error 188 | curErrorMu.Lock() 189 | curError = io.EOF 190 | curErrorMu.Unlock() 191 | // wait for bg check next run 192 | time.Sleep(delay) 193 | 194 | t.Run("change_status_after_each_run", func(t *testing.T) { 195 | res := hcInst.RunAllChecks(context.Background()) 196 | requireReportEqual(t, hc.Report{ 197 | Status: hc.StatusDown, 198 | Checks: []hc.Check{ 199 | { 200 | Name: "some_system", 201 | State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "EOF"}, 202 | Previous: []hc.CheckState{ 203 | {ActualAt: timeNow, Status: hc.StatusUp, Error: ""}, // from prev test 204 | {ActualAt: timeNow, Status: hc.StatusDown, Error: "not ready"}, // from prev test 205 | }, 206 | }, 207 | }, 208 | }, res) 209 | }) 210 | 211 | } 212 | 213 | func TestService(t *testing.T) { //nolint:funlen 214 | t.Run("empty_healthcheck_have_status_up", func(t *testing.T) { 215 | t.Parallel() 216 | 217 | res := hcWithChecks(t).RunAllChecks(context.Background()) 218 | requireReportEqual(t, hc.Report{ 219 | Status: hc.StatusUp, 220 | Checks: []hc.Check{}, 221 | }, res) 222 | }) 223 | 224 | t.Run("fail_when_context_cancelled", func(t *testing.T) { 225 | t.Parallel() 226 | 227 | ctx, cancel := context.WithCancel(context.Background()) 228 | cancel() 229 | 230 | res := hcWithChecks(t, simpleCheck("always_ok", nil)).RunAllChecks(ctx) 231 | requireReportEqual(t, hc.Report{ 232 | Status: hc.StatusDown, 233 | Checks: []hc.Check{ 234 | {Name: "always_ok", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "context canceled"}}, 235 | }, 236 | }, res) 237 | }) 238 | 239 | t.Run("normalize_check_names", func(t *testing.T) { 240 | t.Parallel() 241 | 242 | res := hcWithChecks(t, 243 | simpleCheck("Check1", nil), 244 | simpleCheck("CHECK2", nil), 245 | simpleCheck("Check-3", nil), 246 | ).RunAllChecks(context.Background()) 247 | requireReportEqual(t, hc.Report{ 248 | Status: hc.StatusUp, 249 | Checks: []hc.Check{ 250 | {Name: "check1", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 251 | {Name: "check2", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 252 | {Name: "check_3", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 253 | }, 254 | }, res) 255 | }) 256 | 257 | t.Run("non_unique_names_will_be_unique", func(t *testing.T) { 258 | t.Parallel() 259 | 260 | res := hcWithChecks(t, 261 | simpleCheck("Check1", nil), 262 | simpleCheck("CHECK1", nil), 263 | simpleCheck("Check1", nil), 264 | simpleCheck("check1", nil), 265 | ).RunAllChecks(context.Background()) 266 | requireReportEqual(t, hc.Report{ 267 | Status: hc.StatusUp, 268 | Checks: []hc.Check{ 269 | {Name: "check1", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 270 | {Name: "check1_x", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 271 | {Name: "check1_x_x", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 272 | {Name: "check1_x_x_x", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 273 | }, 274 | }, res) 275 | }) 276 | 277 | t.Run("fail_when_at_least_one_check_failed", func(t *testing.T) { 278 | t.Parallel() 279 | 280 | hcInst := hcWithChecks(t, 281 | simpleCheck("always_ok", nil), 282 | simpleCheck("always_ok", nil), 283 | hc.NewBasic( 284 | "context_timeout", 285 | time.Millisecond, 286 | func(ctx context.Context) error { 287 | time.Sleep(time.Second) 288 | return nil //nolint:nlreturn 289 | }, 290 | ), 291 | ) 292 | 293 | res := hcInst.RunAllChecks(context.Background()) 294 | requireReportEqual(t, hc.Report{ 295 | Status: hc.StatusDown, 296 | Checks: []hc.Check{ 297 | {Name: "always_ok", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 298 | {Name: "always_ok_x", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}}, 299 | {Name: "context_timeout", State: hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "context deadline exceeded"}}, 300 | }, 301 | }, res) 302 | }) 303 | } 304 | 305 | func TestPrevious(t *testing.T) { //nolint:funlen 306 | t.Parallel() 307 | 308 | manualCheck := hc.NewManual("x") 309 | hcInst := hcWithChecks(t, manualCheck) 310 | 311 | t.Run("run_all_checks_ignore_manual_checks", func(t *testing.T) { 312 | hcInst.RunAllChecks(context.Background()) 313 | hcInst.RunAllChecks(context.Background()) 314 | hcInst.RunAllChecks(context.Background()) 315 | report := hcInst.RunAllChecks(context.Background()) 316 | 317 | requireTrue(t, len(report.Checks[0].Previous) == 0, "RunAllChecks should not affect log of states for manual check") 318 | }) 319 | 320 | t.Run("previous_will_filled_on_each_manual_change", func(t *testing.T) { 321 | manualCheck.SetErr(nil) 322 | manualCheck.SetErr(io.EOF) 323 | manualCheck.SetErr(io.ErrUnexpectedEOF) 324 | 325 | report := hcInst.RunAllChecks(context.Background()) 326 | 327 | check := report.Checks[0] 328 | requireStateEqual(t, hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "unexpected EOF"}, check.State) 329 | 330 | prev := check.Previous 331 | requireTrue(t, len(prev) == 3, "no error, eof, unexpected eof") 332 | requireStateEqual(t, hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "EOF"}, prev[0]) 333 | requireStateEqual(t, hc.CheckState{ActualAt: timeNow, Status: hc.StatusUp, Error: ""}, prev[1]) 334 | requireStateEqual(t, hc.CheckState{ActualAt: timeNow, Status: hc.StatusDown, Error: "initial"}, prev[2]) 335 | }) 336 | } 337 | 338 | func TestServiceMetrics(t *testing.T) { //nolint:paralleltest 339 | res := make(map[string]hc.Status) 340 | mu := new(sync.Mutex) 341 | setStatus := func(id string, status hc.Status) { 342 | mu.Lock() 343 | res[id] = status 344 | mu.Unlock() 345 | } 346 | 347 | hcInst, err := hc.New(hc.WithCheckStatusFn(setStatus)) 348 | requireNoError(t, err) 349 | 350 | hcInst.Register(context.TODO(), hc.NewBasic("check_without_error", time.Second, func(ctx context.Context) error { return nil })) 351 | hcInst.Register(context.TODO(), hc.NewBasic("check_with_error", time.Second, func(ctx context.Context) error { return io.EOF })) 352 | 353 | _ = hcInst.RunAllChecks(context.Background()) 354 | 355 | requireTrue(t, len(res) == 2, "response must contains two elems") 356 | requireTrue(t, res["check_without_error"] == hc.StatusUp, "response without error must have status UP") 357 | requireTrue(t, res["check_with_error"] == hc.StatusDown, "response without error must have status UP") 358 | } 359 | 360 | func TestBackgroundCheckStop(t *testing.T) { 361 | var mu sync.Mutex 362 | callCount := 0 363 | check := hc.NewBackground("test_bg", nil, 0, 100*time.Millisecond, time.Second, func(ctx context.Context) error { 364 | mu.Lock() 365 | callCount++ 366 | mu.Unlock() 367 | return nil 368 | }) 369 | 370 | hcInst, err := hc.New() 371 | requireTrue(t, err == nil, "new should not produce error") 372 | ctx, cancel := context.WithCancel(context.Background()) 373 | hcInst.Register(ctx, check) 374 | 375 | // Wait for a run 376 | time.Sleep(350 * time.Millisecond) 377 | 378 | cancel() 379 | 380 | mu.Lock() 381 | initialCount := callCount 382 | mu.Unlock() 383 | 384 | // Wait a bit more 385 | time.Sleep(200 * time.Millisecond) 386 | 387 | // Ensure no more calls were made after Stop 388 | mu.Lock() 389 | finalCount := callCount 390 | mu.Unlock() 391 | 392 | requireTrue(t, initialCount == finalCount, "background check should not run after Stop()") 393 | requireTrue(t, finalCount >= 3, "expected at least 3 calls before stop") 394 | } 395 | -------------------------------------------------------------------------------- /internal/logr/ring.go: -------------------------------------------------------------------------------- 1 | package logr 2 | 3 | import ( 4 | "container/ring" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | const maxStatesToStore = 5 10 | 11 | type Ring struct { 12 | mu *sync.RWMutex 13 | data *ring.Ring 14 | } 15 | 16 | func New() *Ring { 17 | return &Ring{ 18 | mu: new(sync.RWMutex), 19 | data: ring.New(maxStatesToStore), 20 | } 21 | } 22 | 23 | func (r *Ring) Put(rec Rec) { 24 | r.mu.Lock() 25 | defer r.mu.Unlock() 26 | 27 | r.data.Value = rec 28 | r.data = r.data.Prev() 29 | } 30 | 31 | func (r *Ring) GetLast() (Rec, bool) { 32 | r.mu.RLock() 33 | defer r.mu.RUnlock() 34 | 35 | last := r.data.Next() 36 | 37 | if last.Value == nil { 38 | return Rec{}, false 39 | } 40 | 41 | return last.Value.(Rec), true 42 | } 43 | 44 | func (r *Ring) SlicePrev() []Rec { 45 | r.mu.RLock() 46 | defer r.mu.RUnlock() 47 | 48 | res := make([]Rec, 0, r.data.Len()) 49 | r.data.Do(func(val any) { 50 | if val == nil { 51 | return 52 | } 53 | 54 | res = append(res, val.(Rec)) 55 | }) 56 | 57 | if len(res) == 0 { 58 | return nil 59 | } 60 | 61 | return res[1:] 62 | } 63 | 64 | type Rec struct { 65 | Time time.Time 66 | Error error 67 | } 68 | -------------------------------------------------------------------------------- /private.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "github.com/kazhuravlev/healthcheck/internal/logr" 6 | "github.com/kazhuravlev/just" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | func (s *Healthcheck) runCheck(ctx context.Context, check checkContainer) Check { 12 | ctx, cancel := context.WithTimeout(ctx, check.Check.timeout()) 13 | defer cancel() 14 | 15 | rec := logr.Rec{ 16 | Time: time.Now(), 17 | Error: nil, 18 | } 19 | 20 | { 21 | resCh := make(chan logr.Rec, 1) 22 | go func() { 23 | defer close(resCh) 24 | resCh <- check.Check.check(ctx) 25 | }() 26 | 27 | select { 28 | case <-ctx.Done(): 29 | rec = logr.Rec{ 30 | Time: time.Now(), 31 | Error: ctx.Err(), 32 | } 33 | case rec = <-resCh: 34 | } 35 | } 36 | 37 | status := StatusUp 38 | errText := "" 39 | if rec.Error != nil { 40 | status = StatusDown 41 | errText = rec.Error.Error() 42 | } 43 | 44 | // TODO(zhuravlev): run on manual and bg checks. 45 | s.opts.setCheckStatus(check.ID, status) 46 | 47 | prev := just.SliceMap(check.Check.log(), func(rec logr.Rec) CheckState { 48 | status := StatusUp 49 | errText := "" 50 | 51 | if rec.Error != nil { 52 | errText = rec.Error.Error() 53 | status = StatusDown 54 | } 55 | 56 | return CheckState{ 57 | ActualAt: rec.Time, 58 | Status: status, 59 | Error: errText, 60 | } 61 | }) 62 | 63 | return Check{ 64 | Name: check.ID, 65 | State: CheckState{ 66 | ActualAt: rec.Time, 67 | Status: status, 68 | Error: errText, 69 | }, 70 | Previous: prev, 71 | } 72 | } 73 | 74 | func name2id(name string) (string, bool) { 75 | id := strings.ReplaceAll(strings.ToLower(name), "-", "_") 76 | 77 | return id, id == name 78 | } 79 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/prometheus/client_golang/prometheus/promhttp" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | type Server struct { 15 | opts serverOptions 16 | } 17 | 18 | func NewServer(hc IHealthcheck, opts ...func(*serverOptions)) (*Server, error) { 19 | options := serverOptions{ 20 | port: 8000, 21 | healthcheck: hc, 22 | logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), 23 | } 24 | 25 | for _, opt := range opts { 26 | opt(&options) 27 | } 28 | 29 | return &Server{opts: options}, nil 30 | } 31 | 32 | func (s *Server) Run(ctx context.Context) error { 33 | mux := http.NewServeMux() 34 | 35 | mux.HandleFunc("/live", LiveHandler()) 36 | mux.HandleFunc("/ready", ReadyHandler(s.opts.healthcheck)) 37 | mux.Handle("/metrics", promhttp.Handler()) 38 | 39 | httpServer := &http.Server{ 40 | Addr: ":" + strconv.Itoa(s.opts.port), 41 | Handler: mux, 42 | } 43 | 44 | go func() { 45 | <-ctx.Done() 46 | 47 | ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) //nolint:gomnd 48 | defer cancel() 49 | 50 | if err := httpServer.Shutdown(ctx); err != nil { 51 | s.opts.logger.ErrorContext(ctx, "shutdown webserver", slog.String("error", err.Error())) 52 | } 53 | }() 54 | 55 | go func() { 56 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 57 | s.opts.logger.ErrorContext(ctx, "run status server", slog.String("error", err.Error())) 58 | } 59 | }() 60 | 61 | return nil 62 | } 63 | 64 | // LiveHandler return an implementation of /live request. 65 | func LiveHandler() http.HandlerFunc { 66 | return func(w http.ResponseWriter, req *http.Request) { 67 | w.Header().Set("Content-Type", "application/json") 68 | w.WriteHeader(http.StatusOK) 69 | } 70 | } 71 | 72 | // ReadyHandler build a http.HandlerFunc from healthcheck. 73 | func ReadyHandler(healthcheck IHealthcheck) http.HandlerFunc { 74 | return func(w http.ResponseWriter, req *http.Request) { 75 | const unknownResp = `{"status":"unknown","checks":[]}` 76 | 77 | ctx := req.Context() 78 | w.Header().Set("Content-Type", "application/json") 79 | 80 | report := healthcheck.RunAllChecks(ctx) 81 | reportJson, err := json.Marshal(report) 82 | if err != nil { 83 | w.WriteHeader(http.StatusInternalServerError) 84 | _, _ = w.Write([]byte(unknownResp)) 85 | return 86 | } 87 | 88 | switch report.Status { 89 | default: 90 | w.WriteHeader(http.StatusInternalServerError) 91 | _, _ = w.Write([]byte(unknownResp)) 92 | case StatusUp: 93 | w.WriteHeader(http.StatusOK) 94 | _, _ = w.Write(reportJson) 95 | case StatusDown: 96 | w.WriteHeader(http.StatusInternalServerError) 97 | _, _ = w.Write(reportJson) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /server_mock_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/kazhuravlev/healthcheck (interfaces: IHealthcheck) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination server_mock_test.go -package healthcheck_test . IHealthcheck 7 | // 8 | 9 | // Package healthcheck_test is a generated GoMock package. 10 | package healthcheck_test 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | healthcheck "github.com/kazhuravlev/healthcheck" 17 | gomock "go.uber.org/mock/gomock" 18 | ) 19 | 20 | // MockIHealthcheck is a mock of IHealthcheck interface. 21 | type MockIHealthcheck struct { 22 | ctrl *gomock.Controller 23 | recorder *MockIHealthcheckMockRecorder 24 | } 25 | 26 | // MockIHealthcheckMockRecorder is the mock recorder for MockIHealthcheck. 27 | type MockIHealthcheckMockRecorder struct { 28 | mock *MockIHealthcheck 29 | } 30 | 31 | // NewMockIHealthcheck creates a new mock instance. 32 | func NewMockIHealthcheck(ctrl *gomock.Controller) *MockIHealthcheck { 33 | mock := &MockIHealthcheck{ctrl: ctrl} 34 | mock.recorder = &MockIHealthcheckMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockIHealthcheck) EXPECT() *MockIHealthcheckMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // RunAllChecks mocks base method. 44 | func (m *MockIHealthcheck) RunAllChecks(arg0 context.Context) healthcheck.Report { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "RunAllChecks", arg0) 47 | ret0, _ := ret[0].(healthcheck.Report) 48 | return ret0 49 | } 50 | 51 | // RunAllChecks indicates an expected call of RunAllChecks. 52 | func (mr *MockIHealthcheckMockRecorder) RunAllChecks(arg0 any) *gomock.Call { 53 | mr.mock.ctrl.T.Helper() 54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunAllChecks", reflect.TypeOf((*MockIHealthcheck)(nil).RunAllChecks), arg0) 55 | } 56 | -------------------------------------------------------------------------------- /server_options.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | type serverOptions struct { 9 | port int 10 | healthcheck IHealthcheck 11 | logger ILogger 12 | } 13 | 14 | type ILogger interface { 15 | WarnContext(ctx context.Context, msg string, attrs ...any) 16 | ErrorContext(ctx context.Context, msg string, attrs ...any) 17 | } 18 | 19 | type IHealthcheck interface { 20 | RunAllChecks(ctx context.Context) Report 21 | } 22 | 23 | func WithLogger(logger *slog.Logger) func(o *serverOptions) { 24 | return func(o *serverOptions) { 25 | o.logger = logger 26 | } 27 | } 28 | 29 | func WithPort(port int) func(o *serverOptions) { 30 | return func(o *serverOptions) { 31 | o.port = port 32 | } 33 | } 34 | 35 | func WithHealthcheck(hc *Healthcheck) func(o *serverOptions) { 36 | return func(o *serverOptions) { 37 | o.healthcheck = hc 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck_test 2 | 3 | import ( 4 | "context" 5 | "github.com/kazhuravlev/healthcheck" 6 | "github.com/stretchr/testify/require" 7 | "go.uber.org/mock/gomock" 8 | "io" 9 | "math/rand" 10 | "net/http" 11 | "net/http/httptest" 12 | "strconv" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | //go:generate mockgen -destination server_mock_test.go -package healthcheck_test . IHealthcheck 18 | 19 | func TestReadyHandler(t *testing.T) { 20 | ctrl := gomock.NewController(t) 21 | hc := NewMockIHealthcheck(ctrl) 22 | 23 | f := func(status healthcheck.Status, expStatus int, expBody string) { 24 | t.Run("", func(t *testing.T) { 25 | ctx := context.Background() 26 | hc. 27 | EXPECT(). 28 | RunAllChecks(gomock.Eq(ctx)). 29 | Return(healthcheck.Report{ 30 | Status: status, 31 | Checks: []healthcheck.Check{}, 32 | }) 33 | 34 | req := httptest.NewRequest(http.MethodGet, "/ready", nil) 35 | w := httptest.NewRecorder() 36 | 37 | handler := healthcheck.ReadyHandler(hc) 38 | handler(w, req) 39 | 40 | res := w.Result() 41 | defer res.Body.Close() 42 | 43 | require.Equal(t, expStatus, res.StatusCode) 44 | require.Equal(t, "application/json", res.Header.Get("Content-Type")) 45 | 46 | bb, err := io.ReadAll(res.Body) 47 | require.NoError(t, err) 48 | 49 | require.Equal(t, expBody, string(bb)) 50 | }) 51 | } 52 | 53 | f(healthcheck.StatusUp, http.StatusOK, `{"status":"up","checks":[]}`) 54 | 55 | f(healthcheck.StatusDown, http.StatusInternalServerError, `{"status":"down","checks":[]}`) 56 | 57 | f("i_do_not_know", http.StatusInternalServerError, `{"status":"unknown","checks":[]}`) 58 | } 59 | 60 | func TestLiveHandler(t *testing.T) { 61 | handler := healthcheck.LiveHandler() 62 | 63 | req := httptest.NewRequest(http.MethodGet, "/live", nil) 64 | w := httptest.NewRecorder() 65 | handler(w, req) 66 | 67 | res := w.Result() 68 | require.Equal(t, http.StatusOK, res.StatusCode) 69 | require.NoError(t, res.Body.Close()) 70 | } 71 | 72 | func TestServer(t *testing.T) { 73 | ctrl := gomock.NewController(t) 74 | hc := NewMockIHealthcheck(ctrl) 75 | 76 | port := rand.Intn(1000) + 8000 77 | srv, err := healthcheck.NewServer(hc, healthcheck.WithPort(port)) 78 | require.NoError(t, err) 79 | 80 | ctx := context.Background() 81 | require.NoError(t, srv.Run(ctx)) 82 | 83 | // FIXME: fix crunch 84 | time.Sleep(time.Second) 85 | 86 | t.Run("live_returns_200", func(t *testing.T) { 87 | req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/live", nil) 88 | require.NoError(t, err) 89 | 90 | resp, err := http.DefaultClient.Do(req) 91 | require.NoError(t, err) 92 | defer resp.Body.Close() 93 | 94 | require.Equal(t, http.StatusOK, resp.StatusCode) 95 | }) 96 | 97 | t.Run("ready_always_call_healthcheck", func(t *testing.T) { 98 | hc. 99 | EXPECT(). 100 | RunAllChecks(gomock.Any()). 101 | Return(healthcheck.Report{ 102 | Status: healthcheck.StatusDown, 103 | Checks: []healthcheck.Check{}, 104 | }) 105 | 106 | req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/ready", nil) 107 | require.NoError(t, err) 108 | 109 | resp, err := http.DefaultClient.Do(req) 110 | require.NoError(t, err) 111 | defer resp.Body.Close() 112 | 113 | require.Equal(t, http.StatusInternalServerError, resp.StatusCode) 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /structs.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "github.com/kazhuravlev/healthcheck/internal/logr" 6 | "time" 7 | ) 8 | 9 | type Status string 10 | 11 | const ( 12 | StatusUp Status = "up" 13 | StatusDown Status = "down" 14 | ) 15 | 16 | type CheckState struct { 17 | ActualAt time.Time `json:"actual_at"` 18 | Status Status `json:"status"` 19 | Error string `json:"error"` 20 | } 21 | 22 | type Check struct { 23 | Name string `json:"name"` 24 | State CheckState `json:"state"` 25 | Previous []CheckState `json:"previous"` 26 | } 27 | 28 | type Report struct { 29 | Status Status `json:"status"` 30 | Checks []Check `json:"checks"` 31 | } 32 | 33 | type CheckFn func(ctx context.Context) error 34 | 35 | type ICheck interface { 36 | id() string 37 | check(ctx context.Context) logr.Rec 38 | timeout() time.Duration 39 | log() []logr.Rec 40 | } 41 | 42 | type checkContainer struct { 43 | ID string 44 | Check ICheck 45 | } 46 | --------------------------------------------------------------------------------