diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bd90fd7ffb00..57b13739ad8e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -739,16 +739,17 @@ type PricingConfig struct { type ServerConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` - Mode string `mapstructure:"mode"` // debug/release - EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header - FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接 - ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒) - MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 请求头最大字节数(HTTP/2 映射为 header-list 上限) - IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒) - TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP) - TrustedProxiesConfigured bool `mapstructure:"-" json:"-" yaml:"-"` // 是否显式配置了可信代理列表 - MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制 - H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置 + Mode string `mapstructure:"mode"` // debug/release + EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header + FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接 + ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒) + MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 请求头最大字节数(HTTP/2 映射为 header-list 上限) + IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒) + TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP) + TrustedProxiesConfigured bool `mapstructure:"-" json:"-" yaml:"-"` // 是否显式配置了可信代理列表 + MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制 + RequestBodyIdleTimeout int `mapstructure:"request_body_idle_timeout"` // 请求体无数据到达的超时(秒),不限总上传时长;0 关闭 + H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置 } // H2CConfig HTTP/2 Cleartext 配置 @@ -2105,6 +2106,7 @@ func setDefaults() { viper.SetDefault("server.max_header_bytes", 64*1024) viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时 viper.SetDefault("server.max_request_body_size", int64(256*1024*1024)) + viper.SetDefault("server.request_body_idle_timeout", 60) // 60秒内无请求体数据到达则失败 // H2C 默认配置 viper.SetDefault("server.h2c.enabled", false) viper.SetDefault("server.h2c.max_concurrent_streams", uint32(50)) // 50 个并发流 @@ -2801,6 +2803,9 @@ func (c *Config) Validate() error { if c.Server.MaxRequestBodySize < 0 { return fmt.Errorf("server.max_request_body_size must be non-negative") } + if c.Server.RequestBodyIdleTimeout < 0 || c.Server.RequestBodyIdleTimeout > 3600 { + return fmt.Errorf("server.request_body_idle_timeout must be between 0 and 3600 seconds") + } if c.Server.H2C.Enabled { if c.Server.H2C.MaxConcurrentStreams == 0 { return fmt.Errorf("server.h2c.max_concurrent_streams must be positive") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index adb48e5bb2ae..dd551ed6c6b8 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -139,6 +139,7 @@ func TestLoadHTTPIngressSafetyDefaults(t *testing.T) { cfg, err := Load() require.NoError(t, err) require.Equal(t, 10, cfg.Server.ReadHeaderTimeout) + require.Equal(t, 60, cfg.Server.RequestBodyIdleTimeout) require.Equal(t, 64*1024, cfg.Server.MaxHeaderBytes) require.Empty(t, cfg.Server.TrustedProxies) require.False(t, cfg.Server.TrustedProxiesConfigured) @@ -1644,6 +1645,16 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Server.MaxHeaderBytes = 4096 }, wantErr: "server.max_header_bytes", }, + { + name: "server request body idle timeout negative", + mutate: func(c *Config) { c.Server.RequestBodyIdleTimeout = -1 }, + wantErr: "server.request_body_idle_timeout", + }, + { + name: "server request body idle timeout too large", + mutate: func(c *Config) { c.Server.RequestBodyIdleTimeout = 3601 }, + wantErr: "server.request_body_idle_timeout", + }, { name: "server max request body size", mutate: func(c *Config) { c.Server.MaxRequestBodySize = -1 }, diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index be7cc0a1b0cb..01a91ccc7dc7 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -147,11 +147,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 读取请求体 body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } @@ -2158,11 +2155,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { // 读取请求体 body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index ab9f931390ab..fad804036d77 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -47,11 +47,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { // Read request body body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.chatCompletionsErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.chatCompletionsErrorResponse(c, status, "invalid_request_error", message) return } diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 1800e5dda592..027a48ad8797 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -47,11 +47,8 @@ func (h *GatewayHandler) Responses(c *gin.Context) { // Read request body body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.responsesErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.responsesErrorResponse(c, status, "invalid_request_error", message) return } diff --git a/backend/internal/handler/gateway_systemone.go b/backend/internal/handler/gateway_systemone.go index f9cc753c79dc..cd7ea825e20a 100644 --- a/backend/internal/handler/gateway_systemone.go +++ b/backend/internal/handler/gateway_systemone.go @@ -56,11 +56,8 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } model, err := typesafe.ValidateSystemOneRequest(body) diff --git a/backend/internal/handler/gemini_cached_contents_handler.go b/backend/internal/handler/gemini_cached_contents_handler.go index 2071c1612936..fc6ead5aa142 100644 --- a/backend/internal/handler/gemini_cached_contents_handler.go +++ b/backend/internal/handler/gemini_cached_contents_handler.go @@ -65,11 +65,8 @@ func (h *GatewayHandler) geminiCachedContentsPreamble(c *gin.Context, component func readGeminiCachedContentBody(c *gin.Context) ([]byte, bool) { body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - googleError(c, http.StatusRequestEntityTooLarge, buildBodyTooLargeMessage(maxErr.Limit)) - return nil, false - } - googleError(c, http.StatusBadRequest, "Failed to read request body") + status, message := requestBodyReadFailure(requestLogger(c, "handler.gemini_cached_contents"), c.Request, err) + googleErrorWithType(c, status, "invalid_request_error", "", message) return nil, false } if len(body) == 0 { diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 3c8c34bb5316..1063a45c7b52 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -374,11 +374,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - googleError(c, http.StatusRequestEntityTooLarge, buildBodyTooLargeMessage(maxErr.Limit)) - return - } - googleError(c, http.StatusBadRequest, "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + googleErrorWithType(c, status, "invalid_request_error", "", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index 03dabf7520cf..aa7974568ce2 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -90,11 +90,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. if endpoint.RequiresRequestBody() { body, err = pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/image_task_handler.go b/backend/internal/handler/image_task_handler.go index f9b28668aefd..aef7b661b38c 100644 --- a/backend/internal/handler/image_task_handler.go +++ b/backend/internal/handler/image_task_handler.go @@ -77,11 +77,8 @@ func (h *AsyncImageHandler) Submit(c *gin.Context) { body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - imageTaskJSONError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(requestLogger(c, "handler.image_task.create"), c.Request, err) + imageTaskJSONError(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index 3c25b66a0837..3d8581299800 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -52,11 +52,8 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 0a371f12ebd9..9a0d7a4438ce 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -51,12 +51,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - logRequestBodyReadFailure(reqLog, c.Request, err) - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 6c900c482e8c..6d5b618df093 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -48,11 +48,8 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index 2df4f4f2d092..edd6e8929933 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -42,11 +42,8 @@ func (h *OpenAIGatewayHandler) ResponsesInputTokens(c *gin.Context) { body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 || !gjson.ValidBytes(body) { @@ -135,11 +132,8 @@ func (h *OpenAIGatewayHandler) ResponsesInputTokens(c *gin.Context) { func (h *OpenAIGatewayHandler) GrokCountTokens(c *gin.Context) { body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(requestLogger(c, "handler.openai_gateway.grok_count_tokens"), c.Request, err) + h.anthropicErrorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { @@ -205,11 +199,8 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.anthropicErrorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 3518463f4fe0..d01dfe58e004 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -422,12 +422,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // Read request body body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - logRequestBodyReadFailure(reqLog, c.Request, err) - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } @@ -1188,11 +1184,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.anthropicErrorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.anthropicErrorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index beda748cff24..8e50f3b8732e 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -50,11 +50,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) - return - } - h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + status, message := requestBodyReadFailure(reqLog, c.Request, err) + h.errorResponse(c, status, "invalid_request_error", message) return } if len(body) == 0 { diff --git a/backend/internal/handler/request_body_limit.go b/backend/internal/handler/request_body_limit.go index de24551ba947..82d43d01ce14 100644 --- a/backend/internal/handler/request_body_limit.go +++ b/backend/internal/handler/request_body_limit.go @@ -1,7 +1,6 @@ package handler import ( - "errors" "fmt" "net/http" @@ -9,14 +8,6 @@ import ( pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" ) -func extractMaxBytesError(err error) (*http.MaxBytesError, bool) { - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - return maxErr, true - } - return nil, false -} - func formatBodyLimit(limit int64) string { const mb = 1024 * 1024 if limit >= mb { diff --git a/backend/internal/handler/request_body_limit_test.go b/backend/internal/handler/request_body_limit_test.go index bd9b81779720..12dd7646e8e6 100644 --- a/backend/internal/handler/request_body_limit_test.go +++ b/backend/internal/handler/request_body_limit_test.go @@ -21,15 +21,8 @@ func TestRequestBodyLimitTooLarge(t *testing.T) { router.POST("/test", func(c *gin.Context) { _, err := io.ReadAll(c.Request.Body) if err != nil { - if maxErr, ok := extractMaxBytesError(err); ok { - c.JSON(http.StatusRequestEntityTooLarge, gin.H{ - "error": buildBodyTooLargeMessage(maxErr.Limit), - }) - return - } - c.JSON(http.StatusBadRequest, gin.H{ - "error": "read_failed", - }) + status, message := requestBodyReadFailure(nil, c.Request, err) + c.JSON(status, gin.H{"error": message}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) diff --git a/backend/internal/handler/request_body_read_log.go b/backend/internal/handler/request_body_read_log.go index 5c6d73d93472..099c6e777961 100644 --- a/backend/internal/handler/request_body_read_log.go +++ b/backend/internal/handler/request_body_read_log.go @@ -1,79 +1,26 @@ package handler import ( - "context" - "errors" - "io" - "net" "net/http" - "strings" - "syscall" + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "go.uber.org/zap" ) -// logRequestBodyReadFailure records a bounded, payload-free reason for a body -// read failure. Clients continue to receive the stable generic error message; -// operators get enough information to distinguish compression failures from a -// disconnected/truncated upload without logging request content. -func logRequestBodyReadFailure(reqLog *zap.Logger, req *http.Request, err error) { - if reqLog == nil || err == nil { - return - } - - contentLength := int64(-1) - contentEncoding := "identity" - if req != nil { - contentLength = req.ContentLength - contentEncoding = requestContentEncodingCategory(req.Header.Get("Content-Encoding")) - } - - reqLog.Warn("read request body failed", - zap.String("error_kind", requestBodyReadErrorKind(err)), - zap.String("content_encoding", contentEncoding), - zap.Int64("content_length", contentLength), - ) -} - -func requestContentEncodingCategory(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "", "identity": - return "identity" - case "gzip", "x-gzip": - return "gzip" - case "zstd": - return "zstd" - case "deflate": - return "deflate" - default: - return "other" - } -} - -func requestBodyReadErrorKind(err error) string { - if err == nil { - return "none" - } - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - return "max_bytes" - } - lower := strings.ToLower(err.Error()) - if strings.Contains(lower, "decode content-encoding") { - if strings.Contains(lower, "unsupported content-encoding") { - return "unsupported_content_encoding" - } - return "decode_content_encoding" - } - if errors.Is(err, context.Canceled) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) { - return "client_disconnect" - } - if errors.Is(err, io.ErrUnexpectedEOF) { - return "truncated_body" - } - var netErr net.Error - if errors.As(err, &netErr) { - return "transport" - } - return "io_read" +// requestBodyReadFailure maps a request body read error to the response status +// and message. Timeouts (408) and client disconnects (499) are no longer +// reported as malformed requests; other read failures keep the stable generic +// 400 message. Failures other than the configured size limit are logged with +// bounded, payload-free diagnostics (kind, declared length, bytes received and +// read duration) so operators can tell a stalled or slow upload from a broken +// client without logging request content. +func requestBodyReadFailure(reqLog *zap.Logger, req *http.Request, err error) (int, string) { + failure := pkghttputil.DescribeRequestBodyReadFailure(req, err) + if failure.Kind == pkghttputil.RequestBodyReadErrorMaxBytes { + return failure.Status, buildBodyTooLargeMessage(failure.MaxBytesLimit) + } + if reqLog != nil { + reqLog.Warn("read request body failed", failure.LogFields()...) + } + return failure.Status, failure.Message } diff --git a/backend/internal/handler/request_body_read_log_test.go b/backend/internal/handler/request_body_read_log_test.go index 8a8d21f10038..1ffacaada42c 100644 --- a/backend/internal/handler/request_body_read_log_test.go +++ b/backend/internal/handler/request_body_read_log_test.go @@ -3,25 +3,36 @@ package handler import ( + "context" + "encoding/json" "errors" "fmt" "io" + "net" "net/http" + "net/http/httptest" + "os" "strings" "testing" + "testing/iotest" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) -func TestLogRequestBodyReadFailureClassifiesWithoutPayload(t *testing.T) { +func TestRequestBodyReadFailureLogsWithoutPayload(t *testing.T) { log, logs := newObservedLogger(t) req, err := http.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader("secret-payload-marker")) require.NoError(t, err) req.Header.Set("Content-Encoding", "gzip") req.ContentLength = 21 - logRequestBodyReadFailure(log, req, errors.New(`decode Content-Encoding "gzip": unexpected EOF secret-payload-marker`)) + status, message := requestBodyReadFailure(log, req, errors.New(`decode Content-Encoding "gzip": unexpected EOF secret-payload-marker`)) + require.Equal(t, http.StatusBadRequest, status) + require.Equal(t, "Failed to read request body", message) entries := logs.All() require.Len(t, entries, 1) require.Equal(t, "read request body failed", entries[0].Message) @@ -32,9 +43,76 @@ func TestLogRequestBodyReadFailureClassifiesWithoutPayload(t *testing.T) { require.NotContains(t, entries[0].Message+fmt.Sprint(fields), "secret-payload-marker") } -func TestRequestBodyReadErrorKind(t *testing.T) { - require.Equal(t, "unsupported_content_encoding", requestBodyReadErrorKind(errors.New(`decode Content-Encoding "br": unsupported Content-Encoding`))) - require.Equal(t, "truncated_body", requestBodyReadErrorKind(io.ErrUnexpectedEOF)) - require.Equal(t, "max_bytes", requestBodyReadErrorKind(&http.MaxBytesError{Limit: 10})) - require.Equal(t, "other", requestContentEncodingCategory("private-payload-marker")) +func TestRequestBodyReadFailureKeepsBodyLimitMessageWithoutLogging(t *testing.T) { + log, logs := newObservedLogger(t) + status, message := requestBodyReadFailure(log, nil, &http.MaxBytesError{Limit: 2 << 20}) + require.Equal(t, http.StatusRequestEntityTooLarge, status) + require.Equal(t, buildBodyTooLargeMessage(2<<20), message) + require.Empty(t, logs.All()) +} + +// The Anthropic /v1/messages handler reads the full body before it knows the +// model or account. A failed read must report why instead of a generic 400. +func TestGatewayMessagesClassifiesRequestBodyReadFailures(t *testing.T) { + gin.SetMode(gin.TestMode) + deadlineErr := &net.OpError{Op: "read", Net: "tcp", Err: os.ErrDeadlineExceeded} + + tests := []struct { + name string + readErr error + cancelCtx bool + wantStatus int + wantMessage string + }{ + {"idle timeout", deadlineErr, false, http.StatusRequestTimeout, "Timed out reading request body"}, + {"client disconnect", io.ErrUnexpectedEOF, true, 499, "Client closed the connection before the request body was received"}, + {"truncated body", io.ErrUnexpectedEOF, false, http.StatusBadRequest, "Failed to read request body"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := io.MultiReader(strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[`), iotest.ErrReader(tc.readErr)) + req := httptest.NewRequest(http.MethodPost, "/v1/messages", body) + req.ContentLength = 1 << 20 + if tc.cancelCtx { + ctx, cancel := context.WithCancel(req.Context()) + cancel() + req = req.WithContext(ctx) + } + c.Request = req + groupID := int64(1) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ID: 1, UserID: 1, GroupID: &groupID}) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1, Concurrency: 1}) + + (&GatewayHandler{}).Messages(c) + + require.Equal(t, tc.wantStatus, rec.Code) + var resp struct { + Type string `json:"type"` + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "error", resp.Type) + require.Equal(t, "invalid_request_error", resp.Error.Type) + require.Equal(t, tc.wantMessage, resp.Error.Message) + }) + } +} + +// Google-format errors carry no type field; body read failures must still be +// attributed to the client request rather than the platform. +func TestGeminiRequestBodyReadTimeoutClassifiedAsClient(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + c.Request.Body = io.NopCloser(iotest.ErrReader(&net.OpError{Op: "read", Net: "tcp", Err: os.ErrDeadlineExceeded})) + readGeminiCachedContentBody(c) + }) + require.Equal(t, http.StatusRequestTimeout, entry.StatusCode) + require.Equal(t, "invalid_request_error", entry.ErrorType) + require.Equal(t, "request", entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) + require.Equal(t, "P3", entry.Severity) } diff --git a/backend/internal/pkg/googleapi/status.go b/backend/internal/pkg/googleapi/status.go index 18a0eacb41d3..692843d0e7d6 100644 --- a/backend/internal/pkg/googleapi/status.go +++ b/backend/internal/pkg/googleapi/status.go @@ -14,6 +14,8 @@ func HTTPStatusToGoogleStatus(status int) string { return "PERMISSION_DENIED" case http.StatusNotFound: return "NOT_FOUND" + case http.StatusRequestTimeout: + return "DEADLINE_EXCEEDED" case http.StatusTooManyRequests: return "RESOURCE_EXHAUSTED" case 499: // client closed request diff --git a/backend/internal/pkg/googleapi/status_test.go b/backend/internal/pkg/googleapi/status_test.go index 123515be0fa7..030130f7510a 100644 --- a/backend/internal/pkg/googleapi/status_test.go +++ b/backend/internal/pkg/googleapi/status_test.go @@ -14,6 +14,7 @@ func TestHTTPStatusToGoogleStatus(t *testing.T) { {http.StatusUnauthorized, "UNAUTHENTICATED"}, {http.StatusForbidden, "PERMISSION_DENIED"}, {http.StatusNotFound, "NOT_FOUND"}, + {http.StatusRequestTimeout, "DEADLINE_EXCEEDED"}, {http.StatusTooManyRequests, "RESOURCE_EXHAUSTED"}, {499, "CANCELLED"}, {http.StatusBadGateway, "INTERNAL"}, diff --git a/backend/internal/pkg/httputil/body.go b/backend/internal/pkg/httputil/body.go index 3a6fab4b78d1..d51bafeff5c1 100644 --- a/backend/internal/pkg/httputil/body.go +++ b/backend/internal/pkg/httputil/body.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/klauspost/compress/zstd" ) @@ -63,6 +64,7 @@ func (p *PrereadBody) Bytes() []byte { // client used to compress the body (zstd, gzip, deflate). // 已由 PrereadBody 回填的请求体直接返回其完整切片(零拷贝),不检查内部 // reader 是否已被消费——见 PrereadBody 的文档说明。 +// 读取失败时返回 *RequestBodyReadError,携带已读字节数与耗时供诊断。 func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) { if req == nil || req.Body == nil { return nil, nil @@ -83,9 +85,10 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) { } } - raw, err := readRequestBodyChunks(req.Body, capHint, req.ContentLength) + startedAt := time.Now() + raw, bytesRead, err := readRequestBodyChunks(req.Body, capHint, req.ContentLength) if err != nil { - return nil, err + return nil, &RequestBodyReadError{Err: err, BytesRead: bytesRead, Elapsed: time.Since(startedAt)} } enc := strings.ToLower(strings.TrimSpace(req.Header.Get("Content-Encoding"))) @@ -108,7 +111,8 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) { // Read bounded chunks as bytes arrive, then assemble the exact-size result. // This avoids doubling large buffers or eagerly allocating an untrusted // Content-Length before the corresponding bytes have arrived. -func readRequestBodyChunks(reader io.Reader, initialCapacity int, contentLength int64) ([]byte, error) { +// The returned count is the number of bytes received, including on error. +func readRequestBodyChunks(reader io.Reader, initialCapacity int, contentLength int64) ([]byte, int64, error) { capacity := initialCapacity var chunks [][]byte total := 0 @@ -126,7 +130,7 @@ func readRequestBodyChunks(reader io.Reader, initialCapacity int, contentLength n += read } if err != nil && err != io.EOF { - return nil, err + return nil, int64(total + n), err } if n > 0 { chunks = append(chunks, chunk[:n]) @@ -134,17 +138,17 @@ func readRequestBodyChunks(reader io.Reader, initialCapacity int, contentLength } if err != nil { if len(chunks) == 0 { - return chunk[:0], nil + return chunk[:0], 0, nil } if len(chunks) == 1 { - return chunks[0], nil + return chunks[0], int64(total), nil } body := make([]byte, total) offset := 0 for _, part := range chunks { offset += copy(body[offset:], part) } - return body, nil + return body, int64(total), nil } if capacity < requestBodyReadMaxInitCap { capacity *= 2 diff --git a/backend/internal/pkg/httputil/body_read_error.go b/backend/internal/pkg/httputil/body_read_error.go new file mode 100644 index 000000000000..3cfb304b39ca --- /dev/null +++ b/backend/internal/pkg/httputil/body_read_error.go @@ -0,0 +1,187 @@ +package httputil + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "os" + "strings" + "syscall" + "time" + + "go.uber.org/zap" +) + +// Stable, payload-free labels for request body read failures. +const ( + RequestBodyReadErrorMaxBytes = "max_bytes" + RequestBodyReadErrorTimeout = "read_timeout" + RequestBodyReadErrorClientDisconnect = "client_disconnect" + RequestBodyReadErrorTruncated = "truncated_body" + RequestBodyReadErrorDecodeContentEncoding = "decode_content_encoding" + RequestBodyReadErrorUnsupportedContentEncoding = "unsupported_content_encoding" + RequestBodyReadErrorTransport = "transport" + RequestBodyReadErrorIO = "io_read" +) + +// StatusClientClosedRequest is the non-standard status recorded when the client +// goes away before the server responds. +const StatusClientClosedRequest = 499 + +// RequestBodyReadError annotates a failed request body read with progress +// counters. It never carries request content. +type RequestBodyReadError struct { + Err error + BytesRead int64 + Elapsed time.Duration +} + +func (e *RequestBodyReadError) Error() string { return e.Err.Error() } + +func (e *RequestBodyReadError) Unwrap() error { return e.Err } + +// RequestBodyReadFailure is the response and the payload-free diagnostics for a +// failed request body read. +type RequestBodyReadFailure struct { + Kind string + Status int + Message string + // MaxBytesLimit is set when Kind is RequestBodyReadErrorMaxBytes. + MaxBytesLimit int64 + + contentLength int64 + contentEncoding string + protocol string + chunked bool + bytesRead int64 + elapsed time.Duration +} + +// DescribeRequestBodyReadFailure classifies a request body read error. Timeouts +// and client disconnects get their own status codes so they are not reported +// as malformed requests; other failures keep the generic 400 response. +func DescribeRequestBodyReadFailure(req *http.Request, err error) RequestBodyReadFailure { + failure := RequestBodyReadFailure{ + Kind: classifyRequestBodyReadError(req, err), + contentLength: -1, + contentEncoding: "identity", + bytesRead: -1, + } + if req != nil { + failure.contentLength = req.ContentLength + failure.contentEncoding = RequestContentEncodingCategory(req.Header.Get("Content-Encoding")) + failure.protocol = req.Proto + failure.chunked = len(req.TransferEncoding) > 0 + } + var readErr *RequestBodyReadError + if errors.As(err, &readErr) { + failure.bytesRead = readErr.BytesRead + failure.elapsed = readErr.Elapsed + } + + switch failure.Kind { + case RequestBodyReadErrorMaxBytes: + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + failure.MaxBytesLimit = maxErr.Limit + } + failure.Status = http.StatusRequestEntityTooLarge + failure.Message = "Request body is too large" + case RequestBodyReadErrorTimeout: + failure.Status = http.StatusRequestTimeout + failure.Message = "Timed out reading request body" + case RequestBodyReadErrorClientDisconnect: + failure.Status = StatusClientClosedRequest + failure.Message = "Client closed the connection before the request body was received" + default: + failure.Status = http.StatusBadRequest + failure.Message = "Failed to read request body" + } + return failure +} + +// LogFields returns diagnostics that never include request content or error +// text, which may embed peer addresses or decoder input. +func (f RequestBodyReadFailure) LogFields() []zap.Field { + fields := []zap.Field{ + zap.String("error_kind", f.Kind), + zap.Int("status", f.Status), + zap.String("protocol", f.protocol), + zap.String("content_encoding", f.contentEncoding), + zap.Int64("content_length", f.contentLength), + zap.Bool("chunked", f.chunked), + } + if f.bytesRead >= 0 { + fields = append(fields, + zap.Int64("bytes_read", f.bytesRead), + zap.Int64("read_ms", f.elapsed.Milliseconds()), + ) + } + return fields +} + +func classifyRequestBodyReadError(req *http.Request, err error) string { + if err == nil { + return RequestBodyReadErrorIO + } + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + return RequestBodyReadErrorMaxBytes + } + lower := strings.ToLower(err.Error()) + if strings.Contains(lower, "decode content-encoding") { + if strings.Contains(lower, "unsupported content-encoding") { + return RequestBodyReadErrorUnsupportedContentEncoding + } + return RequestBodyReadErrorDecodeContentEncoding + } + // Check timeouts first: on HTTP/1 a read deadline also cancels the request + // context, which would otherwise look like a client disconnect. + var netErr net.Error + if errors.Is(err, os.ErrDeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) { + return RequestBodyReadErrorTimeout + } + if requestBodyClientGone(req, err, lower) { + return RequestBodyReadErrorClientDisconnect + } + // HTTP/2 reports an END_STREAM that arrives before Content-Length bytes + // with "request declared a Content-Length of N but only wrote M bytes". + if errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(lower, "request declared a content-length") { + return RequestBodyReadErrorTruncated + } + if errors.As(err, &netErr) { + return RequestBodyReadErrorTransport + } + return RequestBodyReadErrorIO +} + +func requestBodyClientGone(req *http.Request, err error, lowerErr string) bool { + if errors.Is(err, context.Canceled) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) { + return true + } + // HTTP/2 reports a closed client connection with this message. + if strings.Contains(lowerErr, "client disconnected") { + return true + } + // net/http cancels the request context when the connection fails, so an + // unexpected EOF with a canceled context is a disconnect, not a short body. + return req != nil && errors.Is(req.Context().Err(), context.Canceled) +} + +// RequestContentEncodingCategory maps Content-Encoding to a bounded label. +func RequestContentEncodingCategory(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "identity": + return "identity" + case "gzip", "x-gzip": + return "gzip" + case "zstd": + return "zstd" + case "deflate": + return "deflate" + default: + return "other" + } +} diff --git a/backend/internal/pkg/httputil/body_read_error_test.go b/backend/internal/pkg/httputil/body_read_error_test.go new file mode 100644 index 000000000000..a75261655391 --- /dev/null +++ b/backend/internal/pkg/httputil/body_read_error_test.go @@ -0,0 +1,124 @@ +package httputil + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" + "syscall" + "testing" + "testing/iotest" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestReadRequestBodyWithPreallocReportsProgressOnReadError(t *testing.T) { + reader := io.MultiReader(bytes.NewReader([]byte("partial")), iotest.ErrReader(io.ErrUnexpectedEOF)) + req := &http.Request{Body: io.NopCloser(reader), ContentLength: 100, Header: make(http.Header)} + + got, err := ReadRequestBodyWithPrealloc(req) + + var readErr *RequestBodyReadError + if got != nil || !errors.As(err, &readErr) || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("read error lost its cause or progress: body=%q err=%v", got, err) + } + if readErr.BytesRead != int64(len("partial")) || readErr.Elapsed < 0 { + t.Fatalf("unexpected progress: bytes=%d elapsed=%s", readErr.BytesRead, readErr.Elapsed) + } +} + +func TestDescribeRequestBodyReadFailure(t *testing.T) { + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + deadlineErr := &net.OpError{Op: "read", Net: "tcp", Err: os.ErrDeadlineExceeded} + resetErr := &net.OpError{Op: "read", Net: "tcp", Err: os.NewSyscallError("read", syscall.ECONNRESET)} + + tests := []struct { + name string + ctx context.Context + err error + wantKind string + wantStatus int + }{ + // net/http cancels the request context on an HTTP/1 read timeout too; + // the timeout must still win over the disconnect classification. + {"http1 read deadline", canceledCtx, &RequestBodyReadError{Err: deadlineErr}, RequestBodyReadErrorTimeout, http.StatusRequestTimeout}, + {"http2 read deadline", context.Background(), fmt.Errorf("%w", os.ErrDeadlineExceeded), RequestBodyReadErrorTimeout, http.StatusRequestTimeout}, + {"http1 disconnect mid body", canceledCtx, &RequestBodyReadError{Err: io.ErrUnexpectedEOF}, RequestBodyReadErrorClientDisconnect, StatusClientClosedRequest}, + {"connection reset", context.Background(), resetErr, RequestBodyReadErrorClientDisconnect, StatusClientClosedRequest}, + {"http2 client disconnected", context.Background(), errors.New("client disconnected"), RequestBodyReadErrorClientDisconnect, StatusClientClosedRequest}, + {"truncated body", context.Background(), &RequestBodyReadError{Err: io.ErrUnexpectedEOF}, RequestBodyReadErrorTruncated, http.StatusBadRequest}, + {"http2 short body", context.Background(), errors.New("request declared a Content-Length of 10 but only wrote 3 bytes"), RequestBodyReadErrorTruncated, http.StatusBadRequest}, + {"max bytes", context.Background(), &RequestBodyReadError{Err: &http.MaxBytesError{Limit: 64}}, RequestBodyReadErrorMaxBytes, http.StatusRequestEntityTooLarge}, + {"decode", context.Background(), errors.New(`decode Content-Encoding "gzip": unexpected EOF`), RequestBodyReadErrorDecodeContentEncoding, http.StatusBadRequest}, + {"unsupported encoding", context.Background(), errors.New(`decode Content-Encoding "br": unsupported Content-Encoding`), RequestBodyReadErrorUnsupportedContentEncoding, http.StatusBadRequest}, + {"other", context.Background(), errors.New("boom"), RequestBodyReadErrorIO, http.StatusBadRequest}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequestWithContext(tc.ctx, http.MethodPost, "/v1/messages", nil) + if err != nil { + t.Fatal(err) + } + got := DescribeRequestBodyReadFailure(req, tc.err) + if got.Kind != tc.wantKind || got.Status != tc.wantStatus || got.Message == "" { + t.Fatalf("got kind=%q status=%d message=%q, want kind=%q status=%d", got.Kind, got.Status, got.Message, tc.wantKind, tc.wantStatus) + } + }) + } + + if got := DescribeRequestBodyReadFailure(nil, &http.MaxBytesError{Limit: 64}); got.MaxBytesLimit != 64 { + t.Fatalf("max bytes limit not reported: %d", got.MaxBytesLimit) + } + if got := DescribeRequestBodyReadFailure(nil, errors.New("boom")); got.Message != "Failed to read request body" { + t.Fatalf("generic failures must keep the stable message, got %q", got.Message) + } +} + +func TestRequestBodyReadFailureLogFieldsArePayloadFree(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("secret-payload-marker")) + if err != nil { + t.Fatal(err) + } + req.ContentLength = 4096 + req.Header.Set("Content-Encoding", "zstd") + readErr := &RequestBodyReadError{ + Err: &net.OpError{Op: "read", Net: "tcp", Err: errors.New("10.0.0.1 secret-payload-marker")}, + BytesRead: 1024, + Elapsed: 1500 * time.Millisecond, + } + + core, logs := observer.New(zap.WarnLevel) + zap.New(core).Warn("read request body failed", DescribeRequestBodyReadFailure(req, readErr).LogFields()...) + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("expected one log entry, got %d", len(entries)) + } + fields := entries[0].ContextMap() + want := map[string]any{ + "error_kind": RequestBodyReadErrorTransport, + "status": int64(http.StatusBadRequest), + "content_encoding": "zstd", + "content_length": int64(4096), + "bytes_read": int64(1024), + "read_ms": int64(1500), + "chunked": false, + "protocol": "HTTP/1.1", + } + for key, value := range want { + if fields[key] != value { + t.Fatalf("field %s = %#v, want %#v (all: %v)", key, fields[key], value, fields) + } + } + if strings.Contains(fmt.Sprint(fields), "secret-payload-marker") || strings.Contains(fmt.Sprint(fields), "10.0.0.1") { + t.Fatalf("log fields leaked error text: %v", fields) + } +} diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index f999b343b213..b4c1fda3eff5 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -121,7 +121,8 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { // IdleTimeout: 空闲连接超时时间,释放不活跃的连接资源 IdleTimeout: time.Duration(cfg.Server.IdleTimeout) * time.Second, // 注意:不设置 WriteTimeout,因为流式响应可能持续十几分钟 - // 不设置 ReadTimeout,因为大请求体可能需要较长时间读取 + // 不设置 ReadTimeout,因为大请求体可能需要较长时间读取; + // 请求体改由 request_body_idle_timeout 按“无数据到达”的时长约束 } globalMaxSize := cfg.Server.MaxRequestBodySize @@ -132,6 +133,7 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { httpHandler = http.MaxBytesHandler(httpHandler, globalMaxSize) log.Printf("Global max request body size: %d bytes (%.2f MB)", globalMaxSize, float64(globalMaxSize)/(1<<20)) } + httpHandler = requestBodyIdleTimeoutHandler(httpHandler, time.Duration(cfg.Server.RequestBodyIdleTimeout)*time.Second) // 根据配置决定是否启用 H2C if cfg.Server.H2C.Enabled { diff --git a/backend/internal/server/middleware/group_model_allowlist.go b/backend/internal/server/middleware/group_model_allowlist.go index 3f5e61e585ec..10d05d996259 100644 --- a/backend/internal/server/middleware/group_model_allowlist.go +++ b/backend/internal/server/middleware/group_model_allowlist.go @@ -1,7 +1,6 @@ package middleware import ( - "errors" "fmt" "net/http" "strings" @@ -104,8 +103,8 @@ func isResponsesWebSocketRoute(c *gin.Context) bool { } // groupModelAllowlistModelsFromBody 读取请求体并提取客户端模型名,随后把请求体 -// 回填(PrereadBody),保证后续 handler 零拷贝重读。读取失败按现有合成中间件 -// 的方式返回 400/413(返回 false 表示已写出响应并 Abort)。 +// 回填(PrereadBody),保证后续 handler 零拷贝重读。读取失败按共享分类返回 +// 400/408/413/499(返回 false 表示已写出响应并 Abort)。 // // 下游同时存在 gjson(首个、大小写敏感)、encoding/json 绑定(末值、大小写 // 不敏感)与 multipart 表单(首/末字段)三类解析器,这里返回「任一解析器可能 @@ -113,15 +112,7 @@ func isResponsesWebSocketRoute(c *gin.Context) bool { func groupModelAllowlistModelsFromBody(c *gin.Context) ([]string, bool) { body, err := httputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - status := http.StatusBadRequest - message := "Failed to read request body" - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - status = http.StatusRequestEntityTooLarge - message = "Request body is too large" - } - c.JSON(status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": message}}) - c.Abort() + AbortWithRequestBodyReadFailure(c, "middleware.group_model_allowlist", err) return nil, false } requestmodel.ResetRequestBody(c.Request, body) diff --git a/backend/internal/server/middleware/request_body_read_failure.go b/backend/internal/server/middleware/request_body_read_failure.go new file mode 100644 index 000000000000..22b15a8f43f0 --- /dev/null +++ b/backend/internal/server/middleware/request_body_read_failure.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AbortWithRequestBodyReadFailure writes the shared response for a failed body +// read in middleware that inspects the body before the handler. It applies the +// same classification and payload-free diagnostics as the gateway handlers. +func AbortWithRequestBodyReadFailure(c *gin.Context, component string, err error) { + failure := httputil.DescribeRequestBodyReadFailure(c.Request, err) + if failure.Kind != httputil.RequestBodyReadErrorMaxBytes { + fields := append([]zap.Field{zap.String("component", component)}, failure.LogFields()...) + logger.FromContext(c.Request.Context()).Warn("read request body failed", fields...) + } + c.AbortWithStatusJSON(failure.Status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": failure.Message}}) +} diff --git a/backend/internal/server/request_body_idle_timeout.go b/backend/internal/server/request_body_idle_timeout.go new file mode 100644 index 000000000000..967910ec2e5d --- /dev/null +++ b/backend/internal/server/request_body_idle_timeout.go @@ -0,0 +1,98 @@ +package server + +import ( + "errors" + "io" + "net/http" + "os" + "time" +) + +// requestBodyIdleTimeoutHandler fails a request body read that receives no +// bytes for timeout. Unlike http.Server.ReadTimeout it never bounds the total +// upload time, so large bodies that keep making progress are not cut off, and +// time the handler spends between reads is not counted as client idleness: +// every Read re-arms the deadline before it waits. +// +// HTTP/2 stream deadlines fire even without a pending Read, so they are armed +// only during a Read. HTTP/1 deadlines only affect reads, so they stay armed +// while the body is unfinished: net/http drains up to 256 KiB of an unread +// body itself before writing the response, and that drain must not wait on a +// stalled client forever when a handler rejects a request without reading it. +// After a timeout the expired deadline is kept, so that drain fails at once +// and the connection is closed instead of reused. +func requestBodyIdleTimeoutHandler(next http.Handler, timeout time.Duration) http.Handler { + if timeout <= 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body == nil || r.Body == http.NoBody { + next.ServeHTTP(w, r) + return + } + body := &idleTimeoutBody{ + body: r.Body, + controller: http.NewResponseController(w), + timeout: timeout, + http1: r.ProtoMajor == 1, + } + if body.http1 { + body.arm() + } + // Shallow copy like http.MaxBytesHandler: net/http inspects the + // original *body when writing the response to decide whether the + // connection can be reused. + r2 := *r + r2.Body = body + next.ServeHTTP(w, &r2) + }) +} + +type idleTimeoutBody struct { + body io.ReadCloser + controller *http.ResponseController + timeout time.Duration + http1 bool + // unsupported is set when the ResponseWriter cannot set read deadlines + // (for example httptest.ResponseRecorder); reads are then unbounded. + unsupported bool + timeoutErr error +} + +// arm sets the deadline to now+timeout and reports whether it is supported. +func (b *idleTimeoutBody) arm() bool { + if b.unsupported { + return false + } + if err := b.controller.SetReadDeadline(time.Now().Add(b.timeout)); err != nil { + b.unsupported = true + return false + } + return true +} + +func (b *idleTimeoutBody) Read(p []byte) (int, error) { + if b.timeoutErr != nil { + return 0, b.timeoutErr + } + if len(p) == 0 || !b.arm() { + return b.body.Read(p) + } + n, err := b.body.Read(p) + switch { + case err == nil && b.http1: + b.arm() + case err != nil && errors.Is(err, os.ErrDeadlineExceeded): + b.timeoutErr = err + default: + // The server sets no ReadTimeout, so "no deadline" is the state to + // restore. At EOF net/http has already cleared the HTTP/1 deadline + // for its background read, which must not inherit a new one. + _ = b.controller.SetReadDeadline(time.Time{}) + } + return n, err +} + +func (b *idleTimeoutBody) Close() error { + return b.body.Close() +} diff --git a/backend/internal/server/request_body_idle_timeout_test.go b/backend/internal/server/request_body_idle_timeout_test.go new file mode 100644 index 000000000000..172a43068f96 --- /dev/null +++ b/backend/internal/server/request_body_idle_timeout_test.go @@ -0,0 +1,339 @@ +//go:build unit + +package server + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/handler" + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const testBodyIdleTimeout = 300 * time.Millisecond + +// bodyProbeHandler reads the body through the shared gateway reader. pauseAfter +// bytes into the body it stops reading for pause, and after a successful read it +// waits postReadWait before checking that the request context is still alive. +func bodyProbeHandler(pauseAfter int64, pause, postReadWait time.Duration) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if pause > 0 { + if _, err := io.CopyN(io.Discard, r.Body, pauseAfter); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + time.Sleep(pause) + } + body, err := pkghttputil.ReadRequestBodyWithPrealloc(r) + if err != nil { + failure := pkghttputil.DescribeRequestBodyReadFailure(r, err) + w.Header().Set("X-Body-Error", failure.Kind) + w.WriteHeader(failure.Status) + return + } + if postReadWait > 0 { + time.Sleep(postReadWait) + if r.Context().Err() != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + } + if pause > 0 { + body = append(make([]byte, pauseAfter), body...) + } + _, _ = io.WriteString(w, strconv.Itoa(len(body))) + }) +} + +func newIdleTimeoutTestServer(t *testing.T, h http.Handler, h2c bool) (*httptest.Server, *http.Client) { + t.Helper() + ts := httptest.NewUnstartedServer(requestBodyIdleTimeoutHandler(h, testBodyIdleTimeout)) + transport := &http.Transport{} + if h2c { + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + ts.Config.Protocols = protocols + clientProtocols := new(http.Protocols) + clientProtocols.SetUnencryptedHTTP2(true) + transport.Protocols = clientProtocols + } + ts.Start() + t.Cleanup(ts.Close) + t.Cleanup(transport.CloseIdleConnections) + return ts, &http.Client{Transport: transport, Timeout: 10 * time.Second} +} + +func forEachProtocol(t *testing.T, fn func(t *testing.T, h2c bool)) { + for _, h2c := range []bool{false, true} { + name := "http1" + if h2c { + name = "h2c" + } + t.Run(name, func(t *testing.T) { fn(t, h2c) }) + } +} + +// writeSlowly sends total bytes in pieces separated by gap, then closes. +func writeSlowly(w *io.PipeWriter, total, pieces int, gap time.Duration) { + piece := bytes.Repeat([]byte("x"), total/pieces) + for i := 0; i < pieces; i++ { + if i > 0 { + time.Sleep(gap) + } + if _, err := w.Write(piece); err != nil { + return + } + } + _ = w.Close() +} + +func TestRequestBodyIdleTimeoutAllowsSlowProgressingUpload(t *testing.T) { + forEachProtocol(t, func(t *testing.T, h2c bool) { + ts, client := newIdleTimeoutTestServer(t, bodyProbeHandler(0, 0, 0), h2c) + const total, pieces = 64 << 10, 8 + pr, pw := io.Pipe() + // 8 pieces 100ms apart: the upload takes ~2.3x the idle timeout in + // total, but no single gap reaches it. + go writeSlowly(pw, total, pieces, testBodyIdleTimeout/3) + req, err := http.NewRequest(http.MethodPost, ts.URL, pr) + require.NoError(t, err) + req.ContentLength = total + + start := time.Now() + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + got, _ := io.ReadAll(resp.Body) + + require.Equal(t, http.StatusOK, resp.StatusCode, resp.Header.Get("X-Body-Error")) + require.Equal(t, strconv.Itoa(total), string(got)) + require.Greater(t, time.Since(start), testBodyIdleTimeout) + if h2c { + require.Equal(t, 2, resp.ProtoMajor) + } + }) +} + +func TestRequestBodyIdleTimeoutFailsStalledUpload(t *testing.T) { + forEachProtocol(t, func(t *testing.T, h2c bool) { + ts, client := newIdleTimeoutTestServer(t, bodyProbeHandler(0, 0, 0), h2c) + pr, pw := io.Pipe() + defer func() { _ = pw.Close() }() + go func() { _, _ = pw.Write([]byte(`{"model":"x","messages":[`)) }() + req, err := http.NewRequest(http.MethodPost, ts.URL, pr) + require.NoError(t, err) + req.ContentLength = 1 << 20 + + start := time.Now() + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusRequestTimeout, resp.StatusCode) + require.Equal(t, pkghttputil.RequestBodyReadErrorTimeout, resp.Header.Get("X-Body-Error")) + elapsed := time.Since(start) + require.GreaterOrEqual(t, elapsed, testBodyIdleTimeout) + require.Less(t, elapsed, 5*time.Second) + }) +} + +// With less than 256 KiB of a declared body unread, net/http discards the +// remainder before writing the response. The expired deadline must stay in +// place so the 408 is sent at once instead of waiting on the stalled client. +func TestRequestBodyIdleTimeoutAnswersStalledSmallHTTP1Upload(t *testing.T) { + ts, _ := newIdleTimeoutTestServer(t, bodyProbeHandler(0, 0, 0), false) + conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + _, err = io.WriteString(conn, "POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 1000\r\n\r\nabc") + require.NoError(t, err) + + start := time.Now() + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + _ = resp.Body.Close() + + require.Equal(t, http.StatusRequestTimeout, resp.StatusCode) + require.Less(t, time.Since(start), 3*testBodyIdleTimeout) + require.True(t, resp.Close) +} + +// net/http drains up to 256 KiB of an unread HTTP/1 body before it writes the +// response. A handler that rejects a request without reading its stalled body, +// or after reading only part of it, must still answer within the idle timeout +// instead of never sending the response. HTTP/2 resets the stream instead. +func TestRequestBodyIdleTimeoutBoundsUnreadBodyDrain(t *testing.T) { + for _, readFirst := range []int{0, 3} { + reject := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if readFirst > 0 { + if _, err := io.ReadFull(r.Body, make([]byte, readFirst)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + } + w.WriteHeader(http.StatusUnauthorized) + }) + t.Run(fmt.Sprintf("read_%d", readFirst), func(t *testing.T) { + forEachProtocol(t, func(t *testing.T, h2c bool) { + ts, client := newIdleTimeoutTestServer(t, reject, h2c) + start := time.Now() + if h2c { + pr, pw := io.Pipe() + defer func() { _ = pw.Close() }() + go func() { _, _ = pw.Write([]byte("abc")) }() + req, err := http.NewRequest(http.MethodPost, ts.URL, pr) + require.NoError(t, err) + req.ContentLength = 1000 + resp, err := client.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.Less(t, time.Since(start), 3*testBodyIdleTimeout) + return + } + conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + _, err = io.WriteString(conn, "POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 1000\r\n\r\nabc") + require.NoError(t, err) + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.Less(t, time.Since(start), 3*testBodyIdleTimeout) + require.True(t, resp.Close) + }) + }) + } +} + +// A client that disconnects mid-upload is reported as 499, not as a malformed +// request, using the real net/http error and context behavior. +func TestRequestBodyReadClassifiesHTTP1ClientDisconnect(t *testing.T) { + kinds := make(chan string, 1) + ts, _ := newIdleTimeoutTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := pkghttputil.ReadRequestBodyWithPrealloc(r) + kinds <- pkghttputil.DescribeRequestBodyReadFailure(r, err).Kind + }), false) + conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), time.Second) + require.NoError(t, err) + _, err = io.WriteString(conn, "POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 1000\r\n\r\nabc") + require.NoError(t, err) + time.Sleep(50 * time.Millisecond) + require.NoError(t, conn.Close()) + + select { + case kind := <-kinds: + require.Equal(t, pkghttputil.RequestBodyReadErrorClientDisconnect, kind) + case <-time.After(5 * time.Second): + t.Fatal("handler did not observe the disconnect") + } +} + +// Time the handler spends between reads is not client idleness: the deadline +// is armed only during a Read. The second half arrives while the handler is +// paused but after one idle timeout, so a deadline left armed from the first +// read would fire (an HTTP/2 stream timer runs even without a pending Read). +// A completed body must also leave the request context alive. +func TestRequestBodyIdleTimeoutIgnoresHandlerPauses(t *testing.T) { + forEachProtocol(t, func(t *testing.T, h2c bool) { + const total = 32 << 10 + probe := bodyProbeHandler(total/2, 3*testBodyIdleTimeout, 2*testBodyIdleTimeout) + ts, client := newIdleTimeoutTestServer(t, probe, h2c) + pr, pw := io.Pipe() + go writeSlowly(pw, total, 2, 3*testBodyIdleTimeout/2) + req, err := http.NewRequest(http.MethodPost, ts.URL, pr) + require.NoError(t, err) + req.ContentLength = total + + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + got, _ := io.ReadAll(resp.Body) + + require.Equal(t, http.StatusOK, resp.StatusCode, resp.Header.Get("X-Body-Error")) + require.Equal(t, strconv.Itoa(total), string(got)) + }) +} + +func TestRequestBodyIdleTimeoutFallsBackWithoutDeadlineSupport(t *testing.T) { + h := requestBodyIdleTimeoutHandler(bodyProbeHandler(0, 0, 0), time.Nanosecond) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader("hello"))) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "5", rec.Body.String()) +} + +func TestRequestBodyIdleTimeoutDisabled(t *testing.T) { + next := http.NotFoundHandler() + require.Equal(t, fmt.Sprintf("%p", next), fmt.Sprintf("%p", requestBodyIdleTimeoutHandler(next, 0))) +} + +// End to end over a real HTTP/1 connection: a stalled Anthropic /v1/messages +// upload is answered with 408 after the configured idle timeout, and the +// connection is closed rather than reused. +func TestHTTPServerRequestBodyIdleTimeoutAnthropicMessages(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + groupID := int64(1) + r.POST("/v1/messages", func(c *gin.Context) { + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ID: 1, UserID: 1, GroupID: &groupID}) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1, Concurrency: 1}) + c.Next() + }, (&handler.GatewayHandler{}).Messages) + cfg := ingressTestConfig() + cfg.Server.MaxRequestBodySize = 0 + cfg.Gateway.MaxBodySize = 0 + cfg.Server.RequestBodyIdleTimeout = 1 + addr, stop := serveIngressTestServer(t, ProvideHTTPServer(cfg, r)) + defer stop() + + conn, err := net.DialTimeout("tcp", addr, time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + partial := `{"model":"claude-sonnet-4-5","max_tokens":1,"messages":[` + _, err = io.WriteString(conn, "POST /v1/messages HTTP/1.1\r\nHost: test\r\nContent-Type: application/json\r\nContent-Length: 1048576\r\n\r\n"+partial) + require.NoError(t, err) + + start := time.Now() + reader := bufio.NewReader(conn) + resp, err := http.ReadResponse(reader, nil) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + require.Equal(t, http.StatusRequestTimeout, resp.StatusCode) + elapsed := time.Since(start) + require.GreaterOrEqual(t, elapsed, 900*time.Millisecond) + require.Less(t, elapsed, 5*time.Second) + var payload struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(body, &payload)) + require.Equal(t, "invalid_request_error", payload.Error.Type) + require.Equal(t, "Timed out reading request body", payload.Error.Message) + require.True(t, resp.Close, "connection with an unread body must not be reused") + _, err = reader.ReadByte() + require.ErrorIs(t, err, io.EOF) +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 3f09ebf4053c..76a055cb2111 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -1,7 +1,6 @@ package routes import ( - "errors" "net/http" "strings" @@ -563,15 +562,7 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) if err != nil { - status := http.StatusBadRequest - message := "Failed to read request body" - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - status = http.StatusRequestEntityTooLarge - message = "Request body is too large" - } - c.JSON(status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": message}}) - c.Abort() + middleware.AbortWithRequestBodyReadFailure(c, "middleware.composite_route", err) return } diff --git a/deploy/EDGE_SECURITY.md b/deploy/EDGE_SECURITY.md index 5e9ab10fb824..418b974a1a33 100644 --- a/deploy/EDGE_SECURITY.md +++ b/deploy/EDGE_SECURITY.md @@ -11,6 +11,11 @@ terminate healthy long generations and streams. - `server.read_header_timeout: 10` bounds slow-header attacks. It does not limit request processing or response streaming. - `server.max_request_body_size: 268435456` is the absolute 256 MiB safety net. +- `server.request_body_idle_timeout: 60` fails a request body read with 408 + when no body bytes arrive for 60 seconds. It does not limit the total upload + time, so large bodies that keep arriving are unaffected; `0` disables it. It + also bounds the drain net/http performs on an unread HTTP/1 body before + sending an early error response. - `gateway.max_body_size: 268435456` remains available to multimodal, Gemini, image, video, and batch-image endpoints. - `gateway.text_max_body_size: 33554432` limits the known pure-text diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index a4a32027bd84..a400bb1be481 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -50,6 +50,11 @@ server: # Applies to all requests, especially important for h2c first request memory protection # 适用于所有请求,对 h2c 第一请求的内存保护尤为重要 max_request_body_size: 268435456 + # Fail a request body read when no body bytes arrive for this many seconds (408). + # Does not limit total upload time: large bodies that keep arriving are unaffected. 0 disables. + # 请求体读取空闲超时(秒):持续这么久没有收到任何请求体数据即失败(408)。 + # 不限制总上传时长,持续有数据到达的大请求体不受影响;0 表示关闭。 + request_body_idle_timeout: 60 # HTTP/2 Cleartext (h2c) configuration # HTTP/2 Cleartext (h2c) 配置 h2c: