fix(httpx,gormx): unencrypted-HTTP2 via Protocols, resource caps, and three ltefield tags that reject valid configs - #625
Merged
Conversation
…ection caps
三处改动,核心是第一处。
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) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates httpx server initialization to remove reliance on deprecated h2c.NewHandler, explicitly enable HTTP/1.1 + HTTP/2 (TLS + h2c prior-knowledge) via http.Server.Protocols, and adds safeguards against oversized request bodies and excessive open connections.
Changes:
- Replace deprecated
h2c.NewHandlerusage withhttp.Server.Protocols(enabling h2c prior-knowledge + HTTP/2 over TLS). - Add
MaxRequestBodySize(viahttp.MaxBytesHandler) andMaxConnections(vianetutil.LimitListener) toServerConfigand server/listener setup. - Add new tests for h2c prior-knowledge, HTTP/1.1 compatibility, and request-body size limiting; update example config with new knobs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| httpx/server.go | Applies max body size limit at the outer handler layer, adds connection cap via LimitListener, and enables protocols via http.Server.Protocols. |
| httpx/server_test.go | Adds coverage for h2c prior-knowledge, HTTP/1.1 still working, and MaxRequestBodySize behavior. |
| httpx/example/config.yaml | Documents and exposes maxRequestBodySize and maxConnections in the example configuration. |
| httpx/config.go | Extends ServerConfig with MaxRequestBodySize and MaxConnections plus usage/docs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…quests usage 文案原来只写「maximum number of concurrent connections」,读的人很容易 把它当成并发请求上限。HTTP/1.1 下两者数值接近,HTTP/2 下完全脱钩——一条连接 可以多路复用许多并发请求,所以它管不住并发,只防 fd 耗尽。 这个混淆在 review 中被真实地问到了,说明文案不够。现在 usage 里直接写明 「connections, NOT requests」,doc comment 里补上两种协议下的差异,以及应该 用什么来限并发(网关 circuit breaker 或 in-flight middleware)。
既然两种模式下 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) <noreply@anthropic.com>
…in prod
三件事,都是冲着「已有生产项目在引用这个库」来的。
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) <noreply@anthropic.com>
两条 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) <noreply@anthropic.com>
…open pairing out of validation ## 默认值 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) <noreply@anthropic.com>
…struct tags 三个字段用 `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) <noreply@anthropic.com>
原注释把它说成「防 fd 耗尽」,并让人去用网关的 circuitBreaker 兜并发。两句都要改:
· 它触顶时 netutil.LimitListener 停止 Accept,连接堆在内核 backlog 里,
客户端等到自己超时 —— 不记日志、不拒绝,是一条看不见的队列。
· 这个数两头都站不住:低到能约束单连接内存时,离进程的 fd 上限还差几个
数量级,所以既没防住 fd 也没防住内存。
· 网关侧的并发闸门已不再推荐(见 theplant/mad-provisioning#123),
不该再把它当成配套方案写在这里。
真正约束资源占用的是上面那几个超时。改成「默认 0 通常就是对的,除非你确实
需要一个硬性连接上限、且拿得出依据」。
之前在这两个包的注释里写了「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 从来没有真正生效过。
… constructors 三处跨字段比较的右手边取 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 成正式版本。
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)」两个合法配置立刻被拒。
本 PR 在 server_test.go 里加的 9 行注释是中文,与仓库其余部分不一致,翻掉。 只动本 PR 自己加的部分。
shenzhaoyan
approved these changes
Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1.
h2c.NewHandler已废弃 → 改用http.Server.Protocolsx/net(本仓库依赖的 v0.55.0 起)里的声明:
同一个文件还有一条我们一直没照做的警告:
标准库实现(
net/http/server.go的maybeServeUnencryptedHTTP2)只 Peek 24 字节比对PRI * HTTP/2.0前导,仅支持 prior-knowledge 模式——「首请求全量入内存」正是 Upgrade 模式的实现代价,少支持一种模式反而少了一个内存放大面。顺带把 HTTP/2 的启用从「由
tls.enabled=false反推」改成显式声明三个协议位。原来的 if/else 让「TLS 关闭」隐式蕴含「启用 h2c」,而这两件事语义无关。依赖
Upgrade: h2c头升级的客户端将静默退回 HTTP/1.1(不报错)。Envoy(配appProtocol: kubernetes.io/h2c时)和 gRPC 客户端用的都是 prior-knowledge,不受影响。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 保持不动。
若将来确实要加,建议沿用标准库的名字
maxConcurrentStreams,不要叫maxParallelRequests——Envoy 的同名字段是全局在途请求数,语义完全不同,同名会让人误以为设了它就有全局上限。测试
新增
httpx/server_test.go——NewServer此前没有测试文件。覆盖迁移后最需要守住的行为:TestNewServer_H2C:prior-knowledge h2c 仍可用(用http2.Transport{AllowHTTP: true}验证r.Proto == "HTTP/2.0")TestNewServer_HTTP1StillWorks:同一 server 上 HTTP/1.1 仍可用TestNewServer_MaxRequestBodySize:低于上限、超过上限、0 = 不限 三种情形go test ./httpx/... ./healthz/... ./netx/...全绿。背景
来自一次 pod 稳定性审查。相关的两个 P0 修复已分别提在 theplant/mcd-freewifi#98 和 theplant/mad-provisioning#123。
gormx:
maxOpenConns默认 200 → 0,以及三处ltefield的同一个坑1. 默认值 200 从来没有真正生效过
单个 pod 不可能有 200 个并发查询,所以 200 不是上限,只是一个看起来像经过考量、实际从不 binding 的数字——而消费方会照着它做容量规划(「200 × 5 副本 = 1000 条,超了 RDS 的 475」),基于一个从未发生的前提。
改成
0(unlimited),与database/sql自身的默认一致。要不要设上限是消费方的取舍,本 PR 不替使用者做这个决定 —— 库的注释只陈述机制:超过上限时调用方阻塞在
sql.DB内部,而这个等待只能通过DBStats.WaitCount看到。需要上限的部署自己设一个即可。maxIdleConns保持 20。顺带把它的注释说准了:它是 pool 里保留的空闲连接数(超出的在归还时关闭),不限制能开多少连接。2. 连带必须改:三处
ltefield拿「0 = 不限制」当上界只改默认值会让所有消费方启动即崩。
MaxIdleConns带着validate:"ltefield=MaxOpenConns",而20 <= 0是 false。顺着查下去,同一个坑在这个仓库里有三处:
gormx.MaxIdleConnsltefield=MaxOpenConns(20, 0)— 不限制连接数gormx.ConnMaxIdleTimeltefield=ConnMaxLifetime(10m, 0)— 连接永不回收httpx.ReadHeaderTimeoutltefield=ReadTimeout(10s, 0)— 只设 header 超时三条都实测复现过。httpx 那条最容易踩:它没有默认值文件,而「只设一个 header 超时」是很自然的最小加固。
解法:
stop_if守住那个ltefieldstop_if命中时让该字段后续的 tag 短路,它自己的错误由 confx 按 tag 名滤掉。go-playground/validator里没有能做这件事的内置 tag —— 183 个内置项里skip_*家族只有一个skip_unless,而它return hasValue(fl),是个存在性检查,后面的 tag 照跑。所以这个 tag 加在了 qor5/confx#21。已反证:把
stop_if从 tag 里去掉,「无上限 + 热池(20, 0)」与「只设 header 超时(10s, 0)」两个合法配置立刻被拒。验证
httpx+gormx全部测试通过。新增:TestMaxIdleConnsAgainstCapTestConnMaxIdleTimeAgainstLifetimeTestReadHeaderTimeoutAgainstReadTimeout各钉三种组合(有上限且合规 / 有上限且越界 / 无上限)。
TestConfig补一条20 / 0的用例——它正是改默认值的前提。关联
maxOpenConns与网关熔断PROV_DB_POOL_IDLE_EXCEEDS_OPEN有完全相同的0 = 不限制bug,一并修了