feat: release v0.27 unified HTTP limits and safe request shutdown

This commit is contained in:
2026-09-29 23:24:19 +08:00
parent 859f1851b1
commit 3b00bb55d1
42 changed files with 1191 additions and 70 deletions

View File

@@ -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)
}
}

View File

@@ -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