feat: release v0.27 unified HTTP limits and safe request shutdown
This commit is contained in:
@@ -118,7 +118,7 @@ python3 scripts/verifyTransports.py --version v0.6.0-preview
|
||||
|
||||
第一条验证标准库适配器复用完整 Handler / Service / Repository。第二条在源码目录外创建无 replace 的消费模块,从远程版本导入同一套验收包;自研适配器也应使用同一个公共测试包。
|
||||
|
||||
当前替换边界分成两层:`transport/nethttp` 提供标准库路由,`transport/native` 进一步接管 TCP 监听、连接管理和 HTTP/1.1 协议 I/O。`App.Run` / `App.RunContext` 仍使用标准库 `net/http.Server`,需要显式调用 `App.RunWith(ctx, native.New(nil), address)` 才会启用 native 预览实现。native 当前通过标准 `http.Request` / `http.ResponseWriter` 桥接到业务层,后续可在不改 Handler / Service 的前提下逐步替换解析器和响应写回。
|
||||
当前替换边界分成两层:`transport/nethttp` 提供标准库路由,`transport/native` 进一步接管 TCP 监听、连接管理和 HTTP/1.1 协议 I/O。`App.Run` / `App.RunContext` / `ServeContext` 使用配置选中的 transport,默认 nethttp;推荐 `WithTransport(TransportNative)` 启用 native,框架会统一装配限额和 HTTP 配置。自定义 Runner 才使用 `RunWith`。native 通过标准 `http.Request` / `http.ResponseWriter` 桥接到业务层。
|
||||
|
||||
## HTTP 配置(v0.9.1-preview)
|
||||
|
||||
@@ -152,4 +152,28 @@ I/O 超时不会强制终止 CPU 计算或数据库调用;业务和下游调
|
||||
|
||||
YAML/JSON 字段与上述字段对应的 lowerCamelCase 一致,位于 `config.Config` 的 `http` 对象。脚手架将框架配置包在 `server` 下,因此生成项目填写 `server.http`;直接用 `config.LoadFile` 加载框架配置时填写根级 `http`。环境变量由文件显式声明,例如 `readTimeout: "${HTTP_READ_TIMEOUT:-30s}"`,不再按字段自动覆盖。`omni.New` 不自动读取文件;先 `config.LoadFile` / `config.LoadProject`,再 `WithConfig`。Go-only 的 `ErrorLog` 在文件加载之后设置。
|
||||
|
||||
`NewServer` 始终返回普通 `*http.Server`,应用 HTTP 字段但不负责加载 TLS、监听、退出或资源清理;非法 HTTP 值会 panic。`RunContext` / `ServeContext` 的非法配置则作为 error 返回并关闭 App 资源。手动修改返回服务器的字段仍然有效。项目自己的 Runner 通过 `RunWith` 接入时,配置由该 Runner 持有,这组 HTTP 配置不会覆盖它。
|
||||
`NewServer` 始终返回普通 `*http.Server`,应用 HTTP 字段但不负责加载 TLS、监听、退出或资源清理;非法 HTTP 值或路由限额冲突会 panic。`RunContext` / `ServeContext` 的非法配置则作为 error 返回并关闭 App 资源。手动修改返回服务器的字段仍然有效。`RunWith` 的网络配置由 Runner 持有;App 自身的请求体策略和取消收尾窗口仍生效,框架无法自动移除自定义 Runner、反向代理或业务中间件的其他限额。
|
||||
|
||||
## 统一请求体限额
|
||||
|
||||
三种内置 transport 通过 App 共用下列配置,全部以字节为单位,范围 0..1 TiB:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
http:
|
||||
defaultMaxBodyBytes: 1048576 # 普通 App 路由默认 1 MiB
|
||||
maxBodyBytes: 20971520 # 所有请求硬上限 20 MiB
|
||||
shutdownCancelTimeout: 5s
|
||||
```
|
||||
|
||||
- `DefaultMaxBodyBytes`:未显式声明限额的 App/RouterGroup 路由默认值;零为 8 MiB,如果硬上限更小则取硬上限。显式默认值超过硬上限会报配置错误。
|
||||
- `HandleWithBodyLimit` / `.api @request(maxBytes: ...)`:覆盖普通路由默认值。生成契约既有默认 1 MiB 保持;服务默认值不会扩大契约限制。
|
||||
- `MaxBodyBytes`:服务硬上限;零仅保留框架可支持的 1 TiB 上限,不表示请求会预分配相应内存。路由超过硬上限会在监听前报错,包含完整路由、路由限额、服务限额。`RunWith` 也检查冲突;`NewServer` 在创建时检查,外部宿主在注册完成后调用 `app.ValidateHTTP()` 可预检;未经预检直接调用 App/Engine 时,首次请求拒绝非法配置。
|
||||
|
||||
App 在中间件读取请求之前安装流式 reader 限额,支持已知长度和未知长度。native/HTTP2 在 App 装配下只保留 1 TiB 协议上界,原来的隐含 8 MiB 不再挡住放宽的上传路由。请求进入 App 后使用 `BODY_TOO_LARGE` 错误路径;服务硬上限提前拒绝时使用 App 响应策略,路由限额使用该路由策略。native 提前拒绝后先发送响应再半关闭写方向,最多等待 500ms 避免未读上传触发 TCP reset 吞掉提示;不会等待上传完成。错误目录将 HTTP 状态映射为 200 时仍保留请求体拒绝语义。已提交的流式响应不能撤回;处理器应先完成有界读取/校验再执行不可逆业务。
|
||||
|
||||
底层协议错误(例如非法 framing 或超过 1 TiB)可能在路由前直接拒绝。直接使用 `native.Server` / `http2.Server` 的 `MaxBodyBytes` 默认仍为 8 MiB,由该服务器的拥有者配置;通过 `RunWith` 传入这些服务器也需自行协调其限额,推荐使用 `WithTransport` 获取统一装配。高级 `Router()` 原始注册绕过 App 路由默认值,但经 App/Engine 服务时仍有服务硬限额;直接调用 Router.ServeHTTP 会绕过整个 App 策略。
|
||||
|
||||
multipart 的整个请求包含文件、字段和边界,`MaxFileBytes` 与请求体上限分别控制;幂等中间件 `MaxRequestBytes` 控制缓存量,不随路由自动放大。代理层限额继续在代理配置中维护。框架不猜测业务文件规格或代理策略。
|
||||
|
||||
迁移:旧 nethttp 手写路由此前无统一默认 reader 限额,现在默认 8 MiB。大请求用路由覆盖或显式默认值;已有生成接口保留契约限额。示例见 [httpSafety](../examples/httpSafety/README.md)。
|
||||
|
||||
@@ -247,3 +247,29 @@ func TestCanceledServeContextWaitsForShutdownDeadline(t *testing.T) {
|
||||
}
|
||||
waitSignal(t, canceled)
|
||||
}
|
||||
|
||||
func TestShutdownWaitsForHandlerAfterClientDisconnect(t *testing.T) {
|
||||
started, canceled, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
s := New(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
defer close(finished)
|
||||
close(started)
|
||||
<-r.Context().Done()
|
||||
close(canceled)
|
||||
<-release
|
||||
}))
|
||||
defer func() { close(release); waitSignal(t, finished) }()
|
||||
address, done := launchServer(t, s, context.Background())
|
||||
p := newFramePeer(t, address)
|
||||
p.get(t, 1, "/hold")
|
||||
waitSignal(t, started)
|
||||
p.Close()
|
||||
waitSignal(t, canceled)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := s.Shutdown(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("disconnected handler reported as drained: %v", err)
|
||||
}
|
||||
// Serve can finish its bounded shutdown, but successful Shutdown must never
|
||||
// imply that a disconnected, still-running handler has completed.
|
||||
_ = serveResult(t, done)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ type Server struct {
|
||||
activeConns atomic.Int64
|
||||
activeRequests atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
requestWG sync.WaitGroup
|
||||
}
|
||||
|
||||
// Stats is a point-in-time view of HTTP/2 transport admission and lifecycle.
|
||||
@@ -390,7 +391,8 @@ func (s *Server) tlsHandshakeTimeout() time.Duration {
|
||||
return defaultTLSHandshake
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections and waits for active HTTP/2 sessions.
|
||||
// Shutdown stops accepting connections/requests and waits for both HTTP/2
|
||||
// sessions and admitted handlers, even when the client has already disconnected.
|
||||
// If the context expires, remaining connections are closed so handlers can
|
||||
// observe cancellation through their request contexts.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
@@ -436,6 +438,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
s.requestWG.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
@@ -476,6 +479,17 @@ type requestGate struct {
|
||||
}
|
||||
|
||||
func (g *requestGate) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
// Synchronize admission with Shutdown before it starts waiting. Connection
|
||||
// completion alone does not join x/net/http2's separate handler goroutines.
|
||||
g.server.mu.Lock()
|
||||
if g.server.closing {
|
||||
g.server.mu.Unlock()
|
||||
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
g.server.requestWG.Add(1)
|
||||
g.server.mu.Unlock()
|
||||
defer g.server.requestWG.Done()
|
||||
if g.slots != nil {
|
||||
select {
|
||||
case g.slots <- struct{}{}:
|
||||
|
||||
@@ -2,6 +2,7 @@ package native_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||
)
|
||||
|
||||
@@ -58,3 +61,41 @@ func TestApplicationRejectsUnreadBodyPromptly(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyRejectionWithBusinessHTTP200DoesNotDrain(t *testing.T) {
|
||||
app := omni.New(omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"BODY_TOO_LARGE": {Code: "LIMIT_REJECTED", Message: "body limit exceeded"},
|
||||
}}))
|
||||
app.HandleWithBodyLimit("POST", "/upload", 32, nil, func(*omni.Context) error { t.Error("oversize reached handler"); return nil })
|
||||
server := native.New(app)
|
||||
server.ReadTimeout = 10 * time.Second
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- server.Serve(context.Background(), l) }()
|
||||
defer stopServer(t, server, l, done)
|
||||
defer app.Close()
|
||||
c, err := net.DialTimeout("tcp", l.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(time.Second))
|
||||
// Intentionally withhold every body byte. The rejection must be sent before
|
||||
// any attempted drain, despite the project mapping its HTTP status to 200.
|
||||
_, err = io.WriteString(c, "POST /upload HTTP/1.1\r\nHost: local\r\nContent-Length: 1024\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := http.ReadResponse(bufio.NewReader(c), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil || res.StatusCode != 200 || !res.Close || !bytes.Contains(data, []byte("LIMIT_REJECTED")) {
|
||||
t.Fatalf("status=%d close=%v body=%s err=%v", res.StatusCode, res.Close, data, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +420,9 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
|
||||
status = protocolErr.status
|
||||
message = protocolErr.message + "\n"
|
||||
}
|
||||
_ = writeSimple(state.conn, status, message, true, s.WriteTimeout)
|
||||
if writeSimple(state.conn, status, message, true, s.WriteTimeout) == nil && status == http.StatusRequestEntityTooLarge {
|
||||
closeRejectedBody(baseCtx, state.conn)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -482,13 +484,15 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
|
||||
}
|
||||
handler.ServeHTTP(writer, request)
|
||||
var bodyErr error
|
||||
if body, ok := request.Body.(*requestBody); ok && !body.done && body.err == nil && writer.status == http.StatusRequestEntityTooLarge {
|
||||
rejectedBody := false
|
||||
if body, ok := request.Body.(*requestBody); ok && !body.done && body.err == nil && (writer.bodyTooLarge || writer.status == http.StatusRequestEntityTooLarge) {
|
||||
// An application can reject size from the headers alone when its
|
||||
// body limit is below the transport limit. Waiting to
|
||||
// drain a slow or absent body would withhold the rejection response.
|
||||
// Abandon it and close; unread bytes can never become a next request.
|
||||
body.closed = true
|
||||
closeAfter = true
|
||||
rejectedBody = true
|
||||
} else {
|
||||
bodyErr = request.Body.Close()
|
||||
}
|
||||
@@ -538,11 +542,30 @@ func (s *Server) serveConnection(baseCtx context.Context, state *connection) {
|
||||
s.writerPool.Put(writer)
|
||||
shouldClose = s.finishRequest(state) || shouldClose
|
||||
if shouldClose {
|
||||
if rejectedBody {
|
||||
closeRejectedBody(baseCtx, state.conn)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Half-close after flushing a rejection, then allow the peer to consume it
|
||||
// before closing a socket with unread upload data. As in net/http, immediate
|
||||
// full close can cause a TCP reset to hide the response on BSD systems. This
|
||||
// never waits for the body and cancellation bounds the additional grace.
|
||||
func closeRejectedBody(ctx context.Context, conn net.Conn) {
|
||||
if writer, ok := conn.(interface{ CloseWrite() error }); ok {
|
||||
_ = writer.CloseWrite()
|
||||
}
|
||||
timer := time.NewTimer(500 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// Publish the idle transition under the same lock that starts shutdown. Either
|
||||
// Shutdown observes an idle connection and closes it, or this request observes
|
||||
// shutdown and closes it instead of starting another Keep-Alive read.
|
||||
@@ -1055,6 +1078,7 @@ func writeSimple(conn net.Conn, status int, body string, closeAfter bool, timeou
|
||||
|
||||
type responseWriter struct {
|
||||
header http.Header
|
||||
bodyTooLarge bool
|
||||
status int
|
||||
wroteHeader bool
|
||||
body bytes.Buffer
|
||||
@@ -1081,6 +1105,7 @@ func (w *responseWriter) reset(method string) {
|
||||
}
|
||||
w.status = 0
|
||||
w.wroteHeader = false
|
||||
w.bodyTooLarge = false
|
||||
if w.body.Cap() > maxPooledResponseBytes {
|
||||
w.body = bytes.Buffer{}
|
||||
} else {
|
||||
@@ -1110,6 +1135,10 @@ func (w *responseWriter) configure(request *http.Request, conn net.Conn, closeAf
|
||||
|
||||
func (w *responseWriter) Header() http.Header { return w.header }
|
||||
|
||||
// RequestBodyTooLarge preserves semantic body rejection independently from a
|
||||
// project's chosen HTTP response status. Called through the optional writer hook.
|
||||
func (w *responseWriter) RequestBodyTooLarge() { w.bodyTooLarge = true }
|
||||
|
||||
func (w *responseWriter) WriteHeader(status int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user