A bad day with Rust lifetimes in Axum middleware
I spent an afternoon last week trying to write what felt like a very simple Axum middleware, and I got absolutely taken to school by the Rust compiler. The function I wanted to write was “take a request, pull a header, do a little database lookup, attach a user ID to request extensions, pass the request along.” I’ve done things like this in half a dozen languages in about ten minutes each. In Rust this took me three hours, and most of the time was spent trying to understand what for<'a> was telling me.
Here’s the failing version:
pub async fn auth_middleware(
State(db): State<DbPool>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let user_id = db.lookup_user(token).await?;
req.extensions_mut().insert(user_id);
Ok(next.run(req).await)
}
That looks fine. It compiled, actually. The problem came when I tried to wire it into a router with .layer(middleware::from_fn_with_state(db.clone(), auth_middleware)). The compiler threw something like this at me:
error: implementation of `FnOnce` is not general enough
|
= note: closure with signature `fn(...) -> impl Future` must
implement `FnOnce<(...,)>`, for any lifetime `'0`
but it only implements `FnOnce<(...,)>` for some
specific lifetime `'1`
The actual error was longer than this, and honestly quite baffling the first time you see it. What the compiler is trying to tell you is: “your middleware is only valid for one specific lifetime of the request, but the layer framework needs it to be valid for any lifetime a request could have.” That’s what for<'a> means — it’s a higher-ranked trait bound, HRTB, and the framework needs your function to satisfy it.
Why was my function only valid for a specific lifetime? Because of the token string. I’d called .to_str().ok(), which returns an Option<&str> — a reference that borrows from the HeaderValue, which borrows from the headers map, which lives on the request I later want to move into next.run(req). The borrow checker can’t let me do that, because I still have an alive reference into req when I try to consume it.
The fix, once I understood it, was unglamorous:
pub async fn auth_middleware(
State(db): State<DbPool>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token: String = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?
.to_owned();
let user_id = db.lookup_user(&token).await?;
req.extensions_mut().insert(user_id);
Ok(next.run(req).await)
}
Note to_owned(). By copying the header into an owned String, I release the borrow on req, and now I can freely move req into next.run. The whole middleware now satisfies the HRTB because there are no lifetimes in its signature other than the implicit ones on the arguments.
What bit me is that the compiler error did not point at the to_str() call. It pointed at the registration site, which is several modules away from where the actual borrow problem is. This is a recurring theme with HRTB errors in async Rust: the failure is not where the compiler says it is, because traits are checked at the type-system level after inference has already happened.
Things I’ve internalized after this:
- If I’m writing async code that takes an owned thing and has to hand it off later (like
reqhere), I avoid taking borrows into it that outlive short blocks. Just copy small data into owned form as early as possible. The cost of.to_owned()on an auth header is zero compared to the cost of my time arguing with the borrow checker. - When I see
implementation of FnOnce is not general enough, my first guess is now “you’re holding a borrow that conflicts with a move.” It’s not always that, but it’s the most common cause in middleware-shaped code. for<'a>makes more sense if you read it as “for all lifetimes'a.” The framework is saying: I will call you with requests of any lifetime, and your function needs to be ready.
I almost switched middleware frameworks that afternoon. I’m glad I didn’t. Once this kind of pattern clicks, Axum middleware is actually quite nice — the State extractor is a good ergonomic escape hatch, and from_fn_with_state is doing a lot for you. But I wish the error message would just say “you’ve got a borrow into req that outlives req’s move into next.” Maybe someday.
Related: I wrote about Pin and Unpin and the async Rust reading I had to do to get comfortable with the type system here.