Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Build a Smaller Axum

The reconstruction should preserve the adaptation pipeline, not mimic Axum’s surface syntax.

Build one request type, one service protocol, path and method routing, typed extraction, response conversion, middleware, capacity policy, cancellation, and graceful draining.

1. Start with a concrete synchronous server core

Define Request { method, path, headers, body } and Response { status, body }. Route one path with a match, parse one body, call one function, and return one response. Write tests before adding networking.

2. Introduce the service protocol

trait Service<Req> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;
    fn call(&mut self, req: Req) -> Self::Future;
}

First implement it with Ready. Then return boxed async futures. Observe which lifetimes force the future to own its inputs.

3. Separate path and method routing

Create a PathRouter whose endpoints are MethodRouter values. Preserve 404 versus 405 behavior. Store captured path parameters in a type map or a simpler request-extension structure.

4. Build extraction without variadic generics

Implement a parts extractor and one body extractor. Manually support handlers with zero, one, and two arguments. This makes the reason for Axum’s tuple macro obvious before you imitate it.

trait FromParts<S>: Sized {
    type Rejection: IntoResponse;
    async fn from_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection>;
}

Ensure the body-consuming extractor is last.

5. Normalize output and failure

Add IntoResponse for strings, status/body tuples, and Result<T, E>. Create a private application error that logs its source chain but returns a stable public message. Make the outer router error type Infallible.

6. Add middleware as a generic wrapper

Implement logging and timeout wrappers that each contain another Service. Trace the nested type produced by two wrappers. Then introduce one boxed route boundary and compare compile-time readability with runtime indirection.

7. Add Tokio at the edge

Run an accept loop and spawn one connection task per socket. You may use a tiny line-oriented protocol before integrating Hyper. The point is to see that the handler future is nested inside a connection task rather than automatically becoming its own task.

8. Make overload observable

Place a semaphore-backed concurrency limit around one slow route. Test that the limit is enforced. Then compare three explicit policies: wait for a permit, reject immediately, or wait only until a deadline.

There is no universally correct choice; the boundary must name the product policy.

9. Test cancellation and draining

Start a handler, drop its request future, and record which destructors run. Try again after spawning detached work. Finally stop admission, signal existing connections, and wait for an active-task counter to reach zero. Add a drain deadline so a forever-pending handler cannot block shutdown forever.

10. Compare with production

Map your pieces back to serve, Hyper’s connection driver, Router, MethodRouter, Handler, FromRequestParts, FromRequest, IntoResponse, Tower Layer, and HandleError.

The smaller version omits production HTTP correctness, protocol upgrades, HTTP/2 multiplexing, optimized matching, connect information, body utilities, macro diagnostics, and the full Tower ecosystem. Its architectural center should still explain ownership of the body, how handler functions become services, where futures are polled, how overload is bounded, and what happens when a request disappears.