feat: add dependency shutdown and configurable service endpoints

This commit is contained in:
2026-09-24 18:33:24 +08:00
parent 8a348951eb
commit f0991d3ff2
51 changed files with 1433 additions and 73 deletions

View File

@@ -14,4 +14,8 @@ app.GET("/health/live", checker.LivenessHandler())
app.GET("/health/ready", checker.ReadinessHandler())
```
就绪探针会保留每项检查结果,并在至少一项失败时返回 `503` 和 `NOT_READY`。
从 v0.13.0-preview 起,两个 Handler 默认都使用 App 响应模板/自定义 Encoder。就绪成功使用项目成功码和提示;失败以 `NOT_READY` 查询项目错误目录,返回项目字段、业务码和公开提示。HTTP 状态保持 200/503,不受业务错误 HTTP 200 策略影响。底层探针原因不会直接放入 HTTP 响应,保留到失败错误链供日志使用;内部 `Checker.Check` 仍提供检查错误。
`ReadinessHandler(health.WithRawResponse())` 只返回健康数据,不包公共体。`health.WithShutdownContext(ctx)` 让服务开始退出时立即标记为未就绪;存活检查不受影响。超时依赖 Probe.Check 自己响应 context 取消,框架不会遗留 goroutine 去强行打断不合作的检查。
生成项目的开关、路径和响应模式统一配置在 `server.endpoints`,不需要修改 Handler。完整配置、依赖主动退出和升级方法见 [项目指南](../PROJECT.md)。

View File

@@ -3,14 +3,15 @@ package health
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
const (
@@ -38,6 +39,7 @@ type CheckResult struct {
type Result struct {
Status string `json:"status"`
Checks []CheckResult `json:"checks,omitempty"`
cause error
}
func (r Result) Ready() bool { return r.Status == StatusReady }
@@ -118,6 +120,7 @@ func (c *Checker) Check(ctx context.Context) Result {
result.Status = StatusNotReady
checkResult.Status = StatusNotReady
checkResult.Error = err.Error()
result.cause = errors.Join(result.cause, fmt.Errorf("%s: %w", probe.Name, err))
}
result.Checks = append(result.Checks, checkResult)
}
@@ -126,28 +129,78 @@ func (c *Checker) Check(ctx context.Context) Result {
// LivenessHandler reports that the process is running. It does not execute
// dependency probes and is safe to use for orchestrator liveness checks.
func (c *Checker) LivenessHandler() httpx.Handler {
func (c *Checker) LivenessHandler(options ...HandlerOption) httpx.Handler {
settings := handlerSettings(options)
return func(ctx *httpx.Context) error {
return ctx.OK(map[string]string{"status": "ok"})
data := map[string]string{"status": "ok"}
if settings.raw {
return ctx.JSON(http.StatusOK, data)
}
return ctx.RespondStatus(http.StatusOK, data, nil)
}
}
// ReadinessHandler reports whether all registered dependencies are ready.
// Failure uses HTTP 503 while preserving GoOmni's response envelope.
func (c *Checker) ReadinessHandler() httpx.Handler {
// Failure always uses HTTP 503. App response configuration is used by default;
// WithRawResponse returns just health data. Internal probe errors remain in the
// recorded failure for logs, not in the HTTP response.
func (c *Checker) ReadinessHandler(options ...HandlerOption) httpx.Handler {
settings := handlerSettings(options)
return func(ctx *httpx.Context) error {
result := c.Check(ctx.Context())
var result Result
if settings.shutdown != nil && settings.shutdown.Err() != nil {
result = Result{Status: StatusNotReady, cause: context.Cause(settings.shutdown)}
} else {
result = c.Check(ctx.Context())
// A stop may arrive while the probes are running.
if settings.shutdown != nil && settings.shutdown.Err() != nil {
result.Status = StatusNotReady
result.cause = errors.Join(result.cause, context.Cause(settings.shutdown))
}
}
for i := range result.Checks {
result.Checks[i].Error = ""
}
status := http.StatusOK
code, message := "READY", "ready"
var failure error
if !result.Ready() {
status = http.StatusServiceUnavailable
code, message = "NOT_READY", "service is not ready"
failure = apierrors.New(status, "NOT_READY", "service is not ready").WithCause(result.cause)
}
return ctx.JSON(status, response.Response{
Code: code,
Message: message,
Data: result,
RequestID: ctx.RequestID(),
})
if settings.raw {
if failure != nil {
resolved := ctx.ResolveError(failure)
resolved.Status = status
ctx.RecordFailure(resolved)
}
return ctx.JSON(status, result)
}
return ctx.RespondStatus(status, result, failure)
}
}
type handlerConfig struct {
raw bool
shutdown context.Context
}
type HandlerOption func(*handlerConfig)
// WithRawResponse bypasses the App envelope for this probe's health data.
func WithRawResponse() HandlerOption { return func(c *handlerConfig) { c.raw = true } }
// WithShutdownContext marks readiness false as soon as service shutdown begins.
// It never changes liveness or closes any project resource.
func WithShutdownContext(ctx context.Context) HandlerOption {
return func(c *handlerConfig) { c.shutdown = ctx }
}
func handlerSettings(options []HandlerOption) handlerConfig {
var cfg handlerConfig
for _, option := range options {
if option != nil {
option(&cfg)
}
}
return cfg
}

View File

@@ -1,6 +1,7 @@
package health
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -10,6 +11,9 @@ import (
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
@@ -43,11 +47,119 @@ func TestCheckerReadinessReportsAllProbes(t *testing.T) {
if err := json.Unmarshal(ready.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "connection refused" {
if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "" {
t.Fatalf("unexpected readiness response: %+v", body)
}
}
func TestProbeResponsePolicies(t *testing.T) {
for _, raw := range []bool{false, true} {
t.Run(map[bool]string{true: "raw", false: "app"}[raw], func(t *testing.T) {
probeErr := errors.New("private database connection details")
checker, _ := New(Probe{Name: "database", Check: func(context.Context) error { return probeErr }})
app := omni.New(omni.WithResponse(response.Config{Body: map[string]any{"code": "${code}", "msg": "${message}", "data": "${data}", "httpStatus": "${status}"}, CodeType: "int32", SuccessCode: 200, ErrorCode: 9999, SuccessMessage: "操作成功", DataMode: "always"}), omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"NOT_READY": {Code: 503001, Message: "暂未就绪"}}}))
var failure *apierrors.APIError
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error { err := next(c); failure = c.Failure(); return err }
})
var opts []HandlerOption
if raw {
opts = append(opts, WithRawResponse())
}
app.GET("/ready", checker.ReadinessHandler(opts...))
app.GET("/live", checker.LivenessHandler(opts...))
for _, path := range []string{"/ready", "/live"} {
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", path, nil))
want := 200
if path == "/ready" {
want = 503
}
if out.Code != want {
t.Fatal(out.Code, out.Body.String())
}
var body map[string]any
if err := json.Unmarshal(out.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if raw {
if _, ok := body["code"]; ok || body["status"] == nil {
t.Fatal(body)
}
} else {
if body["httpStatus"] != float64(want) {
t.Fatal(body)
}
if want == 503 && (body["code"] != float64(503001) || body["msg"] != "暂未就绪") {
t.Fatal(body)
}
if want == 200 && (body["code"] != float64(200) || body["msg"] != "操作成功") {
t.Fatal(body)
}
}
if want == 503 && (failure == nil || failure.Status != 503 || !errors.Is(failure, probeErr)) {
t.Fatal("failure/log cause lost", failure)
}
if bytes.Contains(out.Body.Bytes(), []byte(probeErr.Error())) {
t.Fatal("probe cause leaked")
}
}
})
}
}
func TestShutdownReadinessAndCustomEncoderStatus(t *testing.T) {
ctx, stop := context.WithCancel(context.Background())
defer stop()
checker, _ := New()
app := omni.New(omni.WithResponseEncoder(response.EncoderFunc(func(r response.Result) (response.Payload, error) {
return response.JSON(200, map[string]any{"status": r.Status, "data": r.Data})
})))
app.GET("/ready", checker.ReadinessHandler(WithShutdownContext(ctx)))
app.GET("/live", checker.LivenessHandler())
stop()
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/ready", nil))
if out.Code != 503 {
t.Fatal("encoder masked failed readiness", out.Code)
}
out = httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/live", nil))
if out.Code != 200 {
t.Fatal("shutdown changed liveness", out.Code)
}
}
func TestBrokenProbeEncoderCannotReportHealthy(t *testing.T) {
for _, panicEncoder := range []bool{false, true} {
for _, unhealthy := range []bool{false, true} {
checker, _ := New()
if unhealthy {
_ = checker.Add(Probe{Name: "dependency", Check: func(context.Context) error { return errors.New("offline") }})
}
app := omni.New(omni.WithResponseEncoder(response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Data != nil {
if panicEncoder {
panic("private encoder detail")
}
return response.Payload{}, errors.New("private encoder detail")
}
return response.JSON(200, map[string]bool{"acknowledged": true})
})))
app.GET("/ready", checker.ReadinessHandler())
out := httptest.NewRecorder()
app.ServeHTTP(out, httptest.NewRequest("GET", "/ready", nil))
want := 500
if unhealthy {
want = 503
}
if out.Code != want || out.Body.Len() != 0 {
t.Fatal(out.Code, out.Body.String())
}
}
}
}
func TestCheckerTimeoutAndRegistrationValidation(t *testing.T) {
checker, err := New(Probe{
Name: "slow",