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

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