From 684519f689b121a62a4b6cd6d168ec01e1a99a2a Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Tue, 26 Aug 2025 12:47:22 +0200 Subject: [PATCH] Error handling with thiserror and anyhow --- Cargo.lock | 8 +++ Cargo.toml | 2 + src/routes/subscriptions.rs | 126 ++++++++++++++++++++++++++---------- src/startup.rs | 30 +++++---- tests/api/subscriptions.rs | 16 +++++ 5 files changed, 133 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d3f36e..237c00c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + [[package]] name = "arraydeque" version = "0.5.1" @@ -3377,6 +3383,7 @@ dependencies = [ name = "zero2prod" version = "0.1.0" dependencies = [ + "anyhow", "axum", "chrono", "claims", @@ -3393,6 +3400,7 @@ dependencies = [ "serde-aux", "serde_json", "sqlx", + "thiserror", "tokio", "tower-http", "tracing", diff --git a/Cargo.toml b/Cargo.toml index a756d20..773f246 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" name = "zero2prod" [dependencies] +anyhow = "1.0.99" axum = "0.8.4" chrono = { version = "0.4.41", default-features = false, features = ["clock"] } config = "0.15.14" @@ -20,6 +21,7 @@ secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } serde-aux = "4.7.0" sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] } +thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tower-http = { version = "0.6.6", features = ["trace"] } tracing = "0.1.41" diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index d07413b..761fe91 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -3,7 +3,13 @@ use crate::{ email_client::EmailClient, startup::AppState, }; -use axum::{Form, extract::State, http::StatusCode, response::IntoResponse}; +use anyhow::Context; +use axum::{ + Form, Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; use chrono::Utc; use rand::{Rng, distr::Alphanumeric}; use serde::Deserialize; @@ -18,6 +24,69 @@ fn generate_subscription_token() -> String { .collect() } +fn error_chain_fmt(e: &impl std::error::Error, f: &mut std::fmt::Formatter) -> std::fmt::Result { + writeln!(f, "{}", e)?; + let mut current = e.source(); + while let Some(cause) = current { + write!(f, "Caused by:\n\t{}", cause)?; + current = cause.source(); + if current.is_some() { + writeln!(f)?; + } + } + Ok(()) +} + +// #[derive(thiserror::Error)] +// pub enum SubscribeError { +// #[error("Failed to store the confirmation token for a new subscriber.")] +// StoreToken(#[from] StoreTokenError), +// #[error("A database error occured.")] +// Database(#[from] sqlx::Error), +// #[error("Failed to send a confirmation email.")] +// SendEmail(#[from] reqwest::Error), +// #[error("{0}")] +// Validation(String), +// } + +#[derive(thiserror::Error)] +pub enum SubscribeError { + #[error(transparent)] + UnexpectedError(#[from] anyhow::Error), + #[error("{0}")] + Validation(String), +} + +impl std::fmt::Debug for SubscribeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + error_chain_fmt(self, f) + } +} + +impl IntoResponse for SubscribeError { + fn into_response(self) -> Response { + #[derive(serde::Serialize)] + struct ErrorResponse<'a> { + message: &'a str, + } + + tracing::error!("{:?}", self); + + // let status = match self { + // SubscribeError::StoreToken(_) + // | SubscribeError::Database(_) + // | SubscribeError::SendEmail(_) => StatusCode::INTERNAL_SERVER_ERROR, + // SubscribeError::Validation(_) => StatusCode::BAD_REQUEST, + // }; + let status = match self { + SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR, + SubscribeError::Validation(_) => StatusCode::BAD_REQUEST, + }; + let message = "An internal server error occured."; + (status, Json(ErrorResponse { message })).into_response() + } +} + #[tracing::instrument( name = "Adding a new subscriber", skip(connection_pool, email_client, base_url, form), @@ -33,39 +102,32 @@ pub async fn subscribe( base_url, }): State, Form(form): Form, -) -> impl IntoResponse { - let Ok(mut transaction) = connection_pool.begin().await else { - return StatusCode::INTERNAL_SERVER_ERROR; - }; - let Ok(new_subscriber) = form.try_into() else { - return StatusCode::BAD_REQUEST; - }; - let Ok(subscriber_id) = insert_subscriber(&mut transaction, &new_subscriber).await else { - return StatusCode::INTERNAL_SERVER_ERROR; - }; - let subscription_token = generate_subscription_token(); - if store_token(&mut transaction, &subscription_token, &subscriber_id) +) -> Result { + let mut transaction = connection_pool + .begin() .await - .is_err() - { - return StatusCode::INTERNAL_SERVER_ERROR; - } - if let Err(e) = send_confirmation_email( + .context("Failed to acquire a Postgres connection from the pool.")?; + let new_subscriber = form.try_into().map_err(SubscribeError::Validation)?; + let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber) + .await + .context("Failed to insert new subscriber in the database.")?; + let subscription_token = generate_subscription_token(); + store_token(&mut transaction, &subscription_token, &subscriber_id) + .await + .context("Failed to store the confirmation token for a new subscriber.")?; + send_confirmation_email( &email_client, &new_subscriber, &base_url, &subscription_token, ) .await - { - tracing::error!("Could not send confirmation email: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR; - } - if let Err(e) = transaction.commit().await { - tracing::error!("Failed to commit transaction: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR; - } - StatusCode::OK + .context("Failed to send a confirmation email.")?; + transaction + .commit() + .await + .context("Failed to commit the database transaction to store a new subscriber.")?; + Ok(StatusCode::OK.into_response()) } #[tracing::instrument( @@ -87,10 +149,7 @@ pub async fn insert_subscriber( new_subscriber.name.as_ref(), Utc::now() ); - transaction.execute(query).await.map_err(|e| { - tracing::error!("Failed to execute query: {:?}", e); - e - })?; + transaction.execute(query).await?; Ok(subscriber_id) } @@ -111,10 +170,7 @@ async fn store_token( subscription_token, subscriber_id, ); - transaction.execute(query).await.map_err(|e| { - tracing::error!("Failed to execute query: {:?}", e); - e - })?; + transaction.execute(query).await?; Ok(()) } diff --git a/src/startup.rs b/src/startup.rs index cd526de..2f1aeaa 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -62,21 +62,23 @@ pub fn app(connection_pool: PgPool, email_client: EmailClient, base_url: String) .route("/subscriptions", post(subscribe)) .route("/subscriptions/confirm", get(confirm)) .layer( - TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { - let matched_path = request - .extensions() - .get::() - .map(MatchedPath::as_str); - let request_id = Uuid::new_v4().to_string(); + TraceLayer::new_for_http() + .make_span_with(|request: &Request<_>| { + let matched_path = request + .extensions() + .get::() + .map(MatchedPath::as_str); + let request_id = Uuid::new_v4().to_string(); - tracing::info_span!( - "http_request", - method = ?request.method(), - matched_path, - request_id, - some_other_field = tracing::field::Empty, - ) - }), + tracing::info_span!( + "http_request", + method = ?request.method(), + matched_path, + request_id, + some_other_field = tracing::field::Empty, + ) + }) + .on_failure(()), ) .with_state(app_state) } diff --git a/tests/api/subscriptions.rs b/tests/api/subscriptions.rs index ef11f8e..3857db5 100644 --- a/tests/api/subscriptions.rs +++ b/tests/api/subscriptions.rs @@ -122,3 +122,19 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() { let confirmation_links = app.get_confirmation_links(email_request); assert_eq!(confirmation_links.html, confirmation_links.text); } + +#[tokio::test] +async fn subscribe_fails_if_there_is_a_fatal_database_error() { + let app = TestApp::spawn().await; + + let body = "name=Alphonse&email=alphonse.paix%40outlook.com"; + + sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email") + .execute(&app.connection_pool) + .await + .unwrap(); + + let response = app.post_subscriptions(body.into()).await; + + assert_eq!(response.status().as_u16(), 500); +}