da9c8334d8
- SSE Keepalive Ping (15s心跳防止代理断连) - Timing HTTP 头 (X-Timing-Queue/Inference/Total-Ms) - Adapter Request-ID 传播到后端 - Session 清理日志回调 - Server 安全加固 (ReadHeaderTimeout/MaxHeaderBytes 防 slowloris) - Usage Tracker 数据保留清理 (retentionDays + 定期清理) - Config Reload 后 Adapter Registry 更新 (RegisterIfAbsent + RWMutex) - Rate Limiter 空闲 Bucket 清理 (30分钟过期) - Shutdown Drain 超时可配置 (ShutdownDrainSeconds) - Config 模型字段校验增强 (provider/endpoint/actual_model) - Auth 过期 Key 自动清理 (5分钟扫描) - Admin API Rate Limiting - Adapter Health Check 独立超时 (每个 adapter 3s) - TCP 连接阶段超时 (DialContext 5s + KeepAlive 30s) - 幂等键缓存、审计日志、Gzip 中间件、CORS Expose Headers - Backpressure 响应头、熔断器 Prometheus 指标 - 连接池优化、Trace-ID 全链路传播
258 lines
8.0 KiB
Go
258 lines
8.0 KiB
Go
package middleware
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/edgeai/gateway/internal/observability"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
RequestIDKey contextKey = "request_id"
|
|
)
|
|
|
|
// CORS middleware 添加跨域响应头,支持浏览器客户端直接调用 API。
|
|
// allowedOrigins 为允许的源列表,"*" 表示允许所有源。
|
|
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
|
allowed := map[string]bool{}
|
|
for _, o := range allowedOrigins {
|
|
allowed[o] = true
|
|
}
|
|
allowAll := allowed["*"]
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
if allowAll || allowed[origin] {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID")
|
|
w.Header().Set("Access-Control-Expose-Headers", "X-Request-ID, X-Trace-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After, X-Backpressure-Level, X-Timing-Queue-Ms, X-Timing-Inference-Ms, X-Timing-Total-Ms")
|
|
w.Header().Set("Access-Control-Max-Age", "3600")
|
|
}
|
|
}
|
|
// 处理预检请求
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// RequestID middleware generates a unique request ID and sets it in context and response header.
|
|
// 同时设置 X-Trace-Id 响应头,便于客户端和分布式追踪系统关联请求。
|
|
func RequestID(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestID := r.Header.Get("X-Request-ID")
|
|
if requestID == "" {
|
|
requestID = uuid.New().String()
|
|
}
|
|
w.Header().Set("X-Request-ID", requestID)
|
|
w.Header().Set("X-Trace-Id", requestID)
|
|
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// BodyLimit middleware rejects requests with bodies exceeding the given size.
|
|
func BodyLimit(maxMB int) func(http.Handler) http.Handler {
|
|
maxBytes := int64(maxMB) * 1024 * 1024
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.ContentLength > maxBytes {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintf(w, `{"error":{"code":"INVALID_REQUEST","message":"request body exceeds %dMB limit","request_id":"%s"}}`, maxMB, r.Header.Get("X-Request-ID"))
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Recovery middleware catches panics and returns 500.
|
|
func Recovery(logger *observability.Logger) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
logger.Error("panic recovered",
|
|
observability.F().Event("panic").
|
|
RequestID(r.Header.Get("X-Request-ID")).
|
|
Reason(fmt.Sprintf("%v\n%s", rec, debug.Stack())))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprintf(w, `{"error":{"code":"INTERNAL_ERROR","message":"internal server error","request_id":"%s"}}`, r.Header.Get("X-Request-ID"))
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Logging middleware logs request method, path, status, and duration.
|
|
func Logging(logger *observability.Logger) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rw := &responseWriter{ResponseWriter: w, status: 200}
|
|
next.ServeHTTP(rw, r)
|
|
fields := observability.F().
|
|
Event("http_request").
|
|
RequestID(r.Header.Get("X-Request-ID")).
|
|
Set("method", r.Method).
|
|
Set("path", r.URL.Path).
|
|
Set("status", rw.status).
|
|
Set("duration_ms", time.Since(start).Milliseconds()).
|
|
Set("response_bytes", rw.bytesWritten)
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
fields.Set("origin", origin)
|
|
}
|
|
logger.Info("http request", fields)
|
|
})
|
|
}
|
|
}
|
|
|
|
type responseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytesWritten int
|
|
}
|
|
|
|
func (rw *responseWriter) WriteHeader(code int) {
|
|
rw.status = code
|
|
rw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (rw *responseWriter) Write(b []byte) (int, error) {
|
|
n, err := rw.ResponseWriter.Write(b)
|
|
rw.bytesWritten += n
|
|
return n, err
|
|
}
|
|
|
|
func (rw *responseWriter) Flush() {
|
|
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// GetRequestID extracts the request ID from context.
|
|
func GetRequestID(ctx context.Context) string {
|
|
if v, ok := ctx.Value(RequestIDKey).(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RateLimitFn 是限流检查函数的类型。
|
|
// 返回 allowed, limit, remaining。
|
|
type RateLimitFn func(appID string) (allowed bool, limit int, remaining int)
|
|
|
|
// RateLimit middleware 对所有认证请求执行 per-app 限流,并设置 X-RateLimit-* 响应头。
|
|
// skipPaths 中的路径不限流(如 /health、/ready)。
|
|
func RateLimit(checkFn RateLimitFn, getIdentity func(*http.Request) string, skipPaths map[string]bool) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if skipPaths[r.URL.Path] {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
appID := getIdentity(r)
|
|
if appID == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
allowed, limit, remaining := checkFn(appID)
|
|
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limit))
|
|
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
|
|
if !allowed {
|
|
w.Header().Set("Retry-After", "1")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
fmt.Fprintf(w, `{"error":{"code":"RATE_LIMITED","message":"rate limit exceeded for this application","request_id":"%s"}}`, r.Header.Get("X-Request-ID"))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// gzipResponseWriter 包装 ResponseWriter,对响应体进行 gzip 压缩。
|
|
// 仅当客户端发送 Accept-Encoding: gzip 且响应 Content-Type 为 JSON 时启用。
|
|
type gzipResponseWriter struct {
|
|
http.ResponseWriter
|
|
gz *gzip.Writer
|
|
contentType string
|
|
gzStarted bool
|
|
}
|
|
|
|
func (g *gzipResponseWriter) WriteHeader(code int) {
|
|
ct := g.ResponseWriter.Header().Get("Content-Type")
|
|
g.contentType = ct
|
|
// SSE 流式响应不压缩
|
|
if strings.Contains(ct, "text/event-stream") {
|
|
g.ResponseWriter.WriteHeader(code)
|
|
return
|
|
}
|
|
// 对 JSON 等可压缩内容启用 gzip
|
|
if shouldCompress(ct) {
|
|
g.ResponseWriter.Header().Set("Content-Encoding", "gzip")
|
|
g.ResponseWriter.Header().Del("Content-Length")
|
|
g.gzStarted = true
|
|
}
|
|
g.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
|
|
if g.gzStarted {
|
|
return g.gz.Write(b)
|
|
}
|
|
return g.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func (g *gzipResponseWriter) Flush() {
|
|
if g.gzStarted {
|
|
g.gz.Flush()
|
|
}
|
|
if f, ok := g.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// shouldCompress 判断 Content-Type 是否值得压缩。
|
|
func shouldCompress(ct string) bool {
|
|
return strings.Contains(ct, "json") ||
|
|
strings.Contains(ct, "text") ||
|
|
strings.Contains(ct, "javascript") ||
|
|
strings.Contains(ct, "xml")
|
|
}
|
|
|
|
// Gzip 中间件对响应体进行 gzip 压缩,减少网络传输量。
|
|
// 仅当客户端支持 gzip 且响应内容类型可压缩时启用。
|
|
func Gzip(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
gz := gzip.NewWriter(w)
|
|
defer gz.Close()
|
|
|
|
gw := &gzipResponseWriter{ResponseWriter: w, gz: gz}
|
|
next.ServeHTTP(gw, r)
|
|
})
|
|
}
|