You've already forked agentic-coding-workflow
feat(mcp): rate limit middleware (per-token + global token bucket, 1-min window)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user