Flask-SQLAlchemy 踩坑记录:接口偶尔 404?其实是 paginate 在背后搞事
在最近的项目里,我遇到一个非常“玄学”的问题:
同一个接口,大部分时候正常返回 200,但偶尔会莫名其妙返回 404(还是 HTML 格式的那种)。
路由没变、代码没动、参数也正常……
第一次见到的时候,我真以为 Flask 路由随机罢工了 🥲
经过一番非常曲折的排查,最终找到了幕后黑手:
Flask-SQLAlchemy 的
paginate()在页码超范围时,会自动抛abort(404)。
这个行为隐藏得非常深,而且极具迷惑性。
这篇文章记录一下整个定位过程,也希望能帮到踩类似坑的朋友。
🧩 一、现象描述
某个接口(例如 /api/list)表现如下:
- 大部分请求正常 → 200 OK
- 但偶尔(完全不固定)会出现 → 404 Not Found
- 重试同样请求又正常
最关键的迷惑点是:
404 返回的是 Flask 默认 HTML 页面,而不是 JSON。
从表面看,和“路由不存在”几乎没有区别。
🔍 二、初步排查(全部被排除)
按照正常排查路径,我依次确认了:
| 项目 | 是否正常 |
|---|---|
| 路由是否注册 | ✔️ |
| 蓝图是否加载 | ✔️ |
| 前端 URL 是否拼错 | ✔️ |
| 代理 / 网关配置 | ✔️ |
| 多实例冲突 | ✔️ |
| 登录校验 / before_request | ✔️ |
既然所有东西都正常,那 404 到底是哪里来的?
🧪 三、关键突破:在测试环境稳定复现
线上问题偶发 → 很难抓。
于是我把场景搬到本地,通过不断切换分页、重复请求,终于复现了:
- 一次是 404(HTML 页面)
- 下一次就是 200
- URL + body 几乎一致
这一步非常关键,因为有了复现就能做对比。
从浏览器 Network 对比发现:
- 请求参数合法
- headers 正常
- 区别唯一可能来自分页参数 pageNum
于是把视线转回后端分页逻辑。
🎯 四、真相:paginate 默认会在“超页数”时自动 abort(404)
视图中有这样一行代码:
paginate = query.paginate(page=pageNum, per_page=pageSize)
这行代码的问题在于它的默认参数:
paginate(..., error_out=True)
这意味着:
当 pageNum 超过最大页数时,Flask-SQLAlchemy 会直接:
abort(404)
而不是抛异常,也不是返回空数组。
于是效果就是:
- Flask 立刻返回默认的 HTML 404 页面
- 完美伪装成“路由不存在”
- 导致调试者(也包括我)完全朝错误方向排查
这也是为什么这个错误如此隐蔽。
🤦 吐槽一下这个设计(掘金可以吐槽,但别太狠)
老实讲,这个行为放在 Web 时代(模板渲染场景)可能还能说得过去:
“第 N 页不存在 → 返回 404 → 页不存在”
但在 前后端分离的 REST API 场景里,这种默认行为真的不太合理:
- 分页本质是业务逻辑,不该由 ORM 决定返回状态码
- 超页数应该返回空列表 or 自己控制的错误码
- 插一个自动 abort(404) 进去,很容易误导排查者
- 404 是路由语义,不是分页语义
总之:
默认 error_out=True 的选择并不适合现代 API 场景。
🧹 五、最终解决方法
将 error_out 设置为 False:
paginate = query.paginate(
page=pageNum,
per_page=pageSize,
error_out=False # 避免自动抛 404
)
之后:
- page 超范围 →
items = [] - 不会再出现 HTML 404
- 响应全部由业务自行控制
问题彻底解决 🎉
📝 六、经验总结
✔ 现象:偶发 404,但路由没问题
十有八九是视图里调用了会自动 abort(404) 的函数,例如:
paginate(error_out=True)first_or_404()get_or_404()
✔ 404 返回 HTML,而不是 JSON
说明视图函数根本没执行,404 来自 Flask 内部。
✔ 稳定复现是破案关键
只有抓到对比请求,才能缩小范围。
📌 七、给未来的提醒
如果某个 API:
- 偶尔 404
- 大多数时候正常
- 参数不同效果不同(尤其是 pageNum)
- 返回是 HTML 404,而非 JSON
请立刻排查:
你的分页是不是超范围了?并且 paginate(error_out=True) 正在默默帮你 abort(404)。
注:以上内容是亲身经历,文章由AI润色