feat: release v0.27 unified HTTP limits and safe request shutdown
This commit is contained in:
69
app/app.go
69
app/app.go
@@ -33,6 +33,10 @@ type App struct {
|
||||
closeErr error
|
||||
clientIPResolver *httpx.ClientIPResolver
|
||||
responseEncoder response.Encoder
|
||||
requests requestDrain
|
||||
bodyLimits []routeBodyLimit
|
||||
validateOnce sync.Once
|
||||
validateErr error
|
||||
}
|
||||
|
||||
// Runner is the small lifecycle boundary used by an HTTP server
|
||||
@@ -100,7 +104,14 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
||||
}
|
||||
middlewares = append(middlewares, middleware.Recovery())
|
||||
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
|
||||
application.handler = corsPolicy.Wrap(router)
|
||||
application.requests.done = make(chan struct{})
|
||||
application.handler = corsPolicy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength > application.hardBodyLimit() {
|
||||
application.adapt(func(*httpx.Context) error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
router.ServeHTTP(w, r)
|
||||
}))
|
||||
if fallback, ok := router.(httpx.FallbackRouter); ok {
|
||||
adaptError := func(status int, code, message string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -113,15 +124,31 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
||||
}
|
||||
|
||||
// Router exposes the underlying routing contract for advanced registration.
|
||||
// Serve requests through App or Engine so application CORS policy is applied.
|
||||
// Serve through App or Engine for CORS, hard body limits and shutdown tracking.
|
||||
func (a *App) Router() httpx.Router { return a.router }
|
||||
|
||||
// Engine is kept as a compatibility name for the old API. It returns a
|
||||
// standard http.Handler, so callers do not depend on a router type.
|
||||
func (a *App) Engine() http.Handler { return a.handler }
|
||||
func (a *App) Engine() http.Handler { return a }
|
||||
|
||||
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
||||
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
ctx, work := a.requests.begin(request.Context())
|
||||
if work == nil {
|
||||
// Do not invoke encoders, middleware or loggers after their owners close.
|
||||
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer a.requests.end(work)
|
||||
a.validateOnce.Do(func() { a.validateErr = a.ValidateHTTP() })
|
||||
if a.validateErr != nil {
|
||||
http.Error(writer, "invalid HTTP configuration", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
request = request.WithContext(ctx)
|
||||
if request.Body != nil {
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, a.hardBodyLimit())
|
||||
}
|
||||
a.handler.ServeHTTP(writer, request)
|
||||
}
|
||||
|
||||
@@ -143,7 +170,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App {
|
||||
}
|
||||
|
||||
// OnShutdown registers a cleanup function owned by the application. Cleanup
|
||||
// functions run in reverse registration order when Close or RunContext ends.
|
||||
// functions run in reverse registration order after all handlers have returned.
|
||||
// Incomplete draining skips these callbacks and returns ErrResourcesRetained.
|
||||
// This keeps omni.New as the only public startup entry point while allowing
|
||||
// projects to attach databases, loggers and other resources to its lifecycle.
|
||||
func (a *App) OnShutdown(cleanup func() error) *App {
|
||||
@@ -153,9 +181,21 @@ func (a *App) OnShutdown(cleanup func() error) *App {
|
||||
return a
|
||||
}
|
||||
|
||||
// Close releases application resources. It is safe to call more than once.
|
||||
// Close stops admission, cancels active requests and waits for their handlers
|
||||
// to return before releasing resources. It is idempotent. If cancellation grace
|
||||
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
|
||||
// Use RunContext/ServeContext for graceful draining before cancellation. Do not
|
||||
// call Close from a handler or cleanup callback; it would wait for itself.
|
||||
func (a *App) Close() error {
|
||||
a.closeOnce.Do(func() {
|
||||
timeout := a.config.HTTP.ShutdownCancelTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if err := a.requests.cancelAndWait(timeout); err != nil {
|
||||
a.closeErr = err
|
||||
return
|
||||
}
|
||||
for index := len(a.closers) - 1; index >= 0; index-- {
|
||||
if err := a.closers[index](); err != nil {
|
||||
a.closeErr = errors.Join(a.closeErr, err)
|
||||
@@ -177,7 +217,7 @@ func (a *App) Run(address ...string) error {
|
||||
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
|
||||
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
||||
func (a *App) NewServer(address string) *http.Server {
|
||||
if err := a.config.HTTP.Validate(); err != nil {
|
||||
if err := a.ValidateHTTP(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if address == "" {
|
||||
@@ -187,7 +227,7 @@ func (a *App) NewServer(address string) *http.Server {
|
||||
}
|
||||
}
|
||||
cfg := a.config.HTTP
|
||||
return &http.Server{Addr: address, Handler: a.handler,
|
||||
return &http.Server{Addr: address, Handler: a,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
||||
@@ -202,6 +242,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) (runErr error)
|
||||
if ctx.Err() != nil {
|
||||
return a.Close()
|
||||
}
|
||||
stopWatching := a.watchShutdown(ctx)
|
||||
defer stopWatching()
|
||||
serve, err := a.prepareTransport()
|
||||
if err != nil {
|
||||
return errors.Join(err, a.Close())
|
||||
@@ -228,6 +270,11 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (ru
|
||||
if ctx.Err() != nil {
|
||||
return a.Close()
|
||||
}
|
||||
if err := a.ValidateHTTP(); err != nil {
|
||||
return errors.Join(err, a.Close())
|
||||
}
|
||||
stopWatching := a.watchShutdown(ctx)
|
||||
defer stopWatching()
|
||||
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
||||
}
|
||||
|
||||
@@ -261,12 +308,16 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
// Serve returning only proves the accept loop ended. Cancel any remaining
|
||||
// connections before the application completion barrier and cleanup.
|
||||
_ = server.Close()
|
||||
closeErr := a.Close()
|
||||
if err == http.ErrServerClosed {
|
||||
return closeErr
|
||||
}
|
||||
return errors.Join(err, closeErr)
|
||||
case <-ctx.Done():
|
||||
a.requests.stop()
|
||||
shutdownTimeout := a.config.ShutdownTimeout
|
||||
if shutdownTimeout <= 0 {
|
||||
shutdownTimeout = 10 * time.Second
|
||||
@@ -275,8 +326,8 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
||||
defer cancel()
|
||||
shutdownErr := server.Shutdown(shutdownCtx)
|
||||
if shutdownErr != nil {
|
||||
// Shutdown leaves active connections open on timeout. Close them so
|
||||
// request contexts are canceled before releasing shared resources.
|
||||
// Closing sockets requests cancellation; App.Close separately waits
|
||||
// for the actual handler return before releasing shared resources.
|
||||
shutdownErr = errors.Join(shutdownErr, server.Close())
|
||||
}
|
||||
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
||||
|
||||
63
app/body.go
63
app/body.go
@@ -1,11 +1,68 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
type routeBodyLimit struct {
|
||||
method, path string
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
func (a *App) hardBodyLimit() int64 {
|
||||
if a.config.HTTP.MaxBodyBytes > 0 {
|
||||
return a.config.HTTP.MaxBodyBytes
|
||||
}
|
||||
return httplimits.MaxBodyBytes
|
||||
}
|
||||
|
||||
func (a *App) defaultBodyLimit() int64 {
|
||||
limit := a.config.HTTP.DefaultMaxBodyBytes
|
||||
if limit <= 0 {
|
||||
limit = httplimits.DefaultBodyBytes
|
||||
}
|
||||
if limit > a.hardBodyLimit() {
|
||||
limit = a.hardBodyLimit()
|
||||
}
|
||||
// Invalid configuration is rejected at startup or the first external request.
|
||||
if limit > httplimits.MaxBodyBytes {
|
||||
limit = httplimits.MaxBodyBytes
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// ValidateHTTP checks HTTP settings and registered route body limits without
|
||||
// opening a listener. Call after registration when hosting App/Engine externally.
|
||||
// Built-in serving entry points and RunWith perform this check automatically.
|
||||
func (a *App) ValidateHTTP() error {
|
||||
if err := a.config.HTTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, route := range a.bodyLimits {
|
||||
if route.maxBytes > a.hardBodyLimit() {
|
||||
return &config.ConfigError{Field: "http.maxBodyBytes", Message: fmt.Sprintf("route %s %s maxBytes=%d exceeds service hard limit=%d; raise http.maxBodyBytes or lower the route limit", route.method, route.path, route.maxBytes, a.hardBodyLimit())}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinRoutePath(prefix, path string) string {
|
||||
if path == "" {
|
||||
if prefix == "" {
|
||||
return "/"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
}
|
||||
|
||||
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||
checked := httpx.LimitBody(maxBytes)(handler)
|
||||
if encoder == nil {
|
||||
@@ -25,8 +82,10 @@ func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler http
|
||||
// the response policy, including auth failures and parameter errors.
|
||||
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
|
||||
a.bodyLimits = append(a.bodyLimits, routeBodyLimit{method, path, maxBytes})
|
||||
}
|
||||
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
|
||||
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
|
||||
g.app.bodyLimits = append(g.app.bodyLimits, routeBodyLimit{method, joinRoutePath(g.prefix, path), maxBytes})
|
||||
}
|
||||
|
||||
224
app/bodyPolicy_test.go
Normal file
224
app/bodyPolicy_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func serveBodyPolicy(t *testing.T, a *App, transport string) (string, *http.Client) {
|
||||
t.Helper()
|
||||
cert, key, clientTLS := testutil.CertificateFiles(t)
|
||||
a.config.Transport = transport
|
||||
a.config.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.ServeContext(ctx, l) }()
|
||||
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS, ForceAttemptHTTP2: transport == config.TransportHTTP2}}
|
||||
t.Cleanup(func() {
|
||||
client.CloseIdleConnections()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop")
|
||||
}
|
||||
})
|
||||
return "https://" + l.Addr().String(), client
|
||||
}
|
||||
|
||||
func consumeBody(c *httpx.Context) error {
|
||||
_, err := io.Copy(io.Discard, c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Text(200, "accepted")
|
||||
}
|
||||
|
||||
func TestUploadPolicyAcrossTransports(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
a := New()
|
||||
a.POST("/default", consumeBody)
|
||||
a.HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||
base, client := serveBodyPolicy(t, a, transport)
|
||||
payload := bytes.Repeat([]byte{'x'}, 9<<20)
|
||||
for _, chunked := range []bool{false, true} {
|
||||
for _, path := range []string{"/default", "/large", "/v1/files/large"} {
|
||||
var body io.Reader = bytes.NewReader(payload)
|
||||
if chunked {
|
||||
body = struct{ io.Reader }{body}
|
||||
}
|
||||
req, _ := http.NewRequest("POST", base+path, body)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 200
|
||||
if path == "/default" {
|
||||
want = 413
|
||||
}
|
||||
if res.StatusCode != want || (want == 413 && !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||
t.Fatalf("%s unknownLength=%v: %d %s", path, chunked, res.StatusCode, data)
|
||||
}
|
||||
if transport == config.TransportHTTP2 && res.ProtoMajor != 2 {
|
||||
t.Fatal("HTTP/2 not negotiated")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredBodyPolicyBeforeMiddleware(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 32, MaxBodyBytes: 128}))
|
||||
var calls atomic.Int32
|
||||
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
data, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Request().Body = io.NopCloser(bytes.NewReader(data))
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
h := func(c *httpx.Context) error { calls.Add(1); return consumeBody(c) }
|
||||
a.POST("/default", h)
|
||||
a.HandleWithBodyLimit("POST", "/override", 64, nil, h)
|
||||
a.HandleWithBodyLimit("POST", "/hard", 128, nil, h)
|
||||
base, client := serveBodyPolicy(t, a, transport)
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
size, status int
|
||||
}{
|
||||
{"/default", 32, 200}, {"/default", 33, 413}, {"/override", 64, 200}, {"/override", 65, 413}, {"/hard", 128, 200}, {"/hard", 129, 413},
|
||||
} {
|
||||
for _, unknown := range []bool{false, true} {
|
||||
before := calls.Load()
|
||||
var body io.Reader = strings.NewReader(strings.Repeat("a", tc.size))
|
||||
if unknown {
|
||||
body = struct{ io.Reader }{body}
|
||||
}
|
||||
res, err := client.Post(base+tc.path, "application/octet-stream", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil || res.StatusCode != tc.status {
|
||||
t.Fatalf("%+v unknown=%v: %d %s %v", tc, unknown, res.StatusCode, data, err)
|
||||
}
|
||||
if tc.status == 413 && (calls.Load() != before || !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||
t.Fatal("oversize reached business or lost error", string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyHardLimitConflictBeforeServing(t *testing.T) {
|
||||
for _, entry := range []string{"RunContext", "ServeContext", "RunWith", "NewServer", "Engine"} {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{MaxBodyBytes: 8 << 20}))
|
||||
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/upload/", 16<<20, nil, consumeBody)
|
||||
closed := 0
|
||||
a.OnShutdown(func() error { closed++; return nil })
|
||||
if err := a.ValidateHTTP(); err == nil {
|
||||
t.Fatal("external preflight accepted conflicting route limit")
|
||||
}
|
||||
var err error
|
||||
switch entry {
|
||||
case "RunContext":
|
||||
err = a.RunContext(context.Background(), "bad address")
|
||||
case "ServeContext":
|
||||
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
err = a.ServeContext(context.Background(), l)
|
||||
if _, e = l.Accept(); !errors.Is(e, net.ErrClosed) {
|
||||
t.Fatal("listener leaked", e)
|
||||
}
|
||||
case "RunWith":
|
||||
err = a.RunWith(context.Background(), runnerFunc(func(context.Context, http.Handler, string) error { t.Error("runner started"); return nil }))
|
||||
case "NewServer":
|
||||
func() {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
err, _ = p.(error)
|
||||
}
|
||||
}()
|
||||
a.NewServer("")
|
||||
}()
|
||||
_ = a.Close()
|
||||
case "Engine":
|
||||
w := httptest.NewRecorder()
|
||||
a.Engine().ServeHTTP(w, httptest.NewRequest("POST", "/v1/files/upload/", nil))
|
||||
if w.Code != 503 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
err = a.validateErr
|
||||
_ = a.Close()
|
||||
}
|
||||
for _, part := range []string{"POST /v1/files/upload/", "16777216", "8388608", "http.maxBodyBytes"} {
|
||||
if err == nil || !strings.Contains(err.Error(), part) {
|
||||
t.Fatalf("missing %q: %v", part, err)
|
||||
}
|
||||
}
|
||||
if closed != 1 {
|
||||
t.Fatal("cleanup count", closed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBodyLimitRegistrationPaths(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 4, MaxBodyBytes: 16}))
|
||||
h := func(*httpx.Context) error { t.Error("oversize reached business"); return nil }
|
||||
encoder, _ := response.New(response.Config{})
|
||||
a.Handle("POST", "/handle", h)
|
||||
a.HandleWithResponse("POST", "/response", encoder, h)
|
||||
a.Any("/any", h)
|
||||
g := a.Group("/group")
|
||||
g.POST("/post", h)
|
||||
g.Any("/any", h)
|
||||
g.HandleWithResponse("POST", "/response", encoder, h)
|
||||
for _, path := range []string{"/handle", "/response", "/any", "/group/post", "/group/any", "/group/response"} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader("12345"))
|
||||
a.ServeHTTP(w, r)
|
||||
if w.Code != 413 {
|
||||
t.Fatal(path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/requests.go
Normal file
88
app/requests.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
// Admission and completion share a lock: once stopped, no request can enter
|
||||
// between observing zero active handlers and releasing their dependencies.
|
||||
type requestDrain struct {
|
||||
mu sync.Mutex
|
||||
stopped bool
|
||||
active map[*requestWork]struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type requestWork struct{ cancel context.CancelFunc }
|
||||
|
||||
func (d *requestDrain) begin(ctx context.Context) (context.Context, *requestWork) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return ctx, nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
w := &requestWork{cancel: cancel}
|
||||
if d.active == nil {
|
||||
d.active = make(map[*requestWork]struct{})
|
||||
}
|
||||
d.active[w] = struct{}{}
|
||||
return ctx, w
|
||||
}
|
||||
|
||||
func (d *requestDrain) end(w *requestWork) {
|
||||
w.cancel()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
delete(d.active, w)
|
||||
if d.stopped && len(d.active) == 0 {
|
||||
close(d.done)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *requestDrain) stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
d.stopped = true
|
||||
if len(d.active) == 0 {
|
||||
close(d.done)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *requestDrain) cancelAndWait(timeout time.Duration) error {
|
||||
d.stop()
|
||||
d.mu.Lock()
|
||||
for w := range d.active {
|
||||
w.cancel()
|
||||
}
|
||||
d.mu.Unlock()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-d.done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
select {
|
||||
case <-d.done:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
return fmt.Errorf("go-omni: HTTP handlers did not finish within cancellation grace %s: %w", timeout,
|
||||
errors.Join(context.DeadlineExceeded, lifecycle.ErrResourcesRetained))
|
||||
}
|
||||
}
|
||||
|
||||
// watchShutdown stops admission on the early signal without canceling accepted
|
||||
// work. The transport retains its graceful drain budget before Close cancels.
|
||||
func (a *App) watchShutdown(ctx context.Context) func() bool {
|
||||
return context.AfterFunc(ctx, a.requests.stop)
|
||||
}
|
||||
167
app/requests_test.go
Normal file
167
app/requests_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
func TestShutdownWaitsForCancellationCleanup(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
a := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(30*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: time.Second}))
|
||||
started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
var closed atomic.Int32
|
||||
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||
a.GET("/work", func(c *httpx.Context) error {
|
||||
close(started)
|
||||
<-c.Context().Done()
|
||||
close(canceled)
|
||||
<-release
|
||||
if closed.Load() != 0 {
|
||||
t.Error("dependency closed during cancellation cleanup")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.ServeContext(ctx, l) }()
|
||||
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
requestDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestDone)
|
||||
res, err := client.Get("https://" + l.Addr().String() + "/work")
|
||||
if err == nil {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
}
|
||||
}()
|
||||
await(t, started)
|
||||
cancel()
|
||||
await(t, canceled)
|
||||
if closed.Load() != 0 {
|
||||
t.Fatal("dependency closed on cancellation")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatal("returned before handler cleanup", err)
|
||||
default:
|
||||
}
|
||||
// Even an in-memory invocation through Engine must now be rejected.
|
||||
w := httptest.NewRecorder()
|
||||
a.Engine().ServeHTTP(w, httptest.NewRequest("GET", "/work", nil))
|
||||
if w.Code != 503 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
await(t, requestDone)
|
||||
if closed.Load() != 1 {
|
||||
t.Fatal("cleanup count", closed.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalServingEntriesRetainActiveResources(t *testing.T) {
|
||||
for _, entry := range []string{"App", "Engine", "NewServer", "RunWith"} {
|
||||
t.Run(entry, func(t *testing.T) {
|
||||
a := New(config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 30 * time.Millisecond}))
|
||||
started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var closed atomic.Int32
|
||||
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||
a.GET("/", func(*httpx.Context) error {
|
||||
close(started)
|
||||
<-release
|
||||
if closed.Load() != 0 {
|
||||
t.Error("closed while active")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
var h http.Handler = a
|
||||
if entry == "Engine" {
|
||||
h = a.Engine()
|
||||
}
|
||||
if entry == "NewServer" {
|
||||
h = a.NewServer("").Handler
|
||||
}
|
||||
invoke := func(h http.Handler) {
|
||||
defer close(returned)
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||
}
|
||||
defer func() { close(release); await(t, returned) }()
|
||||
var err error
|
||||
if entry == "RunWith" {
|
||||
err = a.RunWith(context.Background(), runnerFunc(func(_ context.Context, h http.Handler, _ string) error { go invoke(h); await(t, started); return nil }))
|
||||
} else {
|
||||
go invoke(h)
|
||||
await(t, started)
|
||||
err = a.Close()
|
||||
}
|
||||
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() != 0 {
|
||||
t.Fatal("retention failed", err, closed.Load())
|
||||
}
|
||||
if err = a.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal("repeat close lost retention", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAdmissionAndCloseRace(t *testing.T) {
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
a := New()
|
||||
var closed atomic.Bool
|
||||
a.OnShutdown(func() error { closed.Store(true); return nil })
|
||||
a.GET("/", func(c *httpx.Context) error {
|
||||
if closed.Load() {
|
||||
t.Error("handler admitted after resource release")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
a.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||
routes.Handle(method, path, a.adapt(handler, middleware...))
|
||||
routes.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||
}
|
||||
|
||||
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||
@@ -62,7 +62,7 @@ func (a *App) HandleWithResponse(method, path string, encoder response.Encoder,
|
||||
if encoder == nil {
|
||||
encoder = a.responseEncoder
|
||||
}
|
||||
a.router.Handle(method, path, a.adaptResponse(encoder, handler, middleware...))
|
||||
a.router.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), encoder, handler, middleware...))
|
||||
}
|
||||
|
||||
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
@@ -86,18 +86,19 @@ func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Mid
|
||||
}
|
||||
|
||||
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
a.router.Any(path, a.adapt(handler, middleware...))
|
||||
a.router.Any(path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||
}
|
||||
|
||||
// RouterGroup scopes a route prefix and optional middleware.
|
||||
type RouterGroup struct {
|
||||
prefix string
|
||||
group httpx.RouterGroup
|
||||
app *App
|
||||
middlewares []httpx.Middleware
|
||||
}
|
||||
|
||||
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware}
|
||||
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware, prefix: joinRoutePath("", path)}
|
||||
}
|
||||
|
||||
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
||||
@@ -108,7 +109,7 @@ func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
||||
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||
combined = append(combined, middleware...)
|
||||
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined}
|
||||
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined, prefix: joinRoutePath(g.prefix, path)}
|
||||
}
|
||||
|
||||
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||
@@ -128,7 +129,7 @@ func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.E
|
||||
}
|
||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||
combined = append(combined, middleware...)
|
||||
g.group.Handle(method, path, g.app.adaptResponse(encoder, handler, combined...))
|
||||
g.group.Handle(method, path, g.app.bodyHandler(g.app.defaultBodyLimit(), encoder, handler, combined...))
|
||||
}
|
||||
|
||||
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
@@ -154,5 +155,5 @@ func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...h
|
||||
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||
combined = append(combined, middleware...)
|
||||
g.group.Any(path, g.app.adapt(handler, combined...))
|
||||
g.group.Any(path, g.app.bodyHandler(g.app.defaultBodyLimit(), nil, handler, combined...))
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||
)
|
||||
|
||||
// ServeContext serves a caller-created listener using the configured transport
|
||||
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||
// and runs resource cleanup after the server has stopped.
|
||||
// and runs resource cleanup only after admitted handlers have returned.
|
||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if listener == nil {
|
||||
@@ -30,6 +31,8 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
||||
_ = listener.Close()
|
||||
return a.Close()
|
||||
}
|
||||
stopWatching := a.watchShutdown(ctx)
|
||||
defer stopWatching()
|
||||
serve, err := a.prepareTransport()
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
@@ -39,6 +42,9 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
||||
}
|
||||
|
||||
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
||||
if err := a.ValidateHTTP(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.config.ValidateTransport(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -57,6 +63,9 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
||||
switch a.config.Transport {
|
||||
case config.TransportNative:
|
||||
server := native.New(a)
|
||||
// App enforces the service/route policy after routing. Keep only the
|
||||
// supported framing ceiling here, never a hidden smaller transport cap.
|
||||
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
cfg := a.config.HTTP
|
||||
if cfg.ReadHeaderTimeout > 0 {
|
||||
@@ -82,6 +91,7 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
||||
return server.Serve, nil
|
||||
case config.TransportHTTP2:
|
||||
server := h2transport.New(a)
|
||||
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||
server.ShutdownTimeout = shutdownTimeout
|
||||
if a.config.HTTP.IdleTimeout > 0 {
|
||||
server.IdleTimeout = a.config.HTTP.IdleTimeout
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
)
|
||||
|
||||
func TestConfiguredTransportLifecycle(t *testing.T) {
|
||||
@@ -269,7 +270,9 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
|
||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 40 * time.Millisecond}))
|
||||
var cleaned atomic.Int32
|
||||
application.OnShutdown(func() error { cleaned.Add(1); return nil })
|
||||
started := make(chan context.Context, 1)
|
||||
release, finished := make(chan struct{}), make(chan struct{})
|
||||
application.GET("/blocked", func(ctx *httpx.Context) error {
|
||||
@@ -304,11 +307,17 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
||||
close(release)
|
||||
t.Fatal("handler did not start")
|
||||
}
|
||||
defer func() { close(release); await(t, finished) }()
|
||||
defer func() {
|
||||
close(release)
|
||||
await(t, finished)
|
||||
if err := application.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||
t.Fatalf("late handler return must not trigger cleanup: %v count=%d", err, cleaned.Load())
|
||||
}
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||
t.Fatalf("shutdown: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
|
||||
Reference in New Issue
Block a user