Errors, Middleware, Tests

One error type mapped to status codes, request tracing, and end-to-end tests.

Part 3 of 3Updated

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.