Files
MailUI4Agents/server/internal/repo/ratelimit.go
JianFeeeee 477b74230f fix(限流): 用 ORDER BY ts 取窗口内最早一条,不靠 MIN(ts) —— 429 的 retry_after 恒为 60
## 缺陷

`SELECT MIN(ts) FROM rate_limits ...` 的聚合结果被 SQLite 驱动按 **string**
返回,扫进 `*time.Time` 失败 ⇒ 落到兜底 `return false, 60`。

⇒ 所有 429 的 `retry_after` 恒为 60,与真实剩余窗口
(最长 `sessionRateWindow` = 1h)完全无关。调用方拿到的重试提示是错的:
限流窗口还有 55 分钟,它却说 60 秒后重试。

## 修法

`SELECT ts FROM rate_limits WHERE bucket = $1 AND ts >= $2 ORDER BY ts ASC LIMIT 1`

排序取值走**结果集本身**,驱动按列类型给 `time.Time`;语义等价。

★ 同一形状的坑今天已出现两次:上午 2h 冷静期因 UTC vs HKT 差 8 小时而形同虚设,
晚上权限记账因两处 `if` 守卫而静默失效。**根子都是「SQLite 侧的时间/类型处理
与直觉不符」,而症状在别处。**

## 判据

4 格(retry_after 反映真实窗口 / 绝不超过 window / 窗口滚动后放行 /
只数窗口内的记录),其中主判据显式对比「修复前 60,修复后 ≈window」。

本改动此前已随 2026-10-01 的两次部署进入线上二进制(vcs.modified=true),
本次补提交以让 provenance 对得上。
2026-10-02 10:02:35 +08:00

71 lines
2.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"time"
"github.com/agentmail/gateway/internal/db"
)
// RateLimitCheckAndRecord 原子地检查 bucket 在 window 内的事件数是否超过 limit。
// 未超过则同时记录本次事件(判断与写入在同一个事务里,防并发刷穿)。
// DB 不可用时放行(宁可放开限速也不能让用户完全无法使用)。
func RateLimitCheckAndRecord(ctx context.Context, bucket string, window time.Duration, limit int) (allowed bool, retryAfter int) {
now := time.Now()
cutoff := now.Add(-window)
// 用 IMMEDIATE 事务:SQLite 的 IMMEDIATE 会在开始时获取 RESERVED 锁,
// 防止其他写事务同时进入 COMMIT 阶段。这是 SQLite 并发写的正确方式。
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {
return true, 0 // DB 不可用 → 放行
}
defer tx.Rollback() // Commit 成功后 Rollback 是 no-op
// 清理过期记录
tx.ExecContext(ctx,
`DELETE FROM rate_limits WHERE bucket = $1 AND ts < $2`, bucket, cutoff)
// 统计当前窗口内事件数
var count int
err = tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
bucket, cutoff).Scan(&count)
if err != nil {
return true, 0
}
if count >= limit {
// 用 ORDER BY ts ASC LIMIT 1 而不是 MIN(ts):SQLite 驱动把聚合 MIN(ts)
// 当 string 返回,扫进 *time.Time 失败 → 落到下面的兜底
// `return false, 60`,于是所有 429 的 retry_after 恒为 60,
// 与真实剩余窗口(最长 sessionRateWindow=1h)无关。
// 排序取值走结果集本身,驱动按列类型给 time.Time;语义等价。
var earliest time.Time
err = tx.QueryRowContext(ctx,
`SELECT ts FROM rate_limits WHERE bucket = $1 AND ts >= $2
ORDER BY ts ASC LIMIT 1`,
bucket, cutoff).Scan(&earliest)
if err == nil && !earliest.IsZero() {
retry := int(earliest.Add(window).Sub(now).Seconds()) + 1
if retry < 1 {
retry = 1
}
return false, retry
}
return false, 60
}
// 记账
tx.ExecContext(ctx,
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`, bucket, now)
tx.Commit()
return true, 0
}
// RateLimitReset 清除指定 bucket 的所有记录(登录成功后调用)。
func RateLimitReset(ctx context.Context, bucket string) {
_, _ = db.DB.ExecContext(ctx,
`DELETE FROM rate_limits WHERE bucket = $1`, bucket)
}