Rust in Everything
基于 axum 0.8.9

Path segments must not start with : 与 Invalid route、Overlapping method route

主线位置:装配主线上 Router::route / nest / merge 这一步。路径先过 axum 自己的格式检查,再插进 matchit 树或和已有的 MethodRouter 合并;任何一步失败都直接 panic,请求主线根本没有机会开始。

flowchart TB
  route["route(path, mr)"] --> vp["validate_path"]
  vp -->|"空、不以 / 开头"| p1["Paths must start with"]
  vp -->|"段以 : 或 * 开头"| p2["Path segments ..."]
  vp --> exists{"路径注册过?"}
  exists -->|是| merge["merge_for_path"]
  merge -->|同一方法| p3["Overlapping ..."]
  exists -->|否| setnode["set_node:matchit 插入"]
  setnode -->|InsertError| p4["Invalid route ..."]

这三类报错和它们背后的规则,在 matchit 与路径语法(路径语法、冲突的全部写法)和 MethodRouter(方法重复的三个入口)里已经挨个试过。这一页换个角度:它们在真实程序里是什么样子、怎么找到出错的那一行、怎么修。

复现:启动就退出

不接 panic,直接让程序崩:

axum = "=0.8.9"
use axum::{
  Router,
  routing::{get, post},
};

async fn show() {}
async fn update() {}

fn users() -> Router {
  Router::new()
    .route("/", get(show))
    .route("/:id", get(show))
}

fn main() {
  let case = std::env::args().nth(1).unwrap_or_default();
  let _app: Router = match case.as_str() {
    "colon" => Router::new().nest("/users", users()),
    "conflict" => Router::new()
      .route("/users/{id}", get(show))
      .route("/users/{name}", post(update)),
    "overlap" => Router::new()
      .route("/users/{id}", get(show))
      .route("/users/{id}", get(update)),
    "merge" => Router::new()
      .route("/users/{id}", get(show))
      .merge(Router::new().route("/users/{id}", get(update).post(update))),
    _ => Router::new(),
  };
  println!("router built");
}

M2 Max、rustc 1.99.0、--release,用 for c in colon conflict overlap merge; do echo "== $c"; ./route_panic $c; echo "exit=$?"; done 四个参数各跑一次(stdout、stderr 合在一起),连跑 3 遍,除了线程号以外输出相同:

== colon

thread 'main' (1175595) panicked at src/bin/route_panic.rs:12:6:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
== conflict

thread 'main' (1175597) panicked at src/bin/route_panic.rs:21:8:
Invalid route "/users/{name}": Insertion failed due to conflict with previously registered route: /users/{id}
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
== overlap

thread 'main' (1175599) panicked at src/bin/route_panic.rs:24:8:
Overlapping method route. Handler for `GET /users/{id}` already exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
== merge

thread 'main' (1175602) panicked at src/bin/route_panic.rs:27:8:
Overlapping method route. Handler for `GET /users/{id}` already exists
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101

几个值得看的地方:

  • 位置:每个 panic 的位置都是 route_panic.rs 里你写的那一行,而不是 axum 内部。colon 指向第 12 行,即子路由 users() 里的 .route("/:id", ...),不是第 18 行的 nest:子路由在被 nest 之前自己构造时就 panic 了。merge 指向第 27 行的 .merge(...) 调用,因为冲突是 merge 内部逐条重放路由时发现的。
  • conflict:两个参数名不同、方法也不同,照样冲突。matchit 先把参数名规范化,/users/{id} 和 /users/{name} 在树里是同一个节点(原因见 matchit 与路径语法 的「没有蠢问题」)。报错里的两个路径都是你写的原样。
  • overlap:路径完全相同的第二次 route 不进 matchit,而是和已有的 MethodRouter 合并;同一个方法两边都有,就报这一句,信息里带着方法和路径。
  • merge:另一边有 GET 和 POST,POST 是新的,GET 撞了,整个 merge 失败。

源码:三处检查,一个 panic

Router::route 本身只有一行逻辑:

    #[track_caller]
    pub fn route(self, path: &str, method_router: MethodRouter<S>) -> Self {
        tap_inner!(self, mut this => {
            panic_on_err!(this.path_router.route(path, method_router));
        })
    }

panic_on_err! 是 Err(err) => panic!("{err}")。真正的检查都在 PathRouter::route 里,它返回 Result<(), Cow<'static, str>>,按顺序做三件事:

  1. validate_path:空路径、不以 / 开头直接拒绝;v7_checks 开着时再调 validate_v07_paths,按 / 切段,哪一段以 : 或 * 开头就返回那两句提示。这一步只看字符串,和 matchit 无关。
  2. 路径已经注册过:取出旧的 MethodRouter,调 merge_for_path(Some(path), ...) 逐个方法合并,两边同一方法都有端点就是「Overlapping method route. Handler for {name} {path} already exists」。
  3. 新路径:set_node 插进 matchit,InsertError 被包成 format!("Invalid route {path:?}: {err}"),Insertion failed due to conflict ... 是 matchit 0.8.4 的原话。

route、nest、nest_service、merge 都标了 #[track_caller],panic! 报告的位置因此是调用方。panic_on_err! 是宏,展开后 panic! 就在 route 的函数体里,#[track_caller] 能一直传到你的代码。

还有一种「Overlapping」不经过 Router::route:get(show).get(update) 这样链式地给同一个 MethodRouter 加两次 GET,panic 发生在 set_endpoint,这时还不知道路径。下面的动脑筋就是它。

📝NOTE
冷知识:panic 位置指向你的代码,是 0.6 才有的

#1248「Annotate panicking functions with #[track_caller]」给这些会 panic 的方法加上了属性。PR 说明里贴了前后对比,之前是:

thread 'routing::tests::foobar' panicked at 'Invalid route "/:b": insertion failed due to conflict with previously registered route: /:a', axum/src/routing/mod.rs:174:13
之后是:
thread 'routing::tests::foobar' panicked at 'Invalid route "/:b": insertion failed due to conflict with previously registered route: /:a', axum/src/routing/tests/mod.rs:667:10

理由是「This should make it even easier to hunt down which route actually causes the conflict.」CHANGELOG 记在 0.6.0 下:「Annotate panicking functions with #[track_caller] so the error message points to where the user added the invalid route, rather than somewhere internally in axum」。提交 6cd35669 第一次出现在 axum-v0.6.0-rc.1。之前位置永远是 axum/src/routing/mod.rs,一个有几十条路由的程序只能靠报错里的路径去搜。

without_v07_checks:不报错,也不匹配

/:id 的报错提示「If you meant to literally match a segment starting with a colon, call without_v07_checks」。这个开关的意思是把 :id 当成字面量,不是恢复 0.7 的参数语法。从 0.7 升级时为了消掉 panic 调用它,路由就悄悄变成了另一条:

use axum::{Router, body::Body, http::Request, routing::get};
use tower::ServiceExt;

#[tokio::main]
async fn main() {
  let app = Router::new()
    .without_v07_checks()
    .route("/users/:id", get(|| async { "matched /users/:id" }));
  for uri in ["/users/42", "/users/:id", "/users/%3Aid"] {
    let res = app.clone().oneshot(Request::get(uri).body(Body::empty()).unwrap()).await.unwrap();
    println!("{uri:<14} {}", res.status());
  }
}

(依赖再加 tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }、tower = { version = "=0.5.3", features = ["util"] }。)连跑 3 次输出相同:

/users/42      404 Not Found
/users/:id     200 OK
/users/%3Aid   404 Not Found

只有路径里真的写着 :id 的请求能匹配,百分号编码的 %3Aid 也不行。0.8.9 不再「静默改变行为」,靠的正是这个默认开着的检查:0.8.0 的 CHANGELOG 写的是「the old syntax produces a panic to avoid silent change in behavior」(#2645,来历见 matchit 与路径语法)。without_v07_checks 只作用于调用它的那个 Router;按它的文档,nest 进来的子路由要各自关,merge 时两边都关了结果才是关的。

❗IMPORTANT
动脑筋:链式写两个 get,panic 指向哪里、信息里有没有路径?
use axum::{Router, routing::get};

async fn show() {}
async fn update() {}

fn main() {
  let _app: Router = Router::new()
    .route("/users", get(show))
    .route("/users/{id}", get(show).get(update));
  println!("router built");
}

panic 的位置是第 9 行的哪一列?信息里会不会出现 /users/{id}?答案在本页末尾。

修法

报错开头原因怎么改
Path segments must not start with `:` / `*`0.7 的 /:id、/*rest改成 /{id}、/{*rest};全局搜 "/: 和 "/*,包括拼出来的路径(format! 里的)
Paths must start with a `/` 路径是空串或没有前导 /根路由写 "/";nest 进来的子路由里也写 "/"
Invalid route "...": Insertion failed due to conflict同一位置两个参数(名字不同也算)、参数和通配、两个通配统一成一个参数名,在 handler 里区分;或改成不同的静态前缀
Invalid route "...": ...(其他)matchit 语法:通配不在末尾、一段里两个参数、空参数名按 matchit 与路径语法 的规则改
Overlapping method route. Handler for ... already exists同一路径同一方法注册两次,常见于 nest 或 merge 两个模块时撞车删掉一个,或把两个 handler 合并;不同方法可以分两次 route
Overlapping method route. Cannot add two method routes同一个 MethodRouter 上同一方法两次同上,信息里没有路径,按 panic 位置找

这些 panic 都发生在构造 Router 的时候,和请求无关。把构造路由的函数放进一个最简单的测试(调用一次、什么也不断言),cargo test 就能在部署前把它们全部暴露出来;不然就是服务一启动就退出,日志里只有一行 panic。

动脑筋答案

连跑 3 次,除线程号外相同:

thread 'main' (1133404) panicked at src/bin/quiz_chain.rs:9:37:
Overlapping method route. Cannot add two method routes that both handle `GET`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  • 位置:第 9 行第 37 列,是 .get(update) 的位置,不是 .route(...)。panic 发生在 MethodRouter::get 里(on_endpoint 和 set_endpoint 都标了 #[track_caller]),这时 Router::route 还没被调用。
  • 路径:没有。这句话来自 set_endpoint,那里只知道方法名。引入「Handler for GET /path already exists」写法的 #1306 在说明里专门提到了这个例外:「It doesn't work for Router::new().route("/foo/bar", get(handler).get(handler)); because the get(_).get(_) panics before Router::route is called so we don't know the path at that point.」所以这一种只能靠 panic 的行列号定位。

小结

  • 路由相关的 panic 分三处产生:validate_path 的格式检查(含 0.7 语法)、matchit 插入失败(Invalid route "...": ...)、merge_for_path / set_endpoint 的方法重复(Overlapping method route)。
  • 它们都发生在构造 Router 时;#[track_caller](0.6 起)让 panic 位置指向你写的 .route、.merge 或 .get,子路由里的错误指向子路由自己的那一行。
  • 参数名不同不等于不冲突:/users/{id} 和 /users/{name} 在 matchit 里是同一个节点。
  • without_v07_checks 把 /:id 当字面量,消掉 panic 的同时让路由再也匹配不到 /users/42;从 0.7 升级应该改语法,不是关检查。
  • 把构造路由的函数放进测试里跑一次,启动时的 panic 就变成了测试失败。

💬 关联讨论

发起讨论
加载中...