feat(mcp): rate limit middleware (per-token + global token bucket, 1-min window)

This commit is contained in:
2026-05-08 08:08:13 +08:00
parent 416a7d5023
commit e1e6508c4c
+117
View File
@@ -0,0 +1,117 @@
package mcp
import (
"net/http"
"sync"
"time"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// RateLimiter 是一对 token bucket:per-token + 全局。
//
// 实现选择:固定时间窗口 + 计数器(每分钟重置)。简单、无第三方依赖、
// 内存占用极低。10–50 人规模 + 默认 100 req/min/token 不需要更精细的
// sliding window / leaky bucket。
//
// 并发:sync.Mutex + map;rate-limit 调用是热路径(每请求一次),但 mutex
// 持有时间极短(map lookup + 1 个 if)。
type RateLimiter struct {
cfg RateLimitConfig
mu sync.Mutex
bucket map[string]*tokenBucket
global *tokenBucket
now func() time.Time // 测试注入
}
// RateLimitConfig 是 limiter 的配置。
type RateLimitConfig struct {
PerTokenPerMinute int
GlobalPerMinute int
}
type tokenBucket struct {
count int
windowAt time.Time
}
// NewRateLimiter 用配置构造 limiter;now=nil 时用 time.Now。
func NewRateLimiter(cfg RateLimitConfig, now func() time.Time) *RateLimiter {
if now == nil {
now = time.Now
}
return &RateLimiter{
cfg: cfg,
bucket: map[string]*tokenBucket{},
global: &tokenBucket{},
now: now,
}
}
// Allow 返回 (allowed, reason)。reason 仅在 !allowed 时有值,
// 取值 ∈ {"per_token", "global"} 用于错误消息区分。
func (l *RateLimiter) Allow(tokenID string) (bool, string) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
// per-token
b := l.bucket[tokenID]
if b == nil {
b = &tokenBucket{}
l.bucket[tokenID] = b
}
if !sameMinute(b.windowAt, now) {
b.windowAt = now
b.count = 0
}
if b.count >= l.cfg.PerTokenPerMinute {
return false, "per_token"
}
// global
if !sameMinute(l.global.windowAt, now) {
l.global.windowAt = now
l.global.count = 0
}
if l.global.count >= l.cfg.GlobalPerMinute {
return false, "global"
}
// 两层桶都通过:增量
b.count++
l.global.count++
return true, ""
}
// sameMinute 判定两时间是否在同一分钟窗口内。
func sameMinute(a, b time.Time) bool {
return a.Truncate(time.Minute).Equal(b.Truncate(time.Minute))
}
// NewRateLimitMiddleware 必须挂载在 NewAuthMiddleware 之后。
// 从 ctx 取 AuthSession.TokenID 作为 bucket key;找不到 = middleware 顺序错,
// 返 500(避免 fail-open 让无鉴权请求通过 rate limit)。
func NewRateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := FromContext(r.Context())
if !ok {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.New(errs.CodeInternal,
"rate limit middleware: missing auth session (middleware order bug)"))
return
}
allowed, reason := limiter.Allow(sess.TokenID)
if !allowed {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
errs.New(errs.CodeMcpRateLimited,
"rate limit exceeded ("+reason+")"))
return
}
next.ServeHTTP(w, r)
})
}
}