Part 10: Middleware, Logging & Error Handling in Rust Web Applications
Introduction
As Rust web applications grow in size and complexity, middleware, structured logging, and centralized error handling become essential. These concepts help you:
- Keep code clean and modular
- Improve observability and debugging
- Handle failures gracefully
- Build production-grade, maintainable systems
In this part, we’ll focus on practical patterns used in real-world Rust backend services.
What Is Middleware in Rust Web Applications?
Middleware is code that runs before or after your request handler.
Typical middleware responsibilities include:
- Authentication & authorization
- Request/response logging
- Rate limiting
- CORS handling
- Error transformation
Middleware allows you to separate cross-cutting concerns from business logic.
Middleware Flow (Conceptual)
Request → Middleware → Handler → Middleware → Response
Each middleware can:
- Inspect the request
- Modify headers
- Reject requests early
- Attach shared context
Creating Middleware (Axum Example)
Axum uses Tower middleware, which is powerful and composable.
Simple Logging Middleware
use axum::{http::Request, middleware::Next, response::Response};
pub async fn log_middleware<B>(
req: Request<B>,
next: Next<B>,
) -> Response {
println!("Incoming request: {} {}", req.method(), req.uri());
let response = next.run(req).await;
println!("Response status: {}", response.status());
response
}Register Middleware
use axum::{Router, middleware};
let app = Router::new()
.route("/", get(handler))
.layer(middleware::from_fn(log_middleware));This middleware will now wrap every request.
Authentication Middleware Pattern
Authentication middleware typically:
- Extracts token (JWT / API key)
- Validates it
- Injects user context into request extensions
Example Pattern
use axum::{http::Request, middleware::Next, response::Response};
pub async fn auth_middleware<B>(
mut req: Request<B>,
next: Next<B>,
) -> Response {
let headers = req.headers();
if headers.get("authorization").is_none() {
return Response::builder()
.status(401)
.body("Unauthorized".into())
.unwrap();
}
next.run(req).await
}This keeps authentication logic outside handlers.
Why Logging Matters in Production
In production, logs are your primary debugging tool.
Good logging helps you:
- Trace user requests
- Detect failures early
- Analyze performance bottlenecks
- Debug distributed systems
Rust encourages structured logging instead of plain println!.
Logging with tracing
The tracing ecosystem is the standard logging solution for async Rust.
Add Dependencies
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }Initialize Logger
use tracing_subscriber::{fmt, EnvFilter};
fn init_logging() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
}Call init_logging() at application startup.
Logging Inside Handlers
use tracing::{info, error};
pub async fn handler() {
info!("Handler called");
}Structured Logs
info!(user_id = 42, action = "login", "User logged in");These logs are machine-readable and ideal for monitoring tools.
Request Tracing Middleware
Axum integrates well with tracing layers.
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/", get(handler))
.layer(TraceLayer::new_for_http());This automatically logs:
- Request start
- Response status
- Latency
Error Handling Philosophy in Rust
Rust encourages explicit error handling.
Instead of throwing exceptions:
- Errors are values
- Functions return
Result<T, E> - Errors must be handled or propagated
This leads to predictable and reliable systems.
Custom Error Types
Define a unified error type for your application.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error")]
DatabaseError,
#[error("Unauthorized")]
Unauthorized,
#[error("Not found")]
NotFound,
}This makes errors consistent across the app.
Returning Errors from Handlers
use axum::{response::IntoResponse, http::StatusCode};
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let status = match self {
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, self.to_string()).into_response()
}
}Now handlers can return:
Result<T, AppError>
Global Error Handling Pattern
pub async fn handler() -> Result<String, AppError> {
Err(AppError::Unauthorized)
}
Axum automatically converts errors using IntoResponse.
This keeps handlers clean and readable.
Best Practices
Middleware
- Keep middleware small and focused
- Avoid heavy business logic
- Order middleware carefully
Logging
- Prefer structured logs
- Never log secrets
- Include request IDs
Error Handling
- Use a single error enum
- Map errors to HTTP status codes
- Log internal errors, not user messages
Common Mistakes to Avoid
- Using
unwrap()in production code - Logging sensitive data
- Handling errors inside every handler manually
- Mixing business logic into middleware
What You’ve Learned in Part 10
- Middleware design and usage
- Logging with
tracing - Structured and request-level logging
- Centralized error handling
- Production-ready patterns
