From dd01b51e74b06918af652dedbcfe38b80dd37561 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:56:45 +0800 Subject: [PATCH 01/12] fix(httpx): replace deprecated h2c.NewHandler, add body size and connection caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处改动,核心是第一处。 1. h2c.NewHandler 已废弃,改用标准库的 http.Server.Protocols x/net(本仓库依赖的 v0.55.0 起)里的声明: // Deprecated: Set the [http.Server] Protocols field to use // unencrypted HTTP/2 instead. func NewHandler(h http.Handler, s *http2.Server) http.Handler 同一个文件还有一条我们一直没照做的警告:h2c.NewHandler 为支持 HTTP/1.1 Upgrade 模式,会把 h2c 连接的**首个请求整体读入内存**,文档要求用 http.MaxBytesHandler 包裹——此前并没有。 标准库实现(net/http/server.go 的 maybeServeUnencryptedHTTP2)只 Peek 24 字节比对 PRI 前导,仅支持 prior-knowledge 模式,没有这个内存放大面。 行为差异:依赖 `Upgrade: h2c` 头升级的客户端将静默退回 HTTP/1.1(不报错)。 Envoy(配 appProtocol=kubernetes.io/h2c 时)和 gRPC 客户端用的都是 prior-knowledge,不受影响。 顺带把 HTTP/2 的启用从「由 tls.enabled=false 反推」改成显式声明三个协议位。 原来的 if/else 结构让「TLS 关闭」隐式蕴含「启用 h2c」,这两件事语义无关。 2. MaxRequestBodySize(新增,0 = 不限) 经 http.MaxBytesHandler 包在最外层,先于路由与业务 handler 生效。 3. MaxConnections(新增,0 = 不限) 经 netutil.LimitListener 加在 SetupListener 上。**只防 fd 耗尽,不是并发 闸门**:HTTP/2 一条连接可承载多个 stream,全局并发 = 连接数 × 每连接 stream 数。真正的并发上限应由上游(网关 circuit breaker)或 in-flight middleware 控制。注释和 usage 里都写明了这一点,避免被误当成限流开关。 另注:超出限制时 Accept 阻塞(连接停在内核 accept queue),不是拒绝。 未加 maxConcurrentStreams:它是 per-connection 的,调小只会让客户端多开连接 绕过去,管不住总并发;在网关后面收紧它更是有害无益(把请求挤成网关侧排队或 更多连接)。Go 默认 250 保持不动。 新增 httpx/server_test.go —— NewServer 此前没有测试文件。覆盖迁移后最需要 守住的行为:h2c(prior-knowledge)仍可用、HTTP/1.1 仍可用、body 上限的三种 情形。go test ./httpx/... ./healthz/... ./netx/... 全绿。 Co-Authored-By: Claude Opus 5 (1M context) --- httpx/config.go | 24 +++++--- httpx/example/config.yaml | 5 ++ httpx/server.go | 42 +++++++++++--- httpx/server_test.go | 117 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 httpx/server_test.go diff --git a/httpx/config.go b/httpx/config.go index fbaef193..3ebd57dd 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -6,14 +6,22 @@ import ( ) type ServerConfig struct { - Address string `confx:"address" usage:"HTTP server address" validate:"required"` - PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"` - ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"` - ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"ltefield=ReadTimeout"` - WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"` - IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"` - TLS TLSConfig `confx:"tls"` - Security SecurityConfig `confx:",squash"` + Address string `confx:"address" usage:"HTTP server address" validate:"required"` + PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"` + ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"` + ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"ltefield=ReadTimeout"` + WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"` + IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"` + // MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited. + // Without it a single oversized body can be read entirely into memory. + MaxRequestBodySize int64 `confx:"maxRequestBodySize" usage:"maximum request body size in bytes, 0 for unlimited"` + // MaxConnections caps concurrent connections via netutil.LimitListener. 0 means unlimited. + // This guards against fd exhaustion, NOT against request concurrency: one HTTP/2 + // connection carries many streams. Cap concurrency upstream (gateway circuit breaker) + // or with an in-flight middleware instead. Accept blocks past the limit rather than rejecting. + MaxConnections int `confx:"maxConnections" usage:"maximum number of concurrent connections, 0 for unlimited"` + TLS TLSConfig `confx:"tls"` + Security SecurityConfig `confx:",squash"` } type TLSConfig struct { diff --git a/httpx/example/config.yaml b/httpx/example/config.yaml index 562730ab..335b8422 100644 --- a/httpx/example/config.yaml +++ b/httpx/example/config.yaml @@ -5,6 +5,11 @@ http: readHeaderTimeout: "4s" writeTimeout: "20s" idleTimeout: "120s" + # 0 = 不限。没有它,单个超大 body 会被整体读进内存。 + maxRequestBodySize: 0 + # 0 = 不限。只防 fd 耗尽,**不是并发闸门**:HTTP/2 一条连接可承载多个 stream。 + # 并发上限应由上游(网关 circuit breaker)或 in-flight middleware 控制。 + maxConnections: 0 tls: enabled: false certBase64: "" diff --git a/httpx/server.go b/httpx/server.go index 241ce3b8..7b5b64eb 100644 --- a/httpx/server.go +++ b/httpx/server.go @@ -12,14 +12,23 @@ import ( "github.com/pkg/errors" "github.com/qor5/x/v3/netx" "github.com/theplant/inject/lifecycle" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/netutil" ) type Listener net.Listener func SetupListener(lc *lifecycle.Lifecycle, conf *ServerConfig) (Listener, error) { - return netx.SetupListenerFactory("http-listener", conf.Address)(lc) + listener, err := netx.SetupListenerFactory("http-listener", conf.Address)(lc) + if err != nil { + return nil, err + } + // 连接数上限只防 fd 耗尽,不是并发闸门:HTTP/2 一条连接可承载多个 stream, + // 真正的并发上限应由上游(网关的 circuit breaker)或 in-flight middleware 控制。 + // 超出限制时 Accept 阻塞(连接停在内核 accept queue),不是拒绝。 + if conf.MaxConnections > 0 { + listener = netutil.LimitListener(listener, conf.MaxConnections) + } + return listener, nil } func SetupServerFactory(name string, handler http.Handler) func(ctx context.Context, lc *lifecycle.Lifecycle, conf *ServerConfig, listener Listener) (*http.Server, error) { @@ -80,6 +89,11 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { handler = http.StripPrefix(pathPrefix, handler) } + // 包在最外层,让 body 上限先于路由与业务 handler 生效。 + if conf.MaxRequestBodySize > 0 { + handler = http.MaxBytesHandler(handler, conf.MaxRequestBodySize) + } + srv := &http.Server{ ReadTimeout: conf.ReadTimeout, ReadHeaderTimeout: conf.ReadHeaderTimeout, @@ -87,6 +101,24 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { IdleTimeout: conf.IdleTimeout, Handler: handler, } + + // HTTP/2 在两种模式下都启用:TLS 经 ALPN 协商,明文经 h2c。 + // + // 这取代了此前 `else` 分支里的 h2c.NewHandler —— 它已被 x/net 标记 + // Deprecated("Set the http.Server Protocols field to use unencrypted + // HTTP/2 instead"),且为支持 HTTP/1.1 Upgrade 模式会把 h2c 连接的**首个 + // 请求整体读入内存**(其文档要求用 MaxBytesHandler 包裹,此前并没有)。 + // 标准库实现只 Peek 24 字节比对 PRI 前导,仅支持 prior-knowledge 模式, + // 没有这个内存放大面。 + // + // 行为差异:依赖 `Upgrade: h2c` 头升级的客户端将静默退回 HTTP/1.1(不报错)。 + // Envoy / gRPC 客户端用的都是 prior-knowledge,不受影响。 + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetHTTP2(true) + protocols.SetUnencryptedHTTP2(true) + srv.Protocols = protocols + if conf.TLS.Enabled { cert, err := loadTLSCertificate(conf.TLS.CertBase64, conf.TLS.KeyBase64) if err != nil { @@ -95,10 +127,6 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { srv.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, } - } else { - srv.Handler = h2c.NewHandler(srv.Handler, &http2.Server{ - IdleTimeout: srv.IdleTimeout, - }) } return srv, nil } diff --git a/httpx/server_test.go b/httpx/server_test.go new file mode 100644 index 00000000..d076429e --- /dev/null +++ b/httpx/server_test.go @@ -0,0 +1,117 @@ +package httpx_test + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" + + "github.com/qor5/x/v3/httpx" +) + +// serve 起一个监听在随机端口上的 server,返回其地址。 +func serve(t *testing.T, conf *httpx.ServerConfig, handler http.Handler) string { + t.Helper() + + srv, err := httpx.NewServer(conf, handler) + require.NoError(t, err) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + + return ln.Addr().String() +} + +// h2cClient 用 prior-knowledge 模式(直接发 HTTP/2 前导)连明文端口, +// 这正是 Envoy / gRPC 客户端在 appProtocol=h2c 下的行为。 +func h2cClient() *http.Client { + return &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, addr) + }, + }, + } +} + +// 迁移到 http.Server.Protocols 之后,明文 HTTP/2 必须仍然可用—— +// 这是替换掉已废弃的 h2c.NewHandler 时最需要守住的行为。 +func TestNewServer_H2C(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + + resp, err := h2cClient().Get("http://" + addr) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "HTTP/2.0", string(body)) +} + +// 同一个 server 必须同时还能服务 HTTP/1.1。 +func TestNewServer_HTTP1StillWorks(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + + resp, err := http.Get("http://" + addr) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "HTTP/1.1", string(body)) +} + +func TestNewServer_MaxRequestBodySize(t *testing.T) { + const limit = 16 + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.ReadAll(r.Body); err != nil { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + + t.Run("under the limit passes", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxRequestBodySize: limit}, handler) + + resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader("short")) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("over the limit is rejected", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxRequestBodySize: limit}, handler) + + resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader(strings.Repeat("x", limit*4))) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) + }) + + t.Run("zero means unlimited", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, handler) + + resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader(strings.Repeat("x", limit*4))) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) +} From d112dfda9f893b771af993017fcddff451b85699 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:14:09 +0800 Subject: [PATCH 02/12] docs(httpx): spell out that MaxConnections counts connections, not requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usage 文案原来只写「maximum number of concurrent connections」,读的人很容易 把它当成并发请求上限。HTTP/1.1 下两者数值接近,HTTP/2 下完全脱钩——一条连接 可以多路复用许多并发请求,所以它管不住并发,只防 fd 耗尽。 这个混淆在 review 中被真实地问到了,说明文案不够。现在 usage 里直接写明 「connections, NOT requests」,doc comment 里补上两种协议下的差异,以及应该 用什么来限并发(网关 circuit breaker 或 in-flight middleware)。 --- httpx/config.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/httpx/config.go b/httpx/config.go index 3ebd57dd..5aa871f5 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -15,11 +15,17 @@ type ServerConfig struct { // MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited. // Without it a single oversized body can be read entirely into memory. MaxRequestBodySize int64 `confx:"maxRequestBodySize" usage:"maximum request body size in bytes, 0 for unlimited"` - // MaxConnections caps concurrent connections via netutil.LimitListener. 0 means unlimited. - // This guards against fd exhaustion, NOT against request concurrency: one HTTP/2 - // connection carries many streams. Cap concurrency upstream (gateway circuit breaker) - // or with an in-flight middleware instead. Accept blocks past the limit rather than rejecting. - MaxConnections int `confx:"maxConnections" usage:"maximum number of concurrent connections, 0 for unlimited"` + // MaxConnections caps concurrent TCP connections via netutil.LimitListener. 0 means unlimited. + // + // It counts CONNECTIONS, not requests. Under HTTP/1.1 a connection carries one request + // at a time so the two roughly coincide, but under HTTP/2 a single connection multiplexes + // many concurrent streams — so this is NOT a concurrency limit. It only guards against + // file-descriptor exhaustion. To bound concurrent requests, use the gateway's circuit + // breaker (e.g. Envoy's maxParallelRequests) or an in-flight middleware. + // + // Past the limit Accept blocks (connections queue in the kernel backlog) rather than + // being rejected. + MaxConnections int `confx:"maxConnections" usage:"max concurrent TCP connections (connections, NOT requests: HTTP/2 multiplexes many requests per connection; guards fd exhaustion only), 0 for unlimited"` TLS TLSConfig `confx:"tls"` Security SecurityConfig `confx:",squash"` } From d09e6dca5fcd68611966d118bd13572cd88b5fa7 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:24:00 +0800 Subject: [PATCH 03/12] feat(httpx): make maxConcurrentStreams configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既然两种模式下 HTTP/2 都启用了,就把每连接的 stream 上限也暴露出来。 单独看它约束不了任何东西——它是 per-connection 的,客户端多开几条连接就绕过去 了。但和 maxConnections 相乘就得到一个**算术上可知**的在途请求硬上限: maxConnections × maxConcurrentStreams = 在途请求上限 这是加它的真正理由:不是为了限流(网关的 circuit breaker 才是并发闸门),而是 为了让容量上界从「无法计算」变成「一眼可算」。默认 0 = Go 默认 250,不改变 现有行为。 实现上用 http.Server.HTTP2(Go 1.24 引入的 http.HTTP2Config),不用 x/net 的 http2.Server —— 后者需要配合已废弃的 h2c.NewHandler 或 ConfigureServer 才能生效, 而前者对 TLS 与 h2c 两条路径统一生效。 有一个坑值得记下:Go 1.25 的 http.Server.HTTP2 字段注释仍写着 // This field does not yet have any effect. // See https://go.dev/issue/67813. **这句已经过时**。读 h2_bundle.go 会发现调用链是通的:configFromServer → fillNetHTTPServerConfig → fillNetHTTPConfig(conf, srv.HTTP2)。Go 1.26 已删掉 那句注释。为了不让后人重新怀疑这一点,新增的测试直接读服务端 SETTINGS 帧里 通告的 MAX_CONCURRENT_STREAMS 来断言: configured value is advertised 设 42 → 通告 42 zero falls back to Go default 不设 → 通告 250 Co-Authored-By: Claude Opus 5 (1M context) --- httpx/config.go | 11 +++++++++ httpx/example/config.yaml | 10 +++++++-- httpx/server.go | 8 +++++++ httpx/server_test.go | 47 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/httpx/config.go b/httpx/config.go index 5aa871f5..e72be351 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -15,6 +15,17 @@ type ServerConfig struct { // MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited. // Without it a single oversized body can be read entirely into memory. MaxRequestBodySize int64 `confx:"maxRequestBodySize" usage:"maximum request body size in bytes, 0 for unlimited"` + // MaxConcurrentStreams caps HTTP/2 streams per connection. 0 uses Go's default (250). + // + // This is PER CONNECTION, not global. Together with MaxConnections it gives a hard + // upper bound on in-flight requests: MaxConnections × MaxConcurrentStreams. On its own + // it bounds nothing — a client can just open more connections. + // + // Behind a gateway that already caps concurrency (Envoy's maxParallelRequests), lowering + // this buys no extra protection and costs multiplexing: the same request volume just + // queues at the gateway or opens more connections. Leave it at 0 unless you need the + // bound to be arithmetically knowable. + MaxConcurrentStreams int `confx:"maxConcurrentStreams" usage:"max HTTP/2 streams per connection (per-connection, not global; multiply by maxConnections for the in-flight ceiling), 0 for Go default (250)"` // MaxConnections caps concurrent TCP connections via netutil.LimitListener. 0 means unlimited. // // It counts CONNECTIONS, not requests. Under HTTP/1.1 a connection carries one request diff --git a/httpx/example/config.yaml b/httpx/example/config.yaml index 335b8422..22b41ed1 100644 --- a/httpx/example/config.yaml +++ b/httpx/example/config.yaml @@ -7,9 +7,15 @@ http: idleTimeout: "120s" # 0 = 不限。没有它,单个超大 body 会被整体读进内存。 maxRequestBodySize: 0 - # 0 = 不限。只防 fd 耗尽,**不是并发闸门**:HTTP/2 一条连接可承载多个 stream。 - # 并发上限应由上游(网关 circuit breaker)或 in-flight middleware 控制。 + # 0 = 不限。数的是 **TCP 连接**不是请求:HTTP/1.1 下两者接近,HTTP/2 下一条 + # 连接多路复用许多请求,完全脱钩。它只防 fd 耗尽。 maxConnections: 0 + # 0 = 用 Go 默认值 250。**per-connection**,不是全局。 + # 与 maxConnections 相乘才是在途请求的硬上限:maxConnections × maxConcurrentStreams。 + # 单独设它约束不了任何东西——客户端多开几条连接就绕过去了。 + # 网关已经限并发时(Envoy 的 maxParallelRequests),调小它没有额外保护,反而 + # 损失多路复用;保持 0 即可,除非你需要这个上限在算术上可知。 + maxConcurrentStreams: 0 tls: enabled: false certBase64: "" diff --git a/httpx/server.go b/httpx/server.go index 7b5b64eb..9be453d3 100644 --- a/httpx/server.go +++ b/httpx/server.go @@ -119,6 +119,14 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { protocols.SetUnencryptedHTTP2(true) srv.Protocols = protocols + // 注意:Go 1.25 的 http.Server.HTTP2 字段注释仍写着 "This field does not yet + // have any effect",但那句已经过时——h2_bundle.go 的 configFromServer 会经 + // fillNetHTTPServerConfig 消费它。实测(1.25.6,读服务端 SETTINGS 帧)设 42 + // 即通告 42,不设则为 250。Go 1.26 已删掉那句注释。 + if conf.MaxConcurrentStreams > 0 { + srv.HTTP2 = &http.HTTP2Config{MaxConcurrentStreams: conf.MaxConcurrentStreams} + } + if conf.TLS.Enabled { cert, err := loadTLSCertificate(conf.TLS.CertBase64, conf.TLS.KeyBase64) if err != nil { diff --git a/httpx/server_test.go b/httpx/server_test.go index d076429e..1dec15d7 100644 --- a/httpx/server_test.go +++ b/httpx/server_test.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/require" "golang.org/x/net/http2" @@ -77,6 +78,52 @@ func TestNewServer_HTTP1StillWorks(t *testing.T) { require.Equal(t, "HTTP/1.1", string(body)) } +// 读服务端在 SETTINGS 帧里通告的 MAX_CONCURRENT_STREAMS。 +// 这是唯一能证明 http.Server.HTTP2 真的生效的方式——Go 1.25 的字段注释还写着 +// "does not yet have any effect",那句已经过时,但只能实测来确认。 +func advertisedMaxStreams(t *testing.T, addr string) uint32 { + t.Helper() + + conn, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + _, err = io.WriteString(conn, http2.ClientPreface) + require.NoError(t, err) + + fr := http2.NewFramer(conn, conn) + require.NoError(t, fr.WriteSettings()) + + for range 5 { + f, err := fr.ReadFrame() + require.NoError(t, err) + sf, ok := f.(*http2.SettingsFrame) + if !ok { + continue + } + if v, ok := sf.Value(http2.SettingMaxConcurrentStreams); ok { + return v + } + } + t.Fatal("server never advertised MAX_CONCURRENT_STREAMS") + return 0 +} + +func TestNewServer_MaxConcurrentStreams(t *testing.T) { + noop := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + t.Run("configured value is advertised", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxConcurrentStreams: 42}, noop) + require.Equal(t, uint32(42), advertisedMaxStreams(t, addr)) + }) + + t.Run("zero falls back to the Go default", func(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, noop) + require.Equal(t, uint32(250), advertisedMaxStreams(t, addr)) + }) +} + func TestNewServer_MaxRequestBodySize(t *testing.T) { const limit = 16 From 812350533c5c1fd09a0c6945f2820755eaf0ce3b Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:00:25 +0800 Subject: [PATCH 04/12] docs(httpx): English comments; pin the compat guarantees that matter in prod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事,都是冲着「已有生产项目在引用这个库」来的。 1. 注释改英文 本仓库其余部分都是英文注释,之前几处中文是不该混进来的。 2. 把版本结论改准确,并给出可复现的证据 之前写的是「Go 1.25 起 Server.HTTP2 生效」。更准确的说法是:**它从字段落地 (1.24)那天起就一直生效,是文档注释错了**。 读服务端 SETTINGS 帧实测,MaxConcurrentStreams: 42 在以下版本全部被如实通告 (不设时为 250): go1.24.1 go1.24.11 go1.25.1 go1.25.6 go1.25.12 go1.26.3 源码侧对得上:1.24 的 h2_bundle.go 里 configFromServer 就经 fillNetHTTPServerConfig 消费 h1.HTTP2;1.26 只是把中间那层去掉、直接调 fillNetHTTPConfig,并顺手删掉了那句过时注释。go.dev/issue/67813。 3. 把两条兼容性保证钉成测试 迁移 h2c 实现动的是所有 tls.enabled=false 的消费方(也就是绝大多数),所以 行为差异必须是「已验证」而不是「我认为」: TestNewServer_H2CUpgradeFallsBackToHTTP1 基于 Upgrade 头的 h2c 不再升级——但请求照常被服务。实测旧写法回 101 Switching Protocols,新写法回 200 OK。是协议降级,不是失败。 浏览器不用这个模式,Envoy(appProtocol=h2c)和 gRPC 用的都是 prior-knowledge,不受影响。 TestNewServer_IdleTimeoutAppliesToH2C 旧代码显式转发 &http2.Server{IdleTimeout: srv.IdleTimeout},新写法没有这 一步。实测标准库路径会从 http.Server 继承,两者在同一时刻关掉空闲连接。 这条不钉住的话,h2c 连接可能会静默地永不超时。 三个新配置项的默认值一律为 0(= 保持既有行为),不给非零默认:合理的上限取决 于服务本身(上传端点可能确实需要几百 MiB)和部署形态(fd ulimit),库无从替 调用方决定。要防「忘了设」应该靠 provisioning 侧强制显式配置,而不是在库里塞 一个会静默掐断生产流量的默认值。 Co-Authored-By: Claude Opus 5 (1M context) --- httpx/example/config.yaml | 22 +++++++++----- httpx/server.go | 50 ++++++++++++++++++++----------- httpx/server_test.go | 63 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/httpx/example/config.yaml b/httpx/example/config.yaml index 22b41ed1..d718871c 100644 --- a/httpx/example/config.yaml +++ b/httpx/example/config.yaml @@ -5,16 +5,22 @@ http: readHeaderTimeout: "4s" writeTimeout: "20s" idleTimeout: "120s" - # 0 = 不限。没有它,单个超大 body 会被整体读进内存。 + # 0 = unlimited. Without it a single oversized body is read wholly into memory. + # Left at 0 by default on purpose: a sane cap depends entirely on the service + # (an upload endpoint may legitimately need hundreds of MiB), so the library + # cannot pick one for you. Set it per service, and prefer capping at the + # gateway too (Envoy requestBuffer / nginx proxy-body-size). maxRequestBodySize: 0 - # 0 = 不限。数的是 **TCP 连接**不是请求:HTTP/1.1 下两者接近,HTTP/2 下一条 - # 连接多路复用许多请求,完全脱钩。它只防 fd 耗尽。 + # 0 = unlimited. Counts TCP CONNECTIONS, not requests: under HTTP/1.1 the two + # roughly coincide, under HTTP/2 one connection multiplexes many requests and + # they fully decouple. Guards fd exhaustion only. maxConnections: 0 - # 0 = 用 Go 默认值 250。**per-connection**,不是全局。 - # 与 maxConnections 相乘才是在途请求的硬上限:maxConnections × maxConcurrentStreams。 - # 单独设它约束不了任何东西——客户端多开几条连接就绕过去了。 - # 网关已经限并发时(Envoy 的 maxParallelRequests),调小它没有额外保护,反而 - # 损失多路复用;保持 0 即可,除非你需要这个上限在算术上可知。 + # 0 = Go's default (250). PER CONNECTION, not global. + # Multiply by maxConnections for the in-flight ceiling. On its own it bounds + # nothing — a client just opens more connections. Behind a gateway that already + # caps concurrency (Envoy's maxParallelRequests), lowering it buys no extra + # protection and costs multiplexing; leave it at 0 unless you need the ceiling + # to be arithmetically knowable. maxConcurrentStreams: 0 tls: enabled: false diff --git a/httpx/server.go b/httpx/server.go index 9be453d3..d8b6f3e1 100644 --- a/httpx/server.go +++ b/httpx/server.go @@ -22,9 +22,11 @@ func SetupListener(lc *lifecycle.Lifecycle, conf *ServerConfig) (Listener, error if err != nil { return nil, err } - // 连接数上限只防 fd 耗尽,不是并发闸门:HTTP/2 一条连接可承载多个 stream, - // 真正的并发上限应由上游(网关的 circuit breaker)或 in-flight middleware 控制。 - // 超出限制时 Accept 阻塞(连接停在内核 accept queue),不是拒绝。 + // A connection cap guards against fd exhaustion; it is NOT a concurrency + // limit, since one HTTP/2 connection carries many streams. Bound concurrency + // upstream (the gateway's circuit breaker) or with an in-flight middleware. + // Past the limit Accept blocks — connections queue in the kernel backlog + // rather than being rejected. if conf.MaxConnections > 0 { listener = netutil.LimitListener(listener, conf.MaxConnections) } @@ -89,7 +91,8 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { handler = http.StripPrefix(pathPrefix, handler) } - // 包在最外层,让 body 上限先于路由与业务 handler 生效。 + // Outermost, so the body cap applies before routing and before any + // business handler gets to read. if conf.MaxRequestBodySize > 0 { handler = http.MaxBytesHandler(handler, conf.MaxRequestBodySize) } @@ -102,27 +105,40 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { Handler: handler, } - // HTTP/2 在两种模式下都启用:TLS 经 ALPN 协商,明文经 h2c。 + // HTTP/2 on both paths: negotiated via ALPN under TLS, h2c in cleartext. // - // 这取代了此前 `else` 分支里的 h2c.NewHandler —— 它已被 x/net 标记 - // Deprecated("Set the http.Server Protocols field to use unencrypted - // HTTP/2 instead"),且为支持 HTTP/1.1 Upgrade 模式会把 h2c 连接的**首个 - // 请求整体读入内存**(其文档要求用 MaxBytesHandler 包裹,此前并没有)。 - // 标准库实现只 Peek 24 字节比对 PRI 前导,仅支持 prior-knowledge 模式, - // 没有这个内存放大面。 + // This replaces the h2c.NewHandler that used to live in the `else` branch. + // x/net marks it Deprecated ("Set the http.Server Protocols field to use + // unencrypted HTTP/2 instead"), and to support HTTP/1.1 Upgrade it reads + // the FIRST request on an h2c connection entirely into memory (its own doc + // asks callers to wrap it in MaxBytesHandler; we never did). The stdlib + // implementation only peeks 24 bytes for the PRI preface — prior-knowledge + // mode only — so that memory amplification does not exist here. // - // 行为差异:依赖 `Upgrade: h2c` 头升级的客户端将静默退回 HTTP/1.1(不报错)。 - // Envoy / gRPC 客户端用的都是 prior-knowledge,不受影响。 + // Behaviour change: a client relying on the `Upgrade: h2c` header no longer + // upgrades. It falls back to HTTP/1.1 silently — the request is still served + // normally (verified: 200 OK rather than 101 Switching Protocols), so this + // is a downgrade in protocol, not a failure. Envoy (with appProtocol h2c) + // and gRPC clients both use prior-knowledge and are unaffected. + // + // Also verified unchanged: http.Server.IdleTimeout still governs h2c + // connections. The old code forwarded it explicitly via + // &http2.Server{IdleTimeout: ...}; the stdlib path inherits it, and both + // close an idle connection at the same moment. protocols := new(http.Protocols) protocols.SetHTTP1(true) protocols.SetHTTP2(true) protocols.SetUnencryptedHTTP2(true) srv.Protocols = protocols - // 注意:Go 1.25 的 http.Server.HTTP2 字段注释仍写着 "This field does not yet - // have any effect",但那句已经过时——h2_bundle.go 的 configFromServer 会经 - // fillNetHTTPServerConfig 消费它。实测(1.25.6,读服务端 SETTINGS 帧)设 42 - // 即通告 42,不设则为 250。Go 1.26 已删掉那句注释。 + // Heads-up for anyone auditing this: through Go 1.25 the doc comment on + // http.Server.HTTP2 still reads "This field does not yet have any effect" + // (go.dev/issue/67813). That comment is wrong, and has been since the field + // landed — h2_bundle.go's configFromServer has always fed it through + // fillNetHTTPConfig. Measured by reading the server's SETTINGS frame, + // MaxConcurrentStreams: 42 is advertised as 42 on go1.24.1, 1.24.11, 1.25.1, + // 1.25.6, 1.25.12 and 1.26.3 alike (250 when unset). Go 1.26 finally dropped + // the stale comment. server_test.go pins this so it cannot silently regress. if conf.MaxConcurrentStreams > 0 { srv.HTTP2 = &http.HTTP2Config{MaxConcurrentStreams: conf.MaxConcurrentStreams} } diff --git a/httpx/server_test.go b/httpx/server_test.go index 1dec15d7..460c5814 100644 --- a/httpx/server_test.go +++ b/httpx/server_test.go @@ -1,6 +1,7 @@ package httpx_test import ( + "bufio" "context" "crypto/tls" "io" @@ -162,3 +163,65 @@ func TestNewServer_MaxRequestBodySize(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) }) } + +// Migrating off h2c.NewHandler drops HTTP/1.1 Upgrade-based h2c (the stdlib +// only speaks prior-knowledge). The contract we must keep is that such a +// request still gets served — a protocol downgrade, never an error. +// +// Old behaviour: 101 Switching Protocols. New: 200 OK over HTTP/1.1. +func TestNewServer_H2CUpgradeFallsBackToHTTP1(t *testing.T) { + addr := serve(t, &httpx.ServerConfig{Address: ":0"}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + + conn, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + _, err = io.WriteString(conn, "GET / HTTP/1.1\r\nHost: x\r\n"+ + "Connection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\n"+ + "HTTP2-Settings: AAMAAABkAARAAAAAAAIAAAAA\r\n\r\n") + require.NoError(t, err) + + status, err := bufio.NewReader(conn).ReadString('\n') + require.NoError(t, err) + require.Contains(t, status, "200 OK", + "an Upgrade: h2c request must still be served, just without upgrading") + require.NotContains(t, status, "101") +} + +// The old code forwarded IdleTimeout explicitly (&http2.Server{IdleTimeout: …}). +// The stdlib path has to inherit it from http.Server, or h2c connections would +// silently start living forever. +func TestNewServer_IdleTimeoutAppliesToH2C(t *testing.T) { + const idle = 500 * time.Millisecond + + addr := serve(t, &httpx.ServerConfig{Address: ":0", IdleTimeout: idle}, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + conn, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second))) + + _, err = io.WriteString(conn, http2.ClientPreface) + require.NoError(t, err) + fr := http2.NewFramer(conn, conn) + require.NoError(t, fr.WriteSettings()) + + // An idle h2c connection must be shut down; the server signals that with + // GOAWAY (and then closes), so any read eventually stops succeeding. + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + f, err := fr.ReadFrame() + if err != nil { + return // connection closed — IdleTimeout did its job + } + if _, ok := f.(*http2.GoAwayFrame); ok { + return + } + } + t.Fatal("idle h2c connection was never closed — IdleTimeout is not reaching HTTP/2") +} From 8650e86895849b2b2632530e988d349cf877a0a6 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:09:09 +0800 Subject: [PATCH 05/12] =?UTF-8?q?test(httpx):=20cover=20MaxConnections,=20?= =?UTF-8?q?reject=20negative=20limits=20=E2=80=94=20per=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条 review 意见,都采纳。 1. 负值静默变成「不限」 三个新配置项都用 `> 0` 判断是否启用,于是 -1 会被当成「不设」而不是报错 —— 一个明显的配置笔误就这么被吞了。加 validate:"gte=0",与本文件既有的 validate 用法一致(required / ltefield / required_if)。 2. MaxConnections 没有测试 补上 TestNewServer_MaxConnections。它必须走真实装配路径:这个上限在 SetupListener(netutil.LimitListener)里生效,而不是 NewServer,所以测试 经 lifecycle 拿 listener,而不是像其余用例那样裸 net.Listen。 断言的是 LimitListener 的实际语义 —— 超限时不 Accept(连接停在内核 backlog),而不是拒绝: 第一条连接 正常拿到 200,随后用 keep-alive 占住唯一的槽位 第二条连接 TCP 握手完成,但拿不到任何响应(读超时) 关掉第一条之后 排队中的那条立刻被 Accept 并拿到 200 第三步特意复用已排队的连接而不是新拨一条:槽位释放后先被 Accept 的正是 backlog 里那条,新拨的会继续排在后面。 go test ./httpx/... ./healthz/... ./netx/... 全绿。 Co-Authored-By: Claude Opus 5 (1M context) --- httpx/config.go | 6 ++--- httpx/server_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/httpx/config.go b/httpx/config.go index e72be351..28871a23 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -14,7 +14,7 @@ type ServerConfig struct { IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"` // MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited. // Without it a single oversized body can be read entirely into memory. - MaxRequestBodySize int64 `confx:"maxRequestBodySize" usage:"maximum request body size in bytes, 0 for unlimited"` + MaxRequestBodySize int64 `confx:"maxRequestBodySize" usage:"maximum request body size in bytes, 0 for unlimited" validate:"gte=0"` // MaxConcurrentStreams caps HTTP/2 streams per connection. 0 uses Go's default (250). // // This is PER CONNECTION, not global. Together with MaxConnections it gives a hard @@ -25,7 +25,7 @@ type ServerConfig struct { // this buys no extra protection and costs multiplexing: the same request volume just // queues at the gateway or opens more connections. Leave it at 0 unless you need the // bound to be arithmetically knowable. - MaxConcurrentStreams int `confx:"maxConcurrentStreams" usage:"max HTTP/2 streams per connection (per-connection, not global; multiply by maxConnections for the in-flight ceiling), 0 for Go default (250)"` + MaxConcurrentStreams int `confx:"maxConcurrentStreams" usage:"max HTTP/2 streams per connection (per-connection, not global; multiply by maxConnections for the in-flight ceiling), 0 for Go default (250)" validate:"gte=0"` // MaxConnections caps concurrent TCP connections via netutil.LimitListener. 0 means unlimited. // // It counts CONNECTIONS, not requests. Under HTTP/1.1 a connection carries one request @@ -36,7 +36,7 @@ type ServerConfig struct { // // Past the limit Accept blocks (connections queue in the kernel backlog) rather than // being rejected. - MaxConnections int `confx:"maxConnections" usage:"max concurrent TCP connections (connections, NOT requests: HTTP/2 multiplexes many requests per connection; guards fd exhaustion only), 0 for unlimited"` + MaxConnections int `confx:"maxConnections" usage:"max concurrent TCP connections (connections, NOT requests: HTTP/2 multiplexes many requests per connection; guards fd exhaustion only), 0 for unlimited" validate:"gte=0"` TLS TLSConfig `confx:"tls"` Security SecurityConfig `confx:",squash"` } diff --git a/httpx/server_test.go b/httpx/server_test.go index 460c5814..b8974faf 100644 --- a/httpx/server_test.go +++ b/httpx/server_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/stretchr/testify/require" + "github.com/theplant/inject/lifecycle" "golang.org/x/net/http2" "github.com/qor5/x/v3/httpx" @@ -225,3 +226,61 @@ func TestNewServer_IdleTimeoutAppliesToH2C(t *testing.T) { } t.Fatal("idle h2c connection was never closed — IdleTimeout is not reaching HTTP/2") } + +// MaxConnections caps concurrent TCP connections. It takes effect in +// SetupListener (netutil.LimitListener), not in NewServer, so this test goes +// through the real wiring rather than the bare net.Listen used elsewhere. +// +// LimitListener enforces the cap by not Accept-ing past it — connections sit +// in the kernel backlog rather than being refused — so the observable effect +// is that a second connection gets no response while the first is held open. +func TestNewServer_MaxConnections(t *testing.T) { + conf := &httpx.ServerConfig{Address: "127.0.0.1:0", MaxConnections: 1} + + lc := lifecycle.New() + listener, err := httpx.SetupListener(lc, conf) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + srv, err := httpx.NewServer(conf, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + require.NoError(t, err) + go func() { _ = srv.Serve(listener) }() + t.Cleanup(func() { _ = srv.Close() }) + + addr := listener.Addr().String() + + // First connection: served normally, then held open via keep-alive so it + // keeps occupying the single slot. + held, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = held.Close() }() + require.NoError(t, held.SetDeadline(time.Now().Add(5*time.Second))) + _, err = io.WriteString(held, "GET / HTTP/1.1\r\nHost: x\r\n\r\n") + require.NoError(t, err) + status, err := bufio.NewReader(held).ReadString('\n') + require.NoError(t, err) + require.Contains(t, status, "200 OK", "the first connection must be served") + + // Second connection: the TCP handshake still completes (kernel backlog), + // but the server never Accepts it, so no response arrives. + blocked, err := net.Dial("tcp", addr) + require.NoError(t, err) + defer func() { _ = blocked.Close() }() + require.NoError(t, blocked.SetDeadline(time.Now().Add(700*time.Millisecond))) + _, err = io.WriteString(blocked, "GET / HTTP/1.1\r\nHost: x\r\n\r\n") + require.NoError(t, err) + _, err = bufio.NewReader(blocked).ReadString('\n') + require.Error(t, err, "a second connection must not be served while the cap is taken") + + // Releasing the slot lets the queued connection through — the cap blocks, + // it does not permanently reject. (It is the already-queued one that gets + // Accept-ed next, so re-use `blocked` rather than dialling afresh.) + require.NoError(t, held.Close()) + require.NoError(t, blocked.SetDeadline(time.Now().Add(5*time.Second))) + status, err = bufio.NewReader(blocked).ReadString('\n') + require.NoError(t, err) + require.Contains(t, status, "200 OK", "the slot must be reusable once freed") +} From fcc617700806080b367024f7cf544e2dcf7fadad Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:49:17 +0800 Subject: [PATCH 06/12] fix(gormx): default maxOpenConns to 0 (unlimited), and move the idle/open pairing out of validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 默认值 200 → 0 200 从来没有真正生效过:单个 pod 不可能有 200 个并发查询,所以它不是上限, 只是一个看起来像经过考量、实际从不 binding 的数字 —— 而消费方会照着它做容量 规划(「200 × 5 副本 = 1000 条,超了 RDS 的 475」),基于一个从未发生的前提。 而池上限本来就不是约束资源占用的正确手段,请求超时才是:超时的请求释放它占着 的连接,前提是 timeout context 一路传到 DB 这一层。设上限只是把队列挪进 database/sql,而那一层是看不见的 —— 没人给 DBStats.WaitCount 打点,症状是延迟 毛刺而不是错误。它还会掩盖真实负载:请求堵在池口而不是堵在数据库上,于是数据库 看着空闲、CPU 低到 HPA 阈值够不到,既不扩容也查不出原因。 不限制时,过载的数据库变慢但不会崩,浪头过去自己恢复;容量真不够就升实例规格 —— 而池上限会让每个消费方都多出一个必须跟着重调的值。 maxIdleConns 保持 20:它不是上限,是保持多少条空闲连接不关,避免稳定流量反复 付重连成本。 ## 连带必须改的:MaxIdleConns 的 ltefield MaxIdleConns 原本带 `validate:"ltefield=MaxOpenConns"`。0 表示 unlimited 而不是 零,所以拿它当上界比较是错的 —— 实测 `idle=20, open=0` 直接校验失败。也就是说 只改默认值会让所有消费方启动即崩。 改成在 Open() 入口检查,且只在真的配了上限时才检查(MaxOpenConns > 0)。放在 拨号之前,配置错误应当以自己的面目出现,而不是藏在连接错误后面。 ConnMaxIdleTime 的 ltefield 保留 —— 那里 0 没有特殊含义。 新增 TestMaxIdleConnsAgainstCap 钉住三种组合;TestConfig 补一条 20/0 的用例, 它正是本次改动的前提。gormx 全部测试通过。 Co-Authored-By: Claude Opus 5 (1M context) --- gormx/database.go | 40 +++++++++++++-- gormx/database_test.go | 63 ++++++++++++++++++++++-- gormx/embed/default-database-config.yaml | 5 +- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/gormx/database.go b/gormx/database.go index 06b0311f..c16124b1 100644 --- a/gormx/database.go +++ b/gormx/database.go @@ -43,11 +43,31 @@ type IAMDialectorConfig struct { } type DatabaseConfig struct { - DSN string `confx:"dsn" usage:"Database connection string" validate:"required"` - Debug bool `confx:"debug" usage:"Enable debug mode"` - Tracing TracingConfig `confx:"tracing" usage:"Tracing configuration"` - MaxIdleConns int `confx:"maxIdleConns" usage:"Maximum number of idle connections" validate:"ltefield=MaxOpenConns"` - MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum number of open connections"` + DSN string `confx:"dsn" usage:"Database connection string" validate:"required"` + Debug bool `confx:"debug" usage:"Enable debug mode"` + Tracing TracingConfig `confx:"tracing" usage:"Tracing configuration"` + // MaxIdleConns is how many idle connections the pool keeps warm, so a + // steady request rate does not pay reconnect cost on every query. It is + // not a limit on anything. + // + // No `ltefield=MaxOpenConns` here: MaxOpenConns == 0 means UNLIMITED, so + // comparing against it as an upper bound is wrong — the pairing is checked + // in Open() instead, and only when a real cap is configured. + MaxIdleConns int `confx:"maxIdleConns" usage:"Number of idle connections kept warm (not a limit)"` + // MaxOpenConns caps concurrent connections. 0 (the default) means + // unlimited, and that is the recommended setting. + // + // A pool cap is not what bounds resource use — request timeouts are, and + // they only work if the timeout context reaches the DB layer. A cap just + // moves the queue into database/sql, where nothing observes it: no log + // carries DBStats.WaitCount, so the symptom is latency with no error. It + // also hides load from the things that should react — requests block on + // the pool rather than on the database, so the DB looks idle and CPU stays + // below the autoscaler's threshold. An overloaded database gets slower but + // does not fall over, and recovers once the surge passes; when capacity is + // genuinely the problem the answer is a larger instance, which a cap would + // then force every consumer to re-tune. + MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum concurrent connections; 0 = unlimited (recommended)"` ConnMaxLifetime time.Duration `confx:"connMaxLifetime" usage:"Maximum connection lifetime"` ConnMaxIdleTime time.Duration `confx:"connMaxIdleTime" usage:"Maximum idle time for connections" validate:"ltefield=ConnMaxLifetime"` AuthMethod AuthMethod `confx:"authMethod" usage:"Authentication method: 'password' or 'iam'" validate:"required,oneof=password iam"` @@ -104,6 +124,16 @@ func (c *dbCloserWrapper) Close() error { } func Open(ctx context.Context, conf *DatabaseConfig, opts ...gorm.Option) (*gorm.DB, io.Closer, error) { + // Checked before dialing: a configuration mistake should surface as itself, + // not behind a connection error. Only meaningful when a cap is actually + // configured — with MaxOpenConns == 0 (unlimited) there is no upper bound + // for MaxIdleConns to exceed, which is why this cannot be a `ltefield` tag. + if conf.MaxOpenConns > 0 && conf.MaxIdleConns > conf.MaxOpenConns { + return nil, nil, errors.Errorf( + "maxIdleConns (%d) must not exceed maxOpenConns (%d)", + conf.MaxIdleConns, conf.MaxOpenConns) + } + var ( dialector gorm.Dialector err error diff --git a/gormx/database_test.go b/gormx/database_test.go index f972d224..167db833 100644 --- a/gormx/database_test.go +++ b/gormx/database_test.go @@ -79,6 +79,11 @@ func TestConfig(t *testing.T) { }, }, { + // MaxIdleConns > MaxOpenConns is no longer a validation error: with + // MaxOpenConns == 0 meaning unlimited, `ltefield` cannot express the + // rule. Open() enforces the pairing instead, only when a real cap is + // set — see TestMaxIdleConnsAgainstCap. ConnMaxIdleTime keeps its + // ltefield, where 0 has no special meaning. Name: "invalid config - connection constraints", Config: &gormx.DatabaseConfig{ DSN: "postgres://user:pass@localhost:5432/db", @@ -91,10 +96,26 @@ func TestConfig(t *testing.T) { AuthMethod: gormx.AuthMethodPassword, }, ExpectedErrors: []confx.ExpectedValidationError{ - {Path: "MaxIdleConns", Tag: "ltefield"}, {Path: "ConnMaxIdleTime", Tag: "ltefield"}, }, }, + { + // The new default. Before this change maxOpenConns defaulted to 200 + // and MaxIdleConns carried `ltefield=MaxOpenConns`, so flipping the + // default to 0 would have made 20 <= 0 fail and every consumer would + // have failed to start. + Name: "valid config - unlimited maxOpenConns with warm idle pool", + Config: &gormx.DatabaseConfig{ + DSN: "postgres://user:pass@localhost:5432/db", + Tracing: gormx.TracingConfig{}, + MaxIdleConns: 20, + MaxOpenConns: 0, + ConnMaxIdleTime: 10 * time.Minute, + ConnMaxLifetime: 30 * time.Minute, + AuthMethod: gormx.AuthMethodPassword, + }, + ExpectedErrors: nil, + }, { Name: "invalid config - auth method", Config: &gormx.DatabaseConfig{ @@ -117,7 +138,7 @@ func TestConfig(t *testing.T) { DSN: "", // empty dsn Debug: true, Tracing: gormx.TracingConfig{}, - MaxIdleConns: 11, // maxIdleConns > maxOpenConns + MaxIdleConns: 11, // no longer a validation error — enforced in Open() MaxOpenConns: 10, ConnMaxIdleTime: 30 * time.Minute, // maxIdleTime > maxLifetime ConnMaxLifetime: 10 * time.Minute, @@ -126,7 +147,6 @@ func TestConfig(t *testing.T) { ExpectedErrors: []confx.ExpectedValidationError{ {Path: "DSN", Tag: "required"}, {Path: "AuthMethod", Tag: "oneof"}, - {Path: "MaxIdleConns", Tag: "ltefield"}, {Path: "ConnMaxIdleTime", Tag: "ltefield"}, }, }, @@ -346,3 +366,40 @@ func TestAuthMethodIAM(t *testing.T) { t.Logf("Expected error with invalid credentials: %v", err) }) } + +// The idle/open pairing moved out of struct validation and into Open(), because +// MaxOpenConns == 0 means unlimited and `ltefield` cannot express that. Open() +// must therefore reject the pairing only when a real cap is configured. +func TestMaxIdleConnsAgainstCap(t *testing.T) { + base := func() *gormx.DatabaseConfig { + return &gormx.DatabaseConfig{ + DSN: "postgres://user:pass@127.0.0.1:1/db", + ConnMaxIdleTime: 10 * time.Minute, + ConnMaxLifetime: 30 * time.Minute, + AuthMethod: gormx.AuthMethodPassword, + } + } + for _, c := range []struct { + name string + idle, open int + wantErr bool + }{ + {"cap set, idle above it", 11, 10, true}, + {"cap set, idle within it", 10, 10, false}, + {"unlimited, warm idle pool", 20, 0, false}, + } { + t.Run(c.name, func(t *testing.T) { + conf := base() + conf.MaxIdleConns, conf.MaxOpenConns = c.idle, c.open + _, _, err := gormx.Open(context.Background(), conf) + if c.wantErr { + require.ErrorContains(t, err, "must not exceed maxOpenConns") + return + } + // Anything else fails on the unreachable DSN, never on the pairing. + if err != nil { + require.NotContains(t, err.Error(), "must not exceed maxOpenConns") + } + }) + } +} diff --git a/gormx/embed/default-database-config.yaml b/gormx/embed/default-database-config.yaml index 6286927c..a13b55ff 100644 --- a/gormx/embed/default-database-config.yaml +++ b/gormx/embed/default-database-config.yaml @@ -5,8 +5,11 @@ database: excludeQuery: false excludeQueryVars: false maxQueryLength: 0 # Maximum query length for tracing, 0 uses default (4096) + # Idle connections kept warm; not a limit on anything. maxIdleConns: 20 - maxOpenConns: 200 + # 0 = unlimited, and that is the recommendation. A pool cap does not bound + # resource use — request timeouts do. See DatabaseConfig.MaxOpenConns. + maxOpenConns: 0 connMaxLifetime: "30m" connMaxIdleTime: "10m" authMethod: "password" # password, iam From 4a1381c85e361e2578ae0940d17d65f9e034e301 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:54:07 +0800 Subject: [PATCH 07/12] fix(gormx,httpx): move the three "0 means unlimited" pairings out of struct tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个字段用 `ltefield` 表达「不得超过另一个字段」,而那三个「另一个字段」取 0 时 的含义都是**不限制**,不是零。于是完全合理的配置会校验失败、服务起不来: gormx MaxIdleConns ltefield=MaxOpenConns (20, 0) → 失败 gormx ConnMaxIdleTime ltefield=ConnMaxLifetime (10m, 0) → 失败 httpx ReadHeaderTimeout ltefield=ReadTimeout (10s, 0) → 失败 三条都实测复现过。httpx 那条最容易踩:它没有默认值文件,而「只设一个 header 超时」是很自然的最小加固。 ## 为什么不是换个 tag 写法 go-playground/validator 没有「目标字段为零就跳过」的内置 tag(`omitzero` 跳的是 **当前**字段),只能自定义。但自定义 tag 要注册进校验器实例,而实例由应用侧 构造,库注册不进去。 更根本的是位置不对:「0 = 不限制」这个语义是由下面那几行 `if conf.X > 0` 定义 的,tag 层看不见。把配对检查放到构造函数里,和定义语义的代码在一起,才是它该 待的地方。三处统一放在入口、拨号/监听之前 —— 配置错误应当以自己的面目出现, 而不是藏在连接错误后面。 ## 顺带 httpx.MaxConcurrentStreams 的注释去掉了「网关已经用 maxParallelRequests 限了 并发」这个前提 —— 那个做法已不再推荐。 新增 TestConnMaxIdleTimeAgainstLifetime 与 TestReadHeaderTimeoutAgainstReadTimeout, 各钉三种组合(有上限且合规 / 有上限且越界 / 无上限)。httpx + gormx 全部通过。 Co-Authored-By: Claude Opus 5 (1M context) --- gormx/database.go | 23 ++++++++++++++------ gormx/database_test.go | 49 +++++++++++++++++++++++++++++++++--------- httpx/config.go | 19 +++++++++------- httpx/server.go | 10 +++++++++ httpx/server_test.go | 29 +++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 24 deletions(-) diff --git a/gormx/database.go b/gormx/database.go index c16124b1..a98aa9ba 100644 --- a/gormx/database.go +++ b/gormx/database.go @@ -67,9 +67,11 @@ type DatabaseConfig struct { // does not fall over, and recovers once the surge passes; when capacity is // genuinely the problem the answer is a larger instance, which a cap would // then force every consumer to re-tune. - MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum concurrent connections; 0 = unlimited (recommended)"` - ConnMaxLifetime time.Duration `confx:"connMaxLifetime" usage:"Maximum connection lifetime"` - ConnMaxIdleTime time.Duration `confx:"connMaxIdleTime" usage:"Maximum idle time for connections" validate:"ltefield=ConnMaxLifetime"` + MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum concurrent connections; 0 = unlimited (recommended)"` + ConnMaxLifetime time.Duration `confx:"connMaxLifetime" usage:"Maximum connection lifetime"` + // Same shape as MaxIdleConns above: ConnMaxLifetime == 0 means connections + // are never recycled, so it is not an upper bound either. Checked in Open(). + ConnMaxIdleTime time.Duration `confx:"connMaxIdleTime" usage:"Maximum idle time for connections"` AuthMethod AuthMethod `confx:"authMethod" usage:"Authentication method: 'password' or 'iam'" validate:"required,oneof=password iam"` IAM IAMDialectorConfig `confx:"iam" validate:"skip_nested_unless=AuthMethod iam" usage:"IAM configuration"` } @@ -125,14 +127,23 @@ func (c *dbCloserWrapper) Close() error { func Open(ctx context.Context, conf *DatabaseConfig, opts ...gorm.Option) (*gorm.DB, io.Closer, error) { // Checked before dialing: a configuration mistake should surface as itself, - // not behind a connection error. Only meaningful when a cap is actually - // configured — with MaxOpenConns == 0 (unlimited) there is no upper bound - // for MaxIdleConns to exceed, which is why this cannot be a `ltefield` tag. + // not behind a connection error. + // + // These two pairings cannot be `ltefield` struct tags. In both, 0 on the + // right-hand side means "no limit", not "zero" — that meaning is defined by + // the `if conf.X > 0` guards further down, and the tag layer cannot see it. + // Tagged, a perfectly good config like (maxIdleConns 20, maxOpenConns 0) + // fails validation and the service will not start. if conf.MaxOpenConns > 0 && conf.MaxIdleConns > conf.MaxOpenConns { return nil, nil, errors.Errorf( "maxIdleConns (%d) must not exceed maxOpenConns (%d)", conf.MaxIdleConns, conf.MaxOpenConns) } + if conf.ConnMaxLifetime > 0 && conf.ConnMaxIdleTime > conf.ConnMaxLifetime { + return nil, nil, errors.Errorf( + "connMaxIdleTime (%s) must not exceed connMaxLifetime (%s)", + conf.ConnMaxIdleTime, conf.ConnMaxLifetime) + } var ( dialector gorm.Dialector diff --git a/gormx/database_test.go b/gormx/database_test.go index 167db833..722bdfa6 100644 --- a/gormx/database_test.go +++ b/gormx/database_test.go @@ -79,12 +79,11 @@ func TestConfig(t *testing.T) { }, }, { - // MaxIdleConns > MaxOpenConns is no longer a validation error: with - // MaxOpenConns == 0 meaning unlimited, `ltefield` cannot express the - // rule. Open() enforces the pairing instead, only when a real cap is - // set — see TestMaxIdleConnsAgainstCap. ConnMaxIdleTime keeps its - // ltefield, where 0 has no special meaning. - Name: "invalid config - connection constraints", + // Neither pairing is a validation error any more: on both, 0 on the + // right-hand side means "no limit", which `ltefield` cannot express. + // Open() enforces them, and only when a real limit is configured — + // see TestMaxIdleConnsAgainstCap / TestConnMaxIdleTimeAgainstLifetime. + Name: "valid config - pairings are enforced in Open(), not here", Config: &gormx.DatabaseConfig{ DSN: "postgres://user:pass@localhost:5432/db", Debug: true, @@ -95,9 +94,7 @@ func TestConfig(t *testing.T) { ConnMaxLifetime: 10 * time.Minute, AuthMethod: gormx.AuthMethodPassword, }, - ExpectedErrors: []confx.ExpectedValidationError{ - {Path: "ConnMaxIdleTime", Tag: "ltefield"}, - }, + ExpectedErrors: nil, }, { // The new default. Before this change maxOpenConns defaulted to 200 @@ -147,7 +144,6 @@ func TestConfig(t *testing.T) { ExpectedErrors: []confx.ExpectedValidationError{ {Path: "DSN", Tag: "required"}, {Path: "AuthMethod", Tag: "oneof"}, - {Path: "ConnMaxIdleTime", Tag: "ltefield"}, }, }, }) @@ -403,3 +399,36 @@ func TestMaxIdleConnsAgainstCap(t *testing.T) { }) } } + +// Same shape as the pool pairing: ConnMaxLifetime == 0 means connections are +// never recycled, so it is not an upper bound for ConnMaxIdleTime. +func TestConnMaxIdleTimeAgainstLifetime(t *testing.T) { + base := func() *gormx.DatabaseConfig { + return &gormx.DatabaseConfig{ + DSN: "postgres://user:pass@127.0.0.1:1/db", + AuthMethod: gormx.AuthMethodPassword, + } + } + for _, c := range []struct { + name string + idleTime, life time.Duration + wantErr bool + }{ + {"lifetime set, idleTime within it", 10 * time.Minute, 30 * time.Minute, false}, + {"lifetime set, idleTime beyond it", 30 * time.Minute, 10 * time.Minute, true}, + {"never recycled, idleTime only", 10 * time.Minute, 0, false}, + } { + t.Run(c.name, func(t *testing.T) { + conf := base() + conf.ConnMaxIdleTime, conf.ConnMaxLifetime = c.idleTime, c.life + _, _, err := gormx.Open(context.Background(), conf) + if c.wantErr { + require.ErrorContains(t, err, "must not exceed connMaxLifetime") + return + } + if err != nil { + require.NotContains(t, err.Error(), "must not exceed connMaxLifetime") + } + }) + } +} diff --git a/httpx/config.go b/httpx/config.go index 28871a23..d6205e22 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -6,10 +6,14 @@ import ( ) type ServerConfig struct { - Address string `confx:"address" usage:"HTTP server address" validate:"required"` - PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"` - ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"` - ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"ltefield=ReadTimeout"` + Address string `confx:"address" usage:"HTTP server address" validate:"required"` + PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"` + ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"` + // No `ltefield=ReadTimeout`: ReadTimeout == 0 means no read deadline at all, + // so it is not an upper bound. Tagged, a config that sets only a header + // timeout — a reasonable minimal hardening — fails validation and the + // service will not start. Checked in NewServer() instead. + ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers"` WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"` IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"` // MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited. @@ -21,10 +25,9 @@ type ServerConfig struct { // upper bound on in-flight requests: MaxConnections × MaxConcurrentStreams. On its own // it bounds nothing — a client can just open more connections. // - // Behind a gateway that already caps concurrency (Envoy's maxParallelRequests), lowering - // this buys no extra protection and costs multiplexing: the same request volume just - // queues at the gateway or opens more connections. Leave it at 0 unless you need the - // bound to be arithmetically knowable. + // Lowering it buys little: the same request volume just opens more connections. + // Leave it at 0 unless you specifically need the in-flight bound to be + // arithmetically knowable. MaxConcurrentStreams int `confx:"maxConcurrentStreams" usage:"max HTTP/2 streams per connection (per-connection, not global; multiply by maxConnections for the in-flight ceiling), 0 for Go default (250)" validate:"gte=0"` // MaxConnections caps concurrent TCP connections via netutil.LimitListener. 0 means unlimited. // diff --git a/httpx/server.go b/httpx/server.go index d8b6f3e1..6b539e7d 100644 --- a/httpx/server.go +++ b/httpx/server.go @@ -71,6 +71,16 @@ func SetupServerFactory(name string, handler http.Handler) func(ctx context.Cont } func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { + // Cannot be a `ltefield` struct tag: ReadTimeout == 0 means no read deadline + // at all, not "zero seconds", so it is not an upper bound. That meaning is + // defined by net/http, which the tag layer cannot see — tagged, a config + // that sets only a header timeout would fail validation. + if conf.ReadTimeout > 0 && conf.ReadHeaderTimeout > conf.ReadTimeout { + return nil, errors.Errorf( + "readHeaderTimeout (%s) must not exceed readTimeout (%s)", + conf.ReadHeaderTimeout, conf.ReadTimeout) + } + // Normalize PathPrefix to ensure predictable behavior: // - Always starts with "/" (add if missing) // - Never ends with "/" unless it's the root path "/" diff --git a/httpx/server_test.go b/httpx/server_test.go index b8974faf..274d805a 100644 --- a/httpx/server_test.go +++ b/httpx/server_test.go @@ -284,3 +284,32 @@ func TestNewServer_MaxConnections(t *testing.T) { require.NoError(t, err) require.Contains(t, status, "200 OK", "the slot must be reusable once freed") } + +// ReadTimeout == 0 means no read deadline, so it is not an upper bound for +// ReadHeaderTimeout. This used to be a `ltefield=ReadTimeout` struct tag, which +// rejected a config that set only a header timeout — a reasonable minimal +// hardening — and stopped the service from starting. +func TestReadHeaderTimeoutAgainstReadTimeout(t *testing.T) { + for _, c := range []struct { + name string + header, read time.Duration + wantErr bool + }{ + {"read deadline set, header within it", 5 * time.Second, 10 * time.Second, false}, + {"read deadline set, header beyond it", 15 * time.Second, 10 * time.Second, true}, + {"no read deadline, header only", 10 * time.Second, 0, false}, + } { + t.Run(c.name, func(t *testing.T) { + _, err := httpx.NewServer(&httpx.ServerConfig{ + Address: ":0", + ReadHeaderTimeout: c.header, + ReadTimeout: c.read, + }, http.NotFoundHandler()) + if c.wantErr { + require.ErrorContains(t, err, "must not exceed readTimeout") + return + } + require.NoError(t, err) + }) + } +} From e18453312f9652de28f8e29b3de0f80a7e248941 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:24:43 +0800 Subject: [PATCH 08/12] =?UTF-8?q?docs(httpx):=20MaxConnections=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=200=20=E6=89=8D=E6=98=AF=E5=B8=B8=E6=80=81=EF=BC=8C?= =?UTF-8?q?=E8=AF=B4=E6=B8=85=E5=AE=83=E4=B8=A4=E5=A4=B4=E9=83=BD=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E4=B8=8D=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原注释把它说成「防 fd 耗尽」,并让人去用网关的 circuitBreaker 兜并发。两句都要改: · 它触顶时 netutil.LimitListener 停止 Accept,连接堆在内核 backlog 里, 客户端等到自己超时 —— 不记日志、不拒绝,是一条看不见的队列。 · 这个数两头都站不住:低到能约束单连接内存时,离进程的 fd 上限还差几个 数量级,所以既没防住 fd 也没防住内存。 · 网关侧的并发闸门已不再推荐(见 theplant/mad-provisioning#123), 不该再把它当成配套方案写在这里。 真正约束资源占用的是上面那几个超时。改成「默认 0 通常就是对的,除非你确实 需要一个硬性连接上限、且拿得出依据」。 --- httpx/config.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/httpx/config.go b/httpx/config.go index d6205e22..7e74f015 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -33,12 +33,19 @@ type ServerConfig struct { // // It counts CONNECTIONS, not requests. Under HTTP/1.1 a connection carries one request // at a time so the two roughly coincide, but under HTTP/2 a single connection multiplexes - // many concurrent streams — so this is NOT a concurrency limit. It only guards against - // file-descriptor exhaustion. To bound concurrent requests, use the gateway's circuit - // breaker (e.g. Envoy's maxParallelRequests) or an in-flight middleware. + // many concurrent streams — so this is NOT a concurrency limit. // - // Past the limit Accept blocks (connections queue in the kernel backlog) rather than - // being rejected. + // Leaving it at 0 is usually right. Past the limit netutil.LimitListener stops calling + // Accept, so connections sit in the kernel backlog and the client waits until its own + // timeout — an invisible queue, with nothing logged and nothing rejected. And the number + // is hard to justify from either side it supposedly protects: low enough to bound + // per-connection memory is orders of magnitude below the process fd ceiling, so it + // guards neither in practice. + // + // What bounds resource use is the request timeouts above: an overloaded server gets + // slower, requests time out and release what they hold, and it recovers on its own. + // Set this only when you specifically need a hard connection ceiling and have a number + // you can defend. MaxConnections int `confx:"maxConnections" usage:"max concurrent TCP connections (connections, NOT requests: HTTP/2 multiplexes many requests per connection; guards fd exhaustion only), 0 for unlimited" validate:"gte=0"` TLS TLSConfig `confx:"tls"` Security SecurityConfig `confx:",squash"` From 0b466e358463db056307328e98baa41660da2de5 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:25:44 +0800 Subject: [PATCH 09/12] docs(gormx,httpx): state the mechanics, not a recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前在这两个包的注释里写了「0 才是推荐值」「池上限不是约束资源的手段,请求超时 才是」之类的话。那是消费方的取舍,不该由共享库替所有人拍板 —— 同一个库的不同 使用者完全可能有不同结论。 只留可验证的事实: gormx.MaxOpenConns 0 = unlimited,与 database/sql 自身默认一致;超过上限时 调用方阻塞在 sql.DB 内部,且只能通过 DBStats.WaitCount 看到 gormx.MaxIdleConns pool 里保留的空闲连接数,超出的在归还时关闭;它不限制 能开多少连接 httpx.MaxConnections 触顶后 netutil.LimitListener 停止 Accept,后续连接在内核 backlog 里等到客户端放弃:不记日志、不拒绝 默认值本身(maxOpenConns 200 → 0)不变,那是上一个 commit 的事,理由在那条 commit message 里 —— 200 从来没有真正生效过。 --- gormx/database.go | 31 +++++++++--------------- gormx/embed/default-database-config.yaml | 5 ++-- httpx/config.go | 14 +++-------- 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/gormx/database.go b/gormx/database.go index a98aa9ba..85620def 100644 --- a/gormx/database.go +++ b/gormx/database.go @@ -46,28 +46,19 @@ type DatabaseConfig struct { DSN string `confx:"dsn" usage:"Database connection string" validate:"required"` Debug bool `confx:"debug" usage:"Enable debug mode"` Tracing TracingConfig `confx:"tracing" usage:"Tracing configuration"` - // MaxIdleConns is how many idle connections the pool keeps warm, so a - // steady request rate does not pay reconnect cost on every query. It is - // not a limit on anything. + // MaxIdleConns is the number of idle connections database/sql keeps in the + // pool; connections beyond it are closed when returned. It does not cap how + // many connections may be open. // - // No `ltefield=MaxOpenConns` here: MaxOpenConns == 0 means UNLIMITED, so - // comparing against it as an upper bound is wrong — the pairing is checked - // in Open() instead, and only when a real cap is configured. - MaxIdleConns int `confx:"maxIdleConns" usage:"Number of idle connections kept warm (not a limit)"` + // No `ltefield=MaxOpenConns` tag: MaxOpenConns == 0 means unlimited, so it + // is not an upper bound to compare against. Open() checks the pairing + // instead, and only when MaxOpenConns > 0. + MaxIdleConns int `confx:"maxIdleConns" usage:"Number of idle connections kept in the pool"` // MaxOpenConns caps concurrent connections. 0 (the default) means - // unlimited, and that is the recommended setting. - // - // A pool cap is not what bounds resource use — request timeouts are, and - // they only work if the timeout context reaches the DB layer. A cap just - // moves the queue into database/sql, where nothing observes it: no log - // carries DBStats.WaitCount, so the symptom is latency with no error. It - // also hides load from the things that should react — requests block on - // the pool rather than on the database, so the DB looks idle and CPU stays - // below the autoscaler's threshold. An overloaded database gets slower but - // does not fall over, and recovers once the surge passes; when capacity is - // genuinely the problem the answer is a larger instance, which a cap would - // then force every consumer to re-tune. - MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum concurrent connections; 0 = unlimited (recommended)"` + // unlimited, matching database/sql's own default: past the cap, callers + // block inside sql.DB waiting for a connection to be returned, and the wait + // is only visible through DBStats.WaitCount. + MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum concurrent connections; 0 = unlimited"` ConnMaxLifetime time.Duration `confx:"connMaxLifetime" usage:"Maximum connection lifetime"` // Same shape as MaxIdleConns above: ConnMaxLifetime == 0 means connections // are never recycled, so it is not an upper bound either. Checked in Open(). diff --git a/gormx/embed/default-database-config.yaml b/gormx/embed/default-database-config.yaml index a13b55ff..117b0ac8 100644 --- a/gormx/embed/default-database-config.yaml +++ b/gormx/embed/default-database-config.yaml @@ -5,10 +5,9 @@ database: excludeQuery: false excludeQueryVars: false maxQueryLength: 0 # Maximum query length for tracing, 0 uses default (4096) - # Idle connections kept warm; not a limit on anything. maxIdleConns: 20 - # 0 = unlimited, and that is the recommendation. A pool cap does not bound - # resource use — request timeouts do. See DatabaseConfig.MaxOpenConns. + # 0 = unlimited, same as database/sql's own default. Set a cap here only if + # your deployment needs one. maxOpenConns: 0 connMaxLifetime: "30m" connMaxIdleTime: "10m" diff --git a/httpx/config.go b/httpx/config.go index 7e74f015..7616e5b1 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -35,17 +35,9 @@ type ServerConfig struct { // at a time so the two roughly coincide, but under HTTP/2 a single connection multiplexes // many concurrent streams — so this is NOT a concurrency limit. // - // Leaving it at 0 is usually right. Past the limit netutil.LimitListener stops calling - // Accept, so connections sit in the kernel backlog and the client waits until its own - // timeout — an invisible queue, with nothing logged and nothing rejected. And the number - // is hard to justify from either side it supposedly protects: low enough to bound - // per-connection memory is orders of magnitude below the process fd ceiling, so it - // guards neither in practice. - // - // What bounds resource use is the request timeouts above: an overloaded server gets - // slower, requests time out and release what they hold, and it recovers on its own. - // Set this only when you specifically need a hard connection ceiling and have a number - // you can defend. + // Past the limit netutil.LimitListener stops calling Accept, so further connections + // wait in the kernel backlog until the client gives up: nothing is logged and nothing + // is rejected. MaxConnections int `confx:"maxConnections" usage:"max concurrent TCP connections (connections, NOT requests: HTTP/2 multiplexes many requests per connection; guards fd exhaustion only), 0 for unlimited" validate:"gte=0"` TLS TLSConfig `confx:"tls"` Security SecurityConfig `confx:",squash"` From a79aa9460927416e6371acb2c63806ad50392429 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:28:24 +0800 Subject: [PATCH 10/12] fix(gormx,httpx): use confx's stop_if instead of hand-checking in the constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处跨字段比较的右手边取 0 表示「不限制」,`ltefield` 拿它当上界是错的。上一轮 把配对检查挪进了构造函数当权宜之计,现在 qor5/confx#21 提供了 stop_if,改回 tag 写法: MaxIdleConns validate:"stop_if=MaxOpenConns 0,ltefield=MaxOpenConns" ConnMaxIdleTime validate:"stop_if=ConnMaxLifetime 0,ltefield=ConnMaxLifetime" ReadHeaderTimeout validate:"stop_if=ReadTimeout 0,ltefield=ReadTimeout" stop_if 命中时让该字段后续的 tag 短路,它自己的错误由 confx 按 tag 名滤掉。 比手写检查好在三点:回到配置校验阶段(confx 的 ValidationSuite 抓得到,而不是 等到 Open()/NewServer() 才炸)、错误是结构化的(path + tag)、三处写法与其余 校验一致。gormx.Open 与 httpx.NewServer 里那两段手写检查随之删除。 测试同步改回 confx.ValidationSuite。已反证:把 stop_if 从 tag 里去掉, 「无上限 + 热池 (20, 0)」与「只设 header 超时 (10s, 0)」两个合法配置立刻被拒 ——正是这个改动要解决的。 ⚠️ go.mod 暂时把 confx 指向 qor5/confx#21 的分支 commit。该 PR 合并发版后 需要 bump 成正式版本。 --- go.mod | 17 ++++---- go.sum | 18 +++++++++ gormx/database.go | 31 +++------------ gormx/database_test.go | 89 ++++++------------------------------------ httpx/config.go | 9 ++--- httpx/server.go | 10 ----- httpx/server_test.go | 50 ++++++++++++------------ 7 files changed, 75 insertions(+), 149 deletions(-) diff --git a/go.mod b/go.mod index b31660a1..c70efb03 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.4.0 github.com/qiniu/api.v7/v7 v7.8.2 - github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 + github.com/qor5/confx v0.0.0-20260809190714-853d25a54d11 github.com/qor5/go-bus v0.1.2 github.com/qor5/go-que v1.1.0 github.com/qor5/kx v0.0.0-20260713082723-dc32af6f8fd6 @@ -45,7 +45,7 @@ require ( github.com/rs/cors v1.11.1 github.com/rs/xid v1.6.0 github.com/samber/lo v1.52.0 - github.com/spf13/cast v1.7.1 + github.com/spf13/cast v1.9.2 github.com/stretchr/testify v1.11.1 github.com/sunfmin/reflectutils v1.0.6 github.com/testcontainers/testcontainers-go v0.42.0 @@ -118,8 +118,8 @@ require ( github.com/ebitengine/purego v0.10.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-kit/kit v0.12.1-0.20220826005032-a7ba4fa4e289 // indirect github.com/go-logfmt/logfmt v0.5.1 // indirect @@ -131,8 +131,9 @@ require ( github.com/go-playground/form/v4 v4.2.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.25.0 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gookit/color v1.3.6 // indirect github.com/gorilla/context v1.1.2 // indirect @@ -172,12 +173,12 @@ require ( github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/ory/pagination v0.0.1 // indirect github.com/pborman/uuid v1.2.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/redis/go-redis/v9 v9.16.0 // indirect - github.com/sagikazarmark/locafero v0.6.0 // indirect + github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect @@ -185,7 +186,7 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/spf13/viper v1.19.0 // indirect + github.com/spf13/viper v1.20.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 // indirect diff --git a/go.sum b/go.sum index 4ced66b6..e607bc18 100644 --- a/go.sum +++ b/go.sum @@ -161,8 +161,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y= github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -200,9 +204,13 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -361,6 +369,8 @@ github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -378,6 +388,8 @@ github.com/qiniu/api.v7/v7 v7.8.2 h1:f08kI0MmsJNzK4sUS8bG3HDH67ktwd/ji23Gkiy2ra4 github.com/qiniu/api.v7/v7 v7.8.2/go.mod h1:FPsIqxh1Ym3X01sANE5ZwXfLZSWoCUp5+jNI8cLo3l0= github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 h1:sO/saPkFgwfLaiCVTg+e9x6lAYBrqY91h1REu4NLMm0= github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54/go.mod h1:03dPo1SHYn9sU57mH67Y1p9FIcglWaHr4i/xkeYmX4o= +github.com/qor5/confx v0.0.0-20260809190714-853d25a54d11 h1:alpo737E+PrM0S/S0G2RTQ3ZVkSuF66hEH44w0itY/k= +github.com/qor5/confx v0.0.0-20260809190714-853d25a54d11/go.mod h1:gD6PmeWoKN36hzDv+3DK73PPvTMftOoazmDwFQ1IZ+8= github.com/qor5/go-bus v0.1.2 h1:R/4uRTqqDUDqd2VZwlzHE+zVurFRMfxSyXOn1Me7dy8= github.com/qor5/go-bus v0.1.2/go.mod h1:VSVJetwyy8ljDMGbRYdk9mW7Mb1wet89UZSp5avh0YQ= github.com/qor5/go-que v1.1.0 h1:jv7BYovZTXRwpshzvaolZezVILlny++AjeMdV/MV/Tc= @@ -397,6 +409,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= +github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= +github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= @@ -414,10 +428,14 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/gormx/database.go b/gormx/database.go index 85620def..b0e2c7d3 100644 --- a/gormx/database.go +++ b/gormx/database.go @@ -50,10 +50,10 @@ type DatabaseConfig struct { // pool; connections beyond it are closed when returned. It does not cap how // many connections may be open. // - // No `ltefield=MaxOpenConns` tag: MaxOpenConns == 0 means unlimited, so it - // is not an upper bound to compare against. Open() checks the pairing - // instead, and only when MaxOpenConns > 0. - MaxIdleConns int `confx:"maxIdleConns" usage:"Number of idle connections kept in the pool"` + // stop_if guards the ltefield: MaxOpenConns == 0 means unlimited, so it is + // not an upper bound to compare against, and a plain `ltefield` would + // reject the (20, 0) pairing. + MaxIdleConns int `confx:"maxIdleConns" usage:"Number of idle connections kept in the pool" validate:"stop_if=MaxOpenConns 0,ltefield=MaxOpenConns"` // MaxOpenConns caps concurrent connections. 0 (the default) means // unlimited, matching database/sql's own default: past the cap, callers // block inside sql.DB waiting for a connection to be returned, and the wait @@ -61,8 +61,8 @@ type DatabaseConfig struct { MaxOpenConns int `confx:"maxOpenConns" usage:"Maximum concurrent connections; 0 = unlimited"` ConnMaxLifetime time.Duration `confx:"connMaxLifetime" usage:"Maximum connection lifetime"` // Same shape as MaxIdleConns above: ConnMaxLifetime == 0 means connections - // are never recycled, so it is not an upper bound either. Checked in Open(). - ConnMaxIdleTime time.Duration `confx:"connMaxIdleTime" usage:"Maximum idle time for connections"` + // are never recycled, so it is not an upper bound either. + ConnMaxIdleTime time.Duration `confx:"connMaxIdleTime" usage:"Maximum idle time for connections" validate:"stop_if=ConnMaxLifetime 0,ltefield=ConnMaxLifetime"` AuthMethod AuthMethod `confx:"authMethod" usage:"Authentication method: 'password' or 'iam'" validate:"required,oneof=password iam"` IAM IAMDialectorConfig `confx:"iam" validate:"skip_nested_unless=AuthMethod iam" usage:"IAM configuration"` } @@ -117,25 +117,6 @@ func (c *dbCloserWrapper) Close() error { } func Open(ctx context.Context, conf *DatabaseConfig, opts ...gorm.Option) (*gorm.DB, io.Closer, error) { - // Checked before dialing: a configuration mistake should surface as itself, - // not behind a connection error. - // - // These two pairings cannot be `ltefield` struct tags. In both, 0 on the - // right-hand side means "no limit", not "zero" — that meaning is defined by - // the `if conf.X > 0` guards further down, and the tag layer cannot see it. - // Tagged, a perfectly good config like (maxIdleConns 20, maxOpenConns 0) - // fails validation and the service will not start. - if conf.MaxOpenConns > 0 && conf.MaxIdleConns > conf.MaxOpenConns { - return nil, nil, errors.Errorf( - "maxIdleConns (%d) must not exceed maxOpenConns (%d)", - conf.MaxIdleConns, conf.MaxOpenConns) - } - if conf.ConnMaxLifetime > 0 && conf.ConnMaxIdleTime > conf.ConnMaxLifetime { - return nil, nil, errors.Errorf( - "connMaxIdleTime (%s) must not exceed connMaxLifetime (%s)", - conf.ConnMaxIdleTime, conf.ConnMaxLifetime) - } - var ( dialector gorm.Dialector err error diff --git a/gormx/database_test.go b/gormx/database_test.go index 722bdfa6..131d870f 100644 --- a/gormx/database_test.go +++ b/gormx/database_test.go @@ -79,11 +79,11 @@ func TestConfig(t *testing.T) { }, }, { - // Neither pairing is a validation error any more: on both, 0 on the - // right-hand side means "no limit", which `ltefield` cannot express. - // Open() enforces them, and only when a real limit is configured — - // see TestMaxIdleConnsAgainstCap / TestConnMaxIdleTimeAgainstLifetime. - Name: "valid config - pairings are enforced in Open(), not here", + // Both pairings still validate here, but each ltefield is guarded by + // a stop_if: 0 on the right-hand side means "no limit", so it is not + // an upper bound and the comparison must not run. Below, both limits + // ARE set, so both comparisons apply and both are violated. + Name: "invalid config - idle above a real cap, idleTime beyond a real lifetime", Config: &gormx.DatabaseConfig{ DSN: "postgres://user:pass@localhost:5432/db", Debug: true, @@ -94,7 +94,10 @@ func TestConfig(t *testing.T) { ConnMaxLifetime: 10 * time.Minute, AuthMethod: gormx.AuthMethodPassword, }, - ExpectedErrors: nil, + ExpectedErrors: []confx.ExpectedValidationError{ + {Path: "MaxIdleConns", Tag: "ltefield"}, + {Path: "ConnMaxIdleTime", Tag: "ltefield"}, + }, }, { // The new default. Before this change maxOpenConns defaulted to 200 @@ -135,7 +138,7 @@ func TestConfig(t *testing.T) { DSN: "", // empty dsn Debug: true, Tracing: gormx.TracingConfig{}, - MaxIdleConns: 11, // no longer a validation error — enforced in Open() + MaxIdleConns: 11, // above MaxOpenConns, and MaxOpenConns is a real cap MaxOpenConns: 10, ConnMaxIdleTime: 30 * time.Minute, // maxIdleTime > maxLifetime ConnMaxLifetime: 10 * time.Minute, @@ -144,6 +147,8 @@ func TestConfig(t *testing.T) { ExpectedErrors: []confx.ExpectedValidationError{ {Path: "DSN", Tag: "required"}, {Path: "AuthMethod", Tag: "oneof"}, + {Path: "MaxIdleConns", Tag: "ltefield"}, + {Path: "ConnMaxIdleTime", Tag: "ltefield"}, }, }, }) @@ -362,73 +367,3 @@ func TestAuthMethodIAM(t *testing.T) { t.Logf("Expected error with invalid credentials: %v", err) }) } - -// The idle/open pairing moved out of struct validation and into Open(), because -// MaxOpenConns == 0 means unlimited and `ltefield` cannot express that. Open() -// must therefore reject the pairing only when a real cap is configured. -func TestMaxIdleConnsAgainstCap(t *testing.T) { - base := func() *gormx.DatabaseConfig { - return &gormx.DatabaseConfig{ - DSN: "postgres://user:pass@127.0.0.1:1/db", - ConnMaxIdleTime: 10 * time.Minute, - ConnMaxLifetime: 30 * time.Minute, - AuthMethod: gormx.AuthMethodPassword, - } - } - for _, c := range []struct { - name string - idle, open int - wantErr bool - }{ - {"cap set, idle above it", 11, 10, true}, - {"cap set, idle within it", 10, 10, false}, - {"unlimited, warm idle pool", 20, 0, false}, - } { - t.Run(c.name, func(t *testing.T) { - conf := base() - conf.MaxIdleConns, conf.MaxOpenConns = c.idle, c.open - _, _, err := gormx.Open(context.Background(), conf) - if c.wantErr { - require.ErrorContains(t, err, "must not exceed maxOpenConns") - return - } - // Anything else fails on the unreachable DSN, never on the pairing. - if err != nil { - require.NotContains(t, err.Error(), "must not exceed maxOpenConns") - } - }) - } -} - -// Same shape as the pool pairing: ConnMaxLifetime == 0 means connections are -// never recycled, so it is not an upper bound for ConnMaxIdleTime. -func TestConnMaxIdleTimeAgainstLifetime(t *testing.T) { - base := func() *gormx.DatabaseConfig { - return &gormx.DatabaseConfig{ - DSN: "postgres://user:pass@127.0.0.1:1/db", - AuthMethod: gormx.AuthMethodPassword, - } - } - for _, c := range []struct { - name string - idleTime, life time.Duration - wantErr bool - }{ - {"lifetime set, idleTime within it", 10 * time.Minute, 30 * time.Minute, false}, - {"lifetime set, idleTime beyond it", 30 * time.Minute, 10 * time.Minute, true}, - {"never recycled, idleTime only", 10 * time.Minute, 0, false}, - } { - t.Run(c.name, func(t *testing.T) { - conf := base() - conf.ConnMaxIdleTime, conf.ConnMaxLifetime = c.idleTime, c.life - _, _, err := gormx.Open(context.Background(), conf) - if c.wantErr { - require.ErrorContains(t, err, "must not exceed connMaxLifetime") - return - } - if err != nil { - require.NotContains(t, err.Error(), "must not exceed connMaxLifetime") - } - }) - } -} diff --git a/httpx/config.go b/httpx/config.go index 7616e5b1..8bc7c623 100644 --- a/httpx/config.go +++ b/httpx/config.go @@ -9,11 +9,10 @@ type ServerConfig struct { Address string `confx:"address" usage:"HTTP server address" validate:"required"` PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"` ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"` - // No `ltefield=ReadTimeout`: ReadTimeout == 0 means no read deadline at all, - // so it is not an upper bound. Tagged, a config that sets only a header - // timeout — a reasonable minimal hardening — fails validation and the - // service will not start. Checked in NewServer() instead. - ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers"` + // stop_if guards the ltefield: ReadTimeout == 0 means no read deadline at + // all, so it is not an upper bound, and a plain `ltefield` would reject a + // config that sets only a header timeout. + ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"stop_if=ReadTimeout 0,ltefield=ReadTimeout"` WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"` IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"` // MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited. diff --git a/httpx/server.go b/httpx/server.go index 6b539e7d..d8b6f3e1 100644 --- a/httpx/server.go +++ b/httpx/server.go @@ -71,16 +71,6 @@ func SetupServerFactory(name string, handler http.Handler) func(ctx context.Cont } func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) { - // Cannot be a `ltefield` struct tag: ReadTimeout == 0 means no read deadline - // at all, not "zero seconds", so it is not an upper bound. That meaning is - // defined by net/http, which the tag layer cannot see — tagged, a config - // that sets only a header timeout would fail validation. - if conf.ReadTimeout > 0 && conf.ReadHeaderTimeout > conf.ReadTimeout { - return nil, errors.Errorf( - "readHeaderTimeout (%s) must not exceed readTimeout (%s)", - conf.ReadHeaderTimeout, conf.ReadTimeout) - } - // Normalize PathPrefix to ensure predictable behavior: // - Always starts with "/" (add if missing) // - Never ends with "/" unless it's the root path "/" diff --git a/httpx/server_test.go b/httpx/server_test.go index 274d805a..6151551d 100644 --- a/httpx/server_test.go +++ b/httpx/server_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/qor5/confx" "github.com/stretchr/testify/require" "github.com/theplant/inject/lifecycle" "golang.org/x/net/http2" @@ -286,30 +287,31 @@ func TestNewServer_MaxConnections(t *testing.T) { } // ReadTimeout == 0 means no read deadline, so it is not an upper bound for -// ReadHeaderTimeout. This used to be a `ltefield=ReadTimeout` struct tag, which -// rejected a config that set only a header timeout — a reasonable minimal -// hardening — and stopped the service from starting. +// ReadHeaderTimeout. A plain `ltefield=ReadTimeout` rejected a config that set +// only a header timeout — a reasonable minimal hardening — and stopped the +// service from starting. The stop_if in front of it is what fixes that. func TestReadHeaderTimeoutAgainstReadTimeout(t *testing.T) { - for _, c := range []struct { - name string - header, read time.Duration - wantErr bool - }{ - {"read deadline set, header within it", 5 * time.Second, 10 * time.Second, false}, - {"read deadline set, header beyond it", 15 * time.Second, 10 * time.Second, true}, - {"no read deadline, header only", 10 * time.Second, 0, false}, - } { - t.Run(c.name, func(t *testing.T) { - _, err := httpx.NewServer(&httpx.ServerConfig{ - Address: ":0", - ReadHeaderTimeout: c.header, - ReadTimeout: c.read, - }, http.NotFoundHandler()) - if c.wantErr { - require.ErrorContains(t, err, "must not exceed readTimeout") - return - } - require.NoError(t, err) - }) + suite := confx.NewValidationSuite(t) + cfg := func(header, read time.Duration) *httpx.ServerConfig { + return &httpx.ServerConfig{Address: ":0", ReadHeaderTimeout: header, ReadTimeout: read} } + suite.RunTests([]confx.ExpectedValidation{ + { + Name: "read deadline set, header within it", + Config: cfg(5*time.Second, 10*time.Second), + ExpectedErrors: nil, + }, + { + Name: "read deadline set, header beyond it", + Config: cfg(15*time.Second, 10*time.Second), + ExpectedErrors: []confx.ExpectedValidationError{ + {Path: "ReadHeaderTimeout", Tag: "ltefield"}, + }, + }, + { + Name: "no read deadline, header only", + Config: cfg(10*time.Second, 0), + ExpectedErrors: nil, + }, + }) } From 4b7b73863c5b9381239f2ca84d2bbc442ffadc55 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:12:58 +0800 Subject: [PATCH 11/12] chore: bump confx to the released stop_if / stop_unless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qor5/confx#21 已合入 main(8d9c78b),go.mod 从分支 commit 换成正式的 pseudo-version v0.0.0-20260810031108-8d9c78bbd3fb。 gormx + httpx 全部测试通过。反证依旧成立:把三处 tag 里的 stop_if 去掉, 「无上限 + 热池 (20, 0)」与「只设 header 超时 (10s, 0)」两个合法配置立刻被拒。 --- go.mod | 7 +------ go.sum | 30 ++---------------------------- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index c70efb03..97970d6c 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.4.0 github.com/qiniu/api.v7/v7 v7.8.2 - github.com/qor5/confx v0.0.0-20260809190714-853d25a54d11 + github.com/qor5/confx v0.0.0-20260810031108-8d9c78bbd3fb github.com/qor5/go-bus v0.1.2 github.com/qor5/go-que v1.1.0 github.com/qor5/kx v0.0.0-20260713082723-dc32af6f8fd6 @@ -144,7 +144,6 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -156,7 +155,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/client v0.4.0 // indirect @@ -179,7 +177,6 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/redis/go-redis/v9 v9.16.0 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -205,12 +202,10 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect gorm.io/datatypes v1.2.7 // indirect gorm.io/driver/mysql v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index e607bc18..df817c8e 100644 --- a/go.sum +++ b/go.sum @@ -159,12 +159,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y= @@ -202,8 +198,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= -github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= @@ -259,8 +253,6 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= @@ -322,8 +314,6 @@ github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5L github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -367,8 +357,6 @@ github.com/ory/pagination v0.0.1 h1:Zp+0n/UXSGYlJAMN0BuRjZhULsQRebGHfqByKtZXNYI= github.com/ory/pagination v0.0.1/go.mod h1:d1ToRROAUleriPhmb2dYbhANhhLwZ8s395m2yJCDFh8= github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -386,10 +374,8 @@ github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/qiniu/api.v7/v7 v7.8.2 h1:f08kI0MmsJNzK4sUS8bG3HDH67ktwd/ji23Gkiy2ra4= github.com/qiniu/api.v7/v7 v7.8.2/go.mod h1:FPsIqxh1Ym3X01sANE5ZwXfLZSWoCUp5+jNI8cLo3l0= -github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54 h1:sO/saPkFgwfLaiCVTg+e9x6lAYBrqY91h1REu4NLMm0= -github.com/qor5/confx v0.0.0-20250426065316-0d28db5b4d54/go.mod h1:03dPo1SHYn9sU57mH67Y1p9FIcglWaHr4i/xkeYmX4o= -github.com/qor5/confx v0.0.0-20260809190714-853d25a54d11 h1:alpo737E+PrM0S/S0G2RTQ3ZVkSuF66hEH44w0itY/k= -github.com/qor5/confx v0.0.0-20260809190714-853d25a54d11/go.mod h1:gD6PmeWoKN36hzDv+3DK73PPvTMftOoazmDwFQ1IZ+8= +github.com/qor5/confx v0.0.0-20260810031108-8d9c78bbd3fb h1:ret+vc4A9xsYTjlhBbsahak1lbT+3tlAWLk/EyB+QbI= +github.com/qor5/confx v0.0.0-20260810031108-8d9c78bbd3fb/go.mod h1:gD6PmeWoKN36hzDv+3DK73PPvTMftOoazmDwFQ1IZ+8= github.com/qor5/go-bus v0.1.2 h1:R/4uRTqqDUDqd2VZwlzHE+zVurFRMfxSyXOn1Me7dy8= github.com/qor5/go-bus v0.1.2/go.mod h1:VSVJetwyy8ljDMGbRYdk9mW7Mb1wet89UZSp5avh0YQ= github.com/qor5/go-que v1.1.0 h1:jv7BYovZTXRwpshzvaolZezVILlny++AjeMdV/MV/Tc= @@ -407,12 +393,8 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= -github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= @@ -426,14 +408,10 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= @@ -530,8 +508,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= -golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -622,8 +598,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 679377d4ef4679a632577fb70de1808cb56a7109 Mon Sep 17 00:00:00 2001 From: molon <3739161+molon@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:20:29 +0800 Subject: [PATCH 12/12] docs(httpx): translate the test comments added by this PR to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本 PR 在 server_test.go 里加的 9 行注释是中文,与仓库其余部分不一致,翻掉。 只动本 PR 自己加的部分。 --- httpx/server_test.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/httpx/server_test.go b/httpx/server_test.go index 6151551d..8b3d1241 100644 --- a/httpx/server_test.go +++ b/httpx/server_test.go @@ -19,7 +19,7 @@ import ( "github.com/qor5/x/v3/httpx" ) -// serve 起一个监听在随机端口上的 server,返回其地址。 +// serve starts a server on a random port and returns its address. func serve(t *testing.T, conf *httpx.ServerConfig, handler http.Handler) string { t.Helper() @@ -35,8 +35,9 @@ func serve(t *testing.T, conf *httpx.ServerConfig, handler http.Handler) string return ln.Addr().String() } -// h2cClient 用 prior-knowledge 模式(直接发 HTTP/2 前导)连明文端口, -// 这正是 Envoy / gRPC 客户端在 appProtocol=h2c 下的行为。 +// h2cClient talks to a cleartext port in prior-knowledge mode (it sends the +// HTTP/2 preface straight away), which is exactly what Envoy and gRPC clients +// do when appProtocol is h2c. func h2cClient() *http.Client { return &http.Client{ Transport: &http2.Transport{ @@ -48,8 +49,9 @@ func h2cClient() *http.Client { } } -// 迁移到 http.Server.Protocols 之后,明文 HTTP/2 必须仍然可用—— -// 这是替换掉已废弃的 h2c.NewHandler 时最需要守住的行为。 +// Cleartext HTTP/2 must keep working after the move to http.Server.Protocols. +// This is the behaviour most at risk when replacing the deprecated +// h2c.NewHandler, so it is asserted directly. func TestNewServer_H2C(t *testing.T) { addr := serve(t, &httpx.ServerConfig{Address: ":0"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -65,7 +67,7 @@ func TestNewServer_H2C(t *testing.T) { require.Equal(t, "HTTP/2.0", string(body)) } -// 同一个 server 必须同时还能服务 HTTP/1.1。 +// The same server must still serve HTTP/1.1. func TestNewServer_HTTP1StillWorks(t *testing.T) { addr := serve(t, &httpx.ServerConfig{Address: ":0"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -81,9 +83,10 @@ func TestNewServer_HTTP1StillWorks(t *testing.T) { require.Equal(t, "HTTP/1.1", string(body)) } -// 读服务端在 SETTINGS 帧里通告的 MAX_CONCURRENT_STREAMS。 -// 这是唯一能证明 http.Server.HTTP2 真的生效的方式——Go 1.25 的字段注释还写着 -// "does not yet have any effect",那句已经过时,但只能实测来确认。 +// Reads MAX_CONCURRENT_STREAMS as the server advertises it in the SETTINGS +// frame. This is the only way to prove http.Server.HTTP2 is honoured: the +// field's doc comment in Go 1.25 still says "does not yet have any effect", +// which is stale, but nothing short of measuring it says so. func advertisedMaxStreams(t *testing.T, addr string) uint32 { t.Helper()