在这之前 /calendar/* 全挂在 UserAuth 后面,Agent 密钥一律 401。 于是「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout —— 进程一重启 定时器就消失,那条提醒静默不见且无处留痕。放进 Gateway 后由数据库与 调度器保证:插件重启、Agent 换机器、甚至换平台都不影响。 更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条 「明天交周报」的提醒。这件事模型自己做不到 —— 它没法让另一个进程在未来 某刻醒来。 三处收紧: **1. 只看/只改自己建的。** 别人的日程里可能有它无权知道的会议与地址。不存在与不属于我都回 **404** 而非 403 —— 后者会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。 **2. 不能设给人类。** 理由是投递通道不对等。Agent 之间的提醒是任务信号:收到就干活、干完回信。 发给人的提醒是打扰 —— 进收件箱、触发未读徽标,而人**无法回信让它停下** (提醒是日历实体不是对话),只能去 WebUI 里找出那条事件删掉。 一个 Agent 建条「每 10 分钟提醒 jianf 检查进度」的代价远大于收益 (它其实可以直接 send_mail)。 **3. 速率 + 总量双闸。** 速率(20 次/小时,独立桶)压住「短时间暴建」,压不住「每小时建 19 条、 连建一周」—— 而日历事件是**长效**的,一条每日重复提醒会一直发下去。 攒下 300 条之后即使停止建新的,每天仍有 300 封提醒涌出来。 所以加 maxActiveEventsPerAgent=50,并在列表响应里回传 active_limit: 模型看到 42/50 就知道该清理,只在撞墙时才报错等于让它一直蒙在鼓里。 其他设计点: - **PUT 是部分更新**(人类端点是整体替换)。调用方是模型 —— 要求它每次 回传全部字段,漏一个就把提醒正文或收件人清空,而那种破坏没有任何报错。 全部字段用 *T,nil = 没传 = 保持原值。 - 一次性事件设在过去拦掉(会立刻触发,几乎总是时区或年份写错); 重复事件不拦 —— 「每天 9 点」从昨天开始是合理写法。 - 收件人省略时默认给自己:最常见的用法,每次要求写出自己的名字只会让 模型忘记然后拿到 400。 顺带:两个 handler 文件各写了一份手写 itoa(models_scope 那份还漏了负数), 统一成 strconv.Itoa。 测试 repo 9 例:创建者过滤、收件人≠创建者不返回、总量计数、 速率桶与会话桶独立、按 Agent 隔离、失败归还名额。 生产实测 11 项全过(含 403/400/404/429 各条边界)。
95 lines
3.6 KiB
Go
95 lines
3.6 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
)
|
||
|
||
// ---------- 新建会话速率限制 ----------
|
||
//
|
||
// Agent 可以用 name@path.new 开一串新会话,每条都是全新预算 ——
|
||
// 速率限制只压住「短时间内暴开」这个滥用形态,过一个窗口自动恢复。
|
||
|
||
// ErrSessionRateLimited 表示该 Agent 短时间内新建会话过多。
|
||
// (目前未使用,直接返回 retryAfter 由 handler 构造 429 响应)
|
||
|
||
const (
|
||
sessionRateWindow = time.Hour
|
||
sessionRateLimit = 20
|
||
)
|
||
|
||
// AllowNewSession 供 handler 调用:Agent 新建会话前先过速率限制。
|
||
// 人类用户不走这条路径(手工点「新建邮件」的频率天然受限)。
|
||
// 返回 (allowed, retryAfter)。DB 不可用时放行。
|
||
func AllowNewSession(ctx context.Context, agentName string) (bool, int) {
|
||
if agentName == "" {
|
||
return true, 0
|
||
}
|
||
return RateLimitCheckAndRecord(ctx, "session:"+agentName, sessionRateWindow, sessionRateLimit)
|
||
}
|
||
|
||
// ReleaseNewSession 建会话失败后归还名额。
|
||
// DB-backed 方式下记账在 AllowNewSession 里已完成,失败时需手动删除最近一条。
|
||
func ReleaseNewSession(ctx context.Context, agentName string) {
|
||
if agentName == "" {
|
||
return
|
||
}
|
||
bucket := "session:" + agentName
|
||
// 删掉最近一条(建会话失败,那次不该占名额)
|
||
_, _ = db.DB.ExecContext(ctx,
|
||
`DELETE FROM rate_limits WHERE bucket = $1 AND ts = (
|
||
SELECT MAX(ts) FROM rate_limits WHERE bucket = $1
|
||
)`, bucket)
|
||
}
|
||
|
||
// SessionRateLimit 暴露窗口内的新建上限,供错误文案使用。
|
||
func SessionRateLimit() int { return sessionRateLimit }
|
||
|
||
// ---------- Agent 建日历事件的速率限制 ----------
|
||
//
|
||
// 与新建会话同一套机制、独立的桶。为什么必须限:
|
||
//
|
||
// 日历事件是**长效**的 —— 一条每日重复的提醒会一直发下去,直到有人去删。
|
||
// 模型在循环里每轮建一个「10 分钟后提醒我检查」,攒出几十条定时任务后,
|
||
// 即使那条会话早已归档,提醒仍会按时发出。这比 `.new` 洪泛更难收拾:
|
||
// 后者只是多几条空会话,前者是持续产生新邮件的源头。
|
||
//
|
||
// 上限与新建会话一致(20 次/小时):正常用法下 Agent 一次任务里建
|
||
// 一两条日程,20 条足够宽松;而循环失控时一小时内就会撞上限。
|
||
const (
|
||
calendarRateWindow = time.Hour
|
||
calendarRateLimit = 20
|
||
)
|
||
|
||
// AllowAgentCalendarEvent 供 handler 调用:Agent 建日历事件前先过速率限制。
|
||
//
|
||
// 人类不走这条路径(在界面上手工填表的频率天然受限),
|
||
// 因此桶名带 agent: 前缀,与人类操作完全隔离。
|
||
// 返回 (allowed, retryAfter)。DB 不可用时放行 —— 限速不该成为可用性的单点。
|
||
func AllowAgentCalendarEvent(ctx context.Context, agentName string) (bool, int) {
|
||
if agentName == "" {
|
||
return true, 0
|
||
}
|
||
return RateLimitCheckAndRecord(ctx, "calendar:"+agentName, calendarRateWindow, calendarRateLimit)
|
||
}
|
||
|
||
// ReleaseAgentCalendarEvent 建事件失败后归还名额。
|
||
//
|
||
// 与 ReleaseNewSession 同理:记账发生在检查那一刻,
|
||
// 后续的写库失败意味着「那次创建实际没有发生」,不该占名额。
|
||
func ReleaseAgentCalendarEvent(ctx context.Context, agentName string) {
|
||
if agentName == "" {
|
||
return
|
||
}
|
||
bucket := "calendar:" + agentName
|
||
_, _ = db.DB.ExecContext(ctx,
|
||
`DELETE FROM rate_limits WHERE bucket = $1 AND ts = (
|
||
SELECT MAX(ts) FROM rate_limits WHERE bucket = $1
|
||
)`, bucket)
|
||
}
|
||
|
||
// CalendarRateLimit 暴露窗口内的上限,供错误文案使用。
|
||
func CalendarRateLimit() int { return calendarRateLimit }
|