Files
MailUI4Agents/server/internal/handler/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

84 lines
2.6 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 handler
import (
"context"
"time"
"github.com/agentmail/gateway/internal/db"
)
// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间。
// 用 DB 而非进程内内存计数器,多实例部署时各实例共享同一份计数。
const (
maxLoginFailures = 5
lockoutDuration = 5 * time.Minute
failureWindow = 15 * time.Minute
)
// LoginLimiter 通过 DB 实现的登录失败限速器。
type LoginLimiter struct{}
var limiter = &LoginLimiter{}
// Locked 返回该用户名是否处于锁定期,以及剩余秒数。
// 不记账,只读。
func (l *LoginLimiter) Locked(ctx context.Context, name string) (bool, int) {
bucket := "login:" + name
cutoff := time.Now().Add(-failureWindow)
var count int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
bucket, cutoff).Scan(&count)
if err != nil || count < maxLoginFailures {
return false, 0
}
// 找到最早那条记录 + lockoutDuration = 解锁时间
//
// 用 ORDER BY ts ASC LIMIT 1 而不是 MIN(ts):SQLite 驱动把聚合结果
// MIN(ts) 当 string 返回,扫进 *time.Time 直接报错(unsupported Scan,
// storing driver.Value type string into type *time.Time),err != nil
// 会让下面直接 return false, 0 —— 也就是「失败次数再多也永远不锁」。
// 排序取值走的是结果集本身,驱动按列类型给 time.Time;语义等价。
// 同一处方言差异在 db/migrate.go:125 用 CAST(... AS TEXT) 处理过。
var earliest time.Time
err = db.DB.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() {
return false, 0
}
unlockAt := earliest.Add(lockoutDuration)
now := time.Now()
if now.Before(unlockAt) {
remain := int(unlockAt.Sub(now).Seconds()) + 1
if remain < 1 {
remain = 1
}
return true, remain
}
// 锁定期已过,清理旧记录
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1 AND ts < $2`,
bucket, unlockAt)
return false, 0
}
// Fail 记录一次登录失败。达到阈值时不额外标记 ——
// Locked() 用 COUNT >= maxLoginFailures 自然判定锁定。
func (l *LoginLimiter) Fail(ctx context.Context, name string) {
bucket := "login:" + name
db.DB.ExecContext(ctx,
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`,
bucket, time.Now())
}
// Reset 登录成功后清除失败计数。
func (l *LoginLimiter) Reset(ctx context.Context, name string) {
bucket := "login:" + name
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1`, bucket)
}