上个月给情绪记录项目改接口。原来的GET /api/emotion/history直接返回一个列表前端自己算总数、自己分页数据一多前端每次都要把几百条记录全拿下来。我决定把返回结构改成{total, page, items}后端分页前端只负责渲染。改动不大把查询函数加个offset/limit返回值包一层字典。改完保存uvicorn --reload打了重载日志我用 curl 打了一次接口返回的还是那个裸列表。我以为是重载没生效手动重启又怀疑是浏览器缓存了 GET换了 Postman再怀疑__pycache__里的旧字节码把目录删了重跑。折腾了快一个小时前端那边一直在问“你确定改了吗”。代码确实改了但服务器上跑的不是它。问题出在同一个路径被注册了两次而后注册的那份根本没机会执行。一、先排掉三个“看起来很像”的原因这类“改了不生效”的问题最容易把人带偏的是几个常见解释。我按顺序验了一遍结论都是否定的但验证方式值得记下来因为后面每次遇到同类现象都能用。猜测怎么验证结果热重载没触发新增一个GET /api/_ver返回时间戳时间戳是新的重载正常浏览器/客户端缓存用 curl 加-H Cache-Control: no-cache直接打返回结构仍旧__pycache__旧字节码删掉所有__pycache__后重启无变化前端用了本地 mock看 DevTools 的 Response 与服务器日志请求确实到了后端四个都排除之后剩下的可能性只有一种后端确实收到请求了也确实执行了某个处理函数但那个函数不是我以为的那个。这时候再盯着业务代码看已经没意义了得去看“这个路径到底绑了几个函数”。二、把 app.routes 打出来FastAPI 的应用对象上有一个routes列表所有注册过的路由都在里面包括你自己写的和框架内置的/docs、/openapi.json。把它按“路径 方法”分组重复的自然就浮出来了。from collections import defaultdict import main # 你的 FastAPI 实例所在模块 seen defaultdict(list) for route in main.app.routes: path getattr(route, path, ) methods getattr(route, methods, None) if not methods or not path.startswith(/api): continue seen[(path, frozenset(methods))].append(route.endpoint) for (path, methods), endpoints in seen.items(): if len(endpoints) 1: print(重复:, path, sorted(methods)) for ep in endpoints: print( , ep.__module__ . ep.__name__)输出很直接/api/emotion/history出现了两次一个来自main.py里的app.get一个来自routers/emotion.py里的router.get后者被app.include_router(emotion_router, prefix/api)挂进来。而我改的是 Router 里那一份执行的是main.py里那一份——那是两个月前写的第一版改结构时忘了它还在。这里有个细节值得注意重复注册不会抛异常也不会打警告。FastAPI 只是往列表里追加两个函数都合法、都能被调用只是永远只有第一个能匹配上。app.routes和app.router.routes指向的是同一个列表include_router也不是把子路由“挂”成树而是逐个复制进这个扁平列表所以打印出来的顺序就是真实的匹配顺序。顺带说一个副作用Swagger 会把两个同路径的操作都渲染出来。我当时代码里一处写了tags[情绪]另一处没写于是同一个路径在文档里出现了两次、分组还不同看着更像两个不同接口进一步误导了排查方向。三、十几行的最小复现为了让团队里其他人相信这不是环境或配置问题我把它压成了一个单文件示例from fastapi import FastAPI from fastapi.testclient import TestClient app FastAPI() app.get(/api/demo) def old_handler(): return {version: old, items: []} app.get(/api/demo) def new_handler(): return {version: new, total: 0, items: []} client TestClient(app) print(client.get(/api/demo).json()) # {version: old, items: []}输出是old把两个函数调换位置就变成new。这段代码同时说明了三件事注册阶段不报错、后注册者永远得不到执行、代码顺序即优先级。TestClient不需要真正监听端口所以它很适合放进回归用例里当作一条“这个错误应该被拦住”的证据。四、为什么是“第一个”生效要理解这个得往下看一层。FastAPI 的app并不是自己处理请求它把请求交给 Starlette 的Router而Router内部的匹配逻辑简化后是这样async def app(self, scope, receive, send): partial None for route in self.routes: match, child_scope route.matches(scope) if match Match.FULL: await route.handle(scope, receive, send) return # 命中即返回后面的路由不再看 if match Match.PARTIAL and partial is None: partial route # 全部走完都没命中才处理 405 / 重定向顺序遍历、首个命中即返回。这意味着路径和方法都相同的两个路由排在后面的那个是死代码——它存在于app.routes里出现在/docs里Swagger 会把同路径的两个操作都列出来看起来像两个接口但永远不会被调用。这也解释了为什么我当时在 Swagger 里看到两行/api/emotion/history却没往重复注册上想以为是 FastAPI 把操作拆成了两个标签页。那顺序由什么决定。app.get在模块导入时就完成注册include_router则在被调用的那一刻把子路由批量追加进列表。所以顺序取决于代码执行的先后而不是文件里的书写位置。常见的三种重复来源老代码留在main.py新逻辑搬进 Router两处都没删include_router被调了两次比如在create_app()和模块级各调一次整组路由翻倍多个 Router 用了同一个prefix各自的路径又重名。五、几种“看着像重复”的相邻坑排查过程中我还顺手确认了几个容易混淆的变体它们的表现和重复注册很像但根因不同处理方式也不一样。现象根因判断依据405 Method Not Allowed路径命中但没有任何路由声明该 HTTP 方法响应头里的Allow列出实际支持的方法422 参数校验失败/history/{item_id}注册在/history/latest之前latest被当成 id 去校验看报错字段名是路径参数而不是查询参数307 重定向后 401请求带了尾斜杠redirect_slashes默认开启并返回 307客户端跨域跟随重定向时丢掉Authorization响应里出现 307且目标地址少了斜杠请求打到旧逻辑同一路径注册两次先注册者生效app.routes里同路径同方法出现多次第三行那个 307 值得多说一句。Starlette 默认redirect_slashesTrue/api/emotion/history/会被重定向到不带斜杠的地址。307 会保留方法和请求体看起来没问题但浏览器的 fetch 在跟随跨域重定向时会剥掉Authorization头于是 token 没了、接口返回 401而你在后端只看到一个没带凭证的请求。我的做法是前端请求统一不带尾斜杠路由也不要两个都定义。六、两道防线知道根因之后改起来只要删掉main.py里那份旧实现。但这类问题会复发尤其多人协作、代码搬来搬去的时候。我加了两道很轻的防线。第一道是启动自检在create_app()最后调用一次重复直接抛错让服务起不来——比让它带病运行强得多。# app/route_guard.py from collections import defaultdict from fastapi import FastAPI def assert_no_duplicate_routes(app: FastAPI, prefix: str /api) - None: seen defaultdict(list) for route in app.routes: path getattr(route, path, ) methods getattr(route, methods, None) if not methods or not path.startswith(prefix): continue seen[(path, frozenset(methods))].append(route.endpoint) dup {k: v for k, v in seen.items() if len(v) 1} if not dup: return lines [] for (path, methods), eps in dup.items(): owners [f{ep.__module__}.{ep.__name__} for ep in eps] lines.append(f{path} {sorted(methods)} - {owners}) raise RuntimeError(检测到重复路由:\n \n.join(lines))第二道是一个测试用例把同一份检查跑在 CI 里。它本身没有新逻辑价值在于“提交前就会被拦下”而不是等线上发现改代码不生效。def test_no_duplicate_api_routes(): from app.route_guard import assert_no_duplicate_routes from main import app assert_no_duplicate_routes(app)自检要注意两点。一是把范围限定在业务前缀比如/api否则框架内置的/openapi.json、/docs之类会干扰判断二是别在测试环境直接raise导致整个 suite 挂掉——如果项目里有很多历史重复先print出清单清完再改成抛错。小结这次踩的坑本质是“配置型的错误伪装成了代码错误”。我一开始所有的排查动作都指向“新代码没跑起来”而真实情况是“新代码根本没被选中”。两者的表现都是返回旧结构但验证路径完全不同前者查进程、查重载、查缓存后者只需要把app.routes打出来看一眼。现在我的习惯是凡是“改了没反应”且确认请求确实打到后端先花十秒枚举路由和方法的组合再去看业务逻辑。这个动作成本极低却能排掉一整类静默失效——路由重复、前缀拼错、方法声明错、路径参数抢占全都能在这一步暴露出来。