Errors, Middleware, Tests
One error type mapped to status codes, request tracing, and end-to-end tests.
Define one error type and implement IntoResponse for it. Thatβs what lets ? work inside
handlers:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, msg) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found"),
AppError::Validation(m) => (StatusCode::BAD_REQUEST, m.as_str()),
AppError::Db(e) => {
tracing::error!(?e, "database error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
(status, Json(json!({ "error": msg }))).into_response()
}
}
Log the internal detail; return the generic message. Leaking a SQL error to a client is an information disclosure bug.
Add tower_http::trace::TraceLayer for request logging, and TimeoutLayer so a slow query
canβt pin a connection forever.
Testing is the payoff of Router being a plain value:
#[tokio::test]
async fn creates_a_task() {
let app = build_app(test_pool().await);
let response = app
.oneshot(Request::builder().method("POST").uri("/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"write tests"}"#)).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}
No server, no port, no flakiness β oneshot drives the router directly. Use an in-memory
SQLite database per test and they run in parallel for free.