TT Lab
开始
学习 学习路径 课程

FastAPI — 类型就是契约

验证请求限流的精确时间边界:设计原理

在 TT Lab 中继续学习

一句话总结

用假时钟检查滑动窗口和 Retry-After,并把每个用户的限额分开。

为什么需要它

流量一增加,服务器就开始把所有请求记录在同一个列表里。一个用户的连续请求,把别的用户的正常请求也拦住了。从窗口的最后时刻删除条目时,比较运算也写错了,导致限制多保持了 1 秒。而真的等上几十秒的测试,会让这种边界既慢又不稳定。

工作原理

如果把时钟作为函数参数接收,就可以不必等待而直接移动到精确的时间点。有效窗口只包含大于 now-window 的时间戳。只记录被允许的请求,被拒绝的请求不会扩大窗口。已满时,把到最旧的被允许请求过期为止的时间向上取整,作为 Retry-After 发送。最后比较同一个用户的第三次请求和另一个用户的第一次请求。

client id → 해당 키의 기록 → 만료 제거 → 여유 있음: 기록+200
                                      └→ 꽉 참: 기록 보존+429

阅读契约并预测失败的工作表

下面不是让你把整个实现背下来的答案,而是分步骤的代码评审。每个改动片段都故意破坏了契约。请注意,改动之后,正常用例仍然可能通过。在运行之前,先预测观测哪些输入、异常和状态才能看出差异,实现之后,再把这个预测与结果进行比较。

1. 验证设置

validate_limit(limit, window) 只允许排除 bool 的正 int limit,以及正的有限 int/float window,并返回 (limit, float(window))。其余都是 ValueError。

判断依据:bool 是 int 的子类型。NaN 和无穷大也必须另外拒绝。

待评审的错误改动片段:

not isinstance(limit, int)

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

2. 排除窗口的左边界

active(history, now, window) 只把大于 now-window 的时间,按原来的顺序作为新列表返回。history 是排好序、非递减的时间。

判断依据:确认保留恰好过期的时间的 >= 与 > 的区别。

待评审的错误改动片段:

stamp >= now - window

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

3. 把等待时间向上取整

retry_after(history, now, window) 是:对已经清理过、非空的 history,取第一个时间+window-now 的 ceil 值与 0 中较大的整数。空列表为 0。

判断依据:如果只剩 0.2 秒就把 Retry-After 写成 0,客户端会立刻重新请求。

待评审的错误改动片段:

int(history[0] + window - now)

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

4. 按键分开记录

history_for(state, key) 在键不存在时返回空列表,存在时返回该记录的副本。不要仅仅因为查询就修改 state。

判断依据:如果返回共享列表,一个请求的清理就可能改变另一个请求的记录。

待评审的错误改动片段:

state.get(key, [])

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

5. 只记录被允许的请求

admit(state, key, now, limit, window) 在验证设置之后,清理该键的过期记录。有余量时追加 now 并返回 (True,0),已满时不追加,返回 (False,retry_after)。

判断依据:如果追加被拒绝的请求,每次重试都会使过期时间被推后。

待评审的错误改动片段:

len(history) > limit

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

6. 验证客户端键

client_key(value) 对 1–40 个字符的 ASCII 字母、数字、连字符的字符串原样返回,其余都是 ValueError。

判断依据:限制输入的范围,避免无限制的键大小挤占状态内存。

待评审的错误改动片段:

<= 80

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

7. 构造拒绝响应

limited_response(wait) 是一个 JSONResponse:状态为 429,正文为 {error:'rate_limited'},Retry-After 头是把 wait 转成字符串的值。

判断依据:让客户端知道重试的时间,所以状态和头一起发送。

待评审的错误改动片段:

status_code=503

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

8. 用虚拟时间完成请求流程

create_app(clock, limit=2, window=10) 在 GET /work 中检查 X-Client-ID:非法的键返回 400 {error:'invalid_client'},允许时返回 200 {ok:True},超出时返回 limited_response。state 按应用分开。

判断依据:不要真的 sleep,而要通过 clock 函数传递装在列表里的当前时间。

待评审的错误改动片段:

admit(state, "shared", clock(), limit, window)

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

在现场相遇的样子

这是一个位于进程内存中、面向单个 worker 的示例。它不保证由多个 Pod 共享的全局限额,也不保证恶意客户端的身份。X-Client-ID 是测试用的键,所以在生产环境中,应当从已认证的主体获取键。持续的时钟回拨应通过使用单调时钟来避免,本实验的 clock 是非递减的。

下一项实验要做什么

八个步骤会连成一个可运行的成果。验证设置 → 排除窗口的左边界 → 把等待时间向上取整 → 按键分开记录 → 只记录被允许的请求 → 验证客户端键 → 构造拒绝响应 → 用虚拟时间完成请求流程。

每一步检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。请说明前面的测试为什么在下一步中依然保持有效,并写出一条本实验不能保证的生产条件。