0. 系列闭环(不公开源码也能跟读)
端到端链路:Vue 前端 → api/routes/chat.py → Guide 多轮 SSE → run_analysis_pipeline(解析→分析→匹配→报告)→ tools/pdf_exporter PDF。
本篇:第 16/17 篇 · 网关环 · 中间件
| 阶段 | 用户可见 | 代码入口 | 对应篇 |
|---|---|---|---|
| 建会话 | 欢迎语 | POST /api/sessions | 09 |
| 多轮对话 | SSE 流式 | chat/stream → run_guide_single_turn | 06, 14 |
| 信息充分 | 开始分析 | _run_analysis_background | 05, 07 |
| 履历解析 | 进度 30% | run_resume_parser | 12 |
| 画像/RIASEC | 进度 50% | run_profile_analyzer | 03, 13 |
| 职业匹配 | 进度 70% | run_career_matcher | 02 |
| 报告 | 进度 90% | run_reporter | 11 |
| 下载 PDF | 文件 | GET …/report/pdf | 11, 15 |
| 说明 | |
|---|---|
| 读本篇前 | 第 01 篇 main.py |
| 读完本篇 | 说出 CORS 与 RateLimit 注册顺序及 /health 白名单 |
| 下一环 | 第 17 篇:配置项来源(第 17 篇) |
全系列闭环索引:SERIES-LOOP.md
一、要解决什么问题
iCan 的 /chat/stream、报告生成、文件上传都会触发 LLM 或长耗时任务。公开演示或内测时,若不对 IP 限流,单客户端刷接口会导致:
- LLM 配额被快速耗尽(成本与速率双杀);
- Uvicorn worker 被占满,正常用户的 SSE 流卡顿;
- 日志被同一 IP 淹没,难以排查真实故障。
项目在 api/middleware.py 自研 滑动窗口 IP 限流(不引入 slowapi/Redis,符合 MVP 体量),在 main.py 的 create_app() 里与 CORSMiddleware 一起注册。
二、实现位置
| 组件 | 文件 | 说明 |
|---|---|---|
| 限流 | api/middleware.py |
RateLimitMiddleware |
| CORS + 注册顺序 | main.py |
create_app() 内两次 add_middleware |
| 受影响路由 | api/routes/chat.py 等 |
所有 HTTP 请求经中间件链 |
三、Starlette 中间件顺序(洋葱模型)
FastAPI/Starlette 后添加的中间件先接触请求。main.py 实际注册顺序:
1 | |
因此请求路径是:
1 | |
RateLimit 在最外层:超限直接在 dispatch 里返回 429,不必再跑 CORS 逻辑和路由,适合挡恶意刷接口。若把顺序写反,会先算 CORS 再限流,浪费 CPU。
四、RateLimitMiddleware 实现
完整逻辑在 api/middleware.py:
1 | |
设计选择:
| 点 | 说明 |
|---|---|
| 滑动窗口 | 按时间戳过滤过期记录,比「整分钟重置」更平滑 |
内存 defaultdict(list) |
实现简单;进程重启计数清零;多副本互不共享 |
/health 豁免 |
Docker/K8s healthcheck 不被 429 |
| 429 JSON body | 前端可统一 toast,字段 error + detail |
默认 60 次 / 60 秒 / IP,在 main.py 写死传入;尚未接到 config.py 的 settings,改配额要改代码或后续抽到配置(第 17 篇)。
五、CORSMiddleware 配置
MVP 阶段允许所有来源,方便本地 Vue dev server 与线上域混测:
1 | |
注意 allow_origins=["*"] 与 allow_credentials=True 的组合:浏览器规范下,带 Cookie 的跨域请求不能使用 Access-Control-Allow-Origin: *。开发环境若未带凭证可能无感;生产若启用登录 Cookie,必须把 allow_origins 改成明确域名列表(如 https://app.example.com)。
iCan 当前 JWT/会话若走 Header 而非 Cookie,影响较小,但上线前仍应复核前端实际凭证方式。
六、与业务路由的配合
POST /chat/stream(api/routes/chat.py):一次用户发消息通常计 1 次限流;前端若错误地频繁重连 SSE,会快速触达 429。- WebSocket(
api/routes/ws.py):握手是 HTTP 升级,同样经过RateLimitMiddleware,计入同一 IP 窗口。 POST上传简历:大 body 仍占一次计数;当前未做「上传单独更低配额」或「按 user_id 限流」。- **
/health**:不限流,保证编排系统探活稳定。
扩展思路(未实现):在 dispatch 里按 request.url.path 分支,例如 chat 30/min、upload 10/min,参数从 config.py 读取。
七、429 响应与 CORS 头
超限返回的是裸 JSONResponse(429),不经过 CORSMiddleware 为成功响应追加头的那条路径时,部分浏览器跨域场景下前端只能看到 network error,读不到 JSON body。
当前顺序下 RateLimit 在外层,429 由 RateLimit 直接返回,可能缺少 CORS 头。内网同源部署通常无问题;若前端与 API 不同域且需解析 429 body,可在 429 分支手动加 Access-Control-Allow-Origin,或调整中间件顺序并实测浏览器行为。
八、生产演进路径
| 阶段 | 方案 |
|---|---|
| MVP(当前) | 单进程内存滑动窗口,60/min/IP |
| 多 Uvicorn worker / 多 Pod | 各副本计数独立,Effective 限额 ≈ N × 60;需 Redis INCR+TTL 或网关限流 |
| 登录用户 | 按 user_id 限流,减轻 NAT 下多用户共 IP 误伤 |
| 边缘 | 静态资源 CDN,API 走 WAF/云厂商速率限制 |
九、踩坑与边界
request.client为 None 或反代 IP 不对
代码用request.client.host;Nginx 未传X-Forwarded-For时,反代后可能全是127.0.0.1或"unknown",所有用户共用一个桶。生产应在反向代理配置真实 IP,并在中间件读X-Forwarded-For首段(当前源码未做,属已知缺口)。/health路径硬编码
若健康检查改成/api/health或带 prefix,必须同步改豁免条件,否则探活被 429 导致容器反复重启。内存泄漏边界
_requests只清理当前 IP 的时间戳列表;长期运行若 IP 种类极多,dict 会变大。MVP 可接受;大规模需 TTL 清理整个 IP 键或换 Redis。与 LLM 超时的关系
限流挡的是「请求次数」,不挡「单次 LLM 90s」;用户 60 秒内发 60 条消息仍可能把 worker 拖慢——需产品层防抖 + 第 7 篇的超时策略配合。CORS
*+ credentials
见第五节;上线前改为显式 origin 列表,并删掉与规范冲突的组合。
十、小结
- 限流在
api/middleware.py,CORS 与注册顺序在main.py;后注册的 RateLimit 在外层,先挡刷接口。 - 滑动窗口 +
/health豁免 + 429 JSON,满足 MVP;多实例前必须换分布式计数。 - 反代 IP、CORS 与 429、路径豁免是上线前三项必查。
- 下一篇(第 17 篇):
pydantic-settings配置管理与 API Key 脱敏。
附录:关键源码(逐行注释)
以下代码摘自 iCan 实现,每行上方均有中文注释,不公开仓库也可跟读。
生成命令:python3 bin/build-ican-annotated-snippets.py
RateLimitMiddleware(节选)
1 | |
create_app CORS + 中间件注册
1 | |
系列导航
| 篇 | 主题 |
|---|---|
| 1 | 系统全景 |
| 2 | 五 Agent 协作 |
| 3 | 霍兰德 RIASEC |
| 4–7 | 状态 · 路由 · 嵌套 · 容错 |
| 8–11 | LLM 层 · SSE/WS · DB 迁移 · PDF |
| 12–14 | JSON Prompt · RIASEC Prompt · Guide Prompt |
| 15–17 | Docker · 16 中间件(本篇) · 配置 |