Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions httpx/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ import (
)

type ServerConfig struct {
Address string `confx:"address" usage:"HTTP server address" validate:"required"`
PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"`
ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"`
ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"ltefield=ReadTimeout"`
WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"`
IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"`
TLS TLSConfig `confx:"tls"`
Security SecurityConfig `confx:",squash"`
Address string `confx:"address" usage:"HTTP server address" validate:"required"`
PathPrefix string `confx:"pathPrefix" usage:"Path prefix for all handlers. Will be normalized to start with '/' and not end with '/' (except for root path '/'). Root path '/' is treated as no prefix. Example: 'api/v1' or '/api/v1/' both become '/api/v1'"`
ReadTimeout time.Duration `confx:"readTimeout" usage:"maximum duration before timing out read of the request"`
ReadHeaderTimeout time.Duration `confx:"readHeaderTimeout" usage:"maximum duration before timing out read of the request headers" validate:"ltefield=ReadTimeout"`
WriteTimeout time.Duration `confx:"writeTimeout" usage:"maximum duration before timing out write of the response"`
IdleTimeout time.Duration `confx:"idleTimeout" usage:"maximum amount of time to wait for the next request when keep-alives are enabled"`
// MaxRequestBodySize caps the request body via http.MaxBytesHandler. 0 means unlimited.
// Without it a single oversized body can be read entirely into memory.
MaxRequestBodySize int64 `confx:"maxRequestBodySize" usage:"maximum request body size in bytes, 0 for unlimited"`
Comment thread
molon marked this conversation as resolved.
Outdated
// MaxConnections caps concurrent connections via netutil.LimitListener. 0 means unlimited.
// This guards against fd exhaustion, NOT against request concurrency: one HTTP/2
// connection carries many streams. Cap concurrency upstream (gateway circuit breaker)
// or with an in-flight middleware instead. Accept blocks past the limit rather than rejecting.
MaxConnections int `confx:"maxConnections" usage:"maximum number of concurrent connections, 0 for unlimited"`
Comment thread
molon marked this conversation as resolved.
Outdated
TLS TLSConfig `confx:"tls"`
Security SecurityConfig `confx:",squash"`
}

type TLSConfig struct {
Expand Down
5 changes: 5 additions & 0 deletions httpx/example/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ http:
readHeaderTimeout: "4s"
writeTimeout: "20s"
idleTimeout: "120s"
# 0 = 不限。没有它,单个超大 body 会被整体读进内存。
maxRequestBodySize: 0
# 0 = 不限。只防 fd 耗尽,**不是并发闸门**:HTTP/2 一条连接可承载多个 stream。
# 并发上限应由上游(网关 circuit breaker)或 in-flight middleware 控制。
maxConnections: 0
tls:
enabled: false
certBase64: ""
Expand Down
42 changes: 35 additions & 7 deletions httpx/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,23 @@ import (
"github.com/pkg/errors"
"github.com/qor5/x/v3/netx"
"github.com/theplant/inject/lifecycle"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/net/netutil"
)

type Listener net.Listener

func SetupListener(lc *lifecycle.Lifecycle, conf *ServerConfig) (Listener, error) {
return netx.SetupListenerFactory("http-listener", conf.Address)(lc)
listener, err := netx.SetupListenerFactory("http-listener", conf.Address)(lc)
if err != nil {
return nil, err
}
// 连接数上限只防 fd 耗尽,不是并发闸门:HTTP/2 一条连接可承载多个 stream,
// 真正的并发上限应由上游(网关的 circuit breaker)或 in-flight middleware 控制。
// 超出限制时 Accept 阻塞(连接停在内核 accept queue),不是拒绝。
if conf.MaxConnections > 0 {
listener = netutil.LimitListener(listener, conf.MaxConnections)
}
Comment thread
molon marked this conversation as resolved.
Outdated
return listener, nil
}

func SetupServerFactory(name string, handler http.Handler) func(ctx context.Context, lc *lifecycle.Lifecycle, conf *ServerConfig, listener Listener) (*http.Server, error) {
Expand Down Expand Up @@ -80,13 +89,36 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) {
handler = http.StripPrefix(pathPrefix, handler)
}

// 包在最外层,让 body 上限先于路由与业务 handler 生效。
if conf.MaxRequestBodySize > 0 {
handler = http.MaxBytesHandler(handler, conf.MaxRequestBodySize)
}

srv := &http.Server{
ReadTimeout: conf.ReadTimeout,
ReadHeaderTimeout: conf.ReadHeaderTimeout,
WriteTimeout: conf.WriteTimeout,
IdleTimeout: conf.IdleTimeout,
Handler: handler,
}

// HTTP/2 在两种模式下都启用:TLS 经 ALPN 协商,明文经 h2c。
//
// 这取代了此前 `else` 分支里的 h2c.NewHandler —— 它已被 x/net 标记
// Deprecated("Set the http.Server Protocols field to use unencrypted
// HTTP/2 instead"),且为支持 HTTP/1.1 Upgrade 模式会把 h2c 连接的**首个
// 请求整体读入内存**(其文档要求用 MaxBytesHandler 包裹,此前并没有)。
// 标准库实现只 Peek 24 字节比对 PRI 前导,仅支持 prior-knowledge 模式,
// 没有这个内存放大面。
//
// 行为差异:依赖 `Upgrade: h2c` 头升级的客户端将静默退回 HTTP/1.1(不报错)。
// Envoy / gRPC 客户端用的都是 prior-knowledge,不受影响。
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
protocols.SetHTTP2(true)
protocols.SetUnencryptedHTTP2(true)
srv.Protocols = protocols

if conf.TLS.Enabled {
cert, err := loadTLSCertificate(conf.TLS.CertBase64, conf.TLS.KeyBase64)
if err != nil {
Expand All @@ -95,10 +127,6 @@ func NewServer(conf *ServerConfig, handler http.Handler) (*http.Server, error) {
srv.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
}
} else {
srv.Handler = h2c.NewHandler(srv.Handler, &http2.Server{
IdleTimeout: srv.IdleTimeout,
})
}
return srv, nil
}
Expand Down
117 changes: 117 additions & 0 deletions httpx/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package httpx_test

import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"strings"
"testing"

"github.com/stretchr/testify/require"
"golang.org/x/net/http2"

"github.com/qor5/x/v3/httpx"
)

// serve 起一个监听在随机端口上的 server,返回其地址。
func serve(t *testing.T, conf *httpx.ServerConfig, handler http.Handler) string {
t.Helper()

srv, err := httpx.NewServer(conf, handler)
require.NoError(t, err)

ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)

go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close() })

return ln.Addr().String()
}

// h2cClient 用 prior-knowledge 模式(直接发 HTTP/2 前导)连明文端口,
// 这正是 Envoy / gRPC 客户端在 appProtocol=h2c 下的行为。
func h2cClient() *http.Client {
return &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, addr)
},
},
}
}

// 迁移到 http.Server.Protocols 之后,明文 HTTP/2 必须仍然可用——
// 这是替换掉已废弃的 h2c.NewHandler 时最需要守住的行为。
func TestNewServer_H2C(t *testing.T) {
addr := serve(t, &httpx.ServerConfig{Address: ":0"},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, r.Proto)
}))

resp, err := h2cClient().Get("http://" + addr)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "HTTP/2.0", string(body))
}

// 同一个 server 必须同时还能服务 HTTP/1.1。
func TestNewServer_HTTP1StillWorks(t *testing.T) {
addr := serve(t, &httpx.ServerConfig{Address: ":0"},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, r.Proto)
}))

resp, err := http.Get("http://" + addr)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "HTTP/1.1", string(body))
}

func TestNewServer_MaxRequestBodySize(t *testing.T) {
const limit = 16

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := io.ReadAll(r.Body); err != nil {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
})

t.Run("under the limit passes", func(t *testing.T) {
addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxRequestBodySize: limit}, handler)

resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader("short"))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
})

t.Run("over the limit is rejected", func(t *testing.T) {
addr := serve(t, &httpx.ServerConfig{Address: ":0", MaxRequestBodySize: limit}, handler)

resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader(strings.Repeat("x", limit*4)))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
})

t.Run("zero means unlimited", func(t *testing.T) {
addr := serve(t, &httpx.ServerConfig{Address: ":0"}, handler)

resp, err := http.Post("http://"+addr, "text/plain", strings.NewReader(strings.Repeat("x", limit*4)))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}
Loading