Error handling with thiserror and anyhow

This commit is contained in:
Alphonse Paix
2025-08-26 12:47:22 +02:00
parent 4ce25a8136
commit 9193f2020d
5 changed files with 133 additions and 49 deletions

8
Cargo.lock generated
View File

@@ -60,6 +60,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]] [[package]]
name = "arraydeque" name = "arraydeque"
version = "0.5.1" version = "0.5.1"
@@ -3377,6 +3383,7 @@ dependencies = [
name = "zero2prod" name = "zero2prod"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"axum", "axum",
"chrono", "chrono",
"claims", "claims",
@@ -3393,6 +3400,7 @@ dependencies = [
"serde-aux", "serde-aux",
"serde_json", "serde_json",
"sqlx", "sqlx",
"thiserror",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",

View File

@@ -11,6 +11,7 @@ path = "src/main.rs"
name = "zero2prod" name = "zero2prod"
[dependencies] [dependencies]
anyhow = "1.0.99"
axum = "0.8.4" axum = "0.8.4"
chrono = { version = "0.4.41", default-features = false, features = ["clock"] } chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
config = "0.15.14" config = "0.15.14"
@@ -20,6 +21,7 @@ secrecy = { version = "0.10.3", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde-aux = "4.7.0" serde-aux = "4.7.0"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] } 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"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.6.6", features = ["trace"] } tower-http = { version = "0.6.6", features = ["trace"] }
tracing = "0.1.41" tracing = "0.1.41"

View File

@@ -3,7 +3,13 @@ use crate::{
email_client::EmailClient, email_client::EmailClient,
startup::AppState, 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 chrono::Utc;
use rand::{Rng, distr::Alphanumeric}; use rand::{Rng, distr::Alphanumeric};
use serde::Deserialize; use serde::Deserialize;
@@ -18,6 +24,69 @@ fn generate_subscription_token() -> String {
.collect() .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( #[tracing::instrument(
name = "Adding a new subscriber", name = "Adding a new subscriber",
skip(connection_pool, email_client, base_url, form), skip(connection_pool, email_client, base_url, form),
@@ -33,39 +102,32 @@ pub async fn subscribe(
base_url, base_url,
}): State<AppState>, }): State<AppState>,
Form(form): Form<FormData>, Form(form): Form<FormData>,
) -> impl IntoResponse { ) -> Result<Response, SubscribeError> {
let Ok(mut transaction) = connection_pool.begin().await else { let mut transaction = connection_pool
return StatusCode::INTERNAL_SERVER_ERROR; .begin()
};
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)
.await .await
.is_err() .context("Failed to acquire a Postgres connection from the pool.")?;
{ let new_subscriber = form.try_into().map_err(SubscribeError::Validation)?;
return StatusCode::INTERNAL_SERVER_ERROR; let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
} .await
if let Err(e) = send_confirmation_email( .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, &email_client,
&new_subscriber, &new_subscriber,
&base_url, &base_url,
&subscription_token, &subscription_token,
) )
.await .await
{ .context("Failed to send a confirmation email.")?;
tracing::error!("Could not send confirmation email: {:?}", e); transaction
return StatusCode::INTERNAL_SERVER_ERROR; .commit()
} .await
if let Err(e) = transaction.commit().await { .context("Failed to commit the database transaction to store a new subscriber.")?;
tracing::error!("Failed to commit transaction: {:?}", e); Ok(StatusCode::OK.into_response())
return StatusCode::INTERNAL_SERVER_ERROR;
}
StatusCode::OK
} }
#[tracing::instrument( #[tracing::instrument(
@@ -87,10 +149,7 @@ pub async fn insert_subscriber(
new_subscriber.name.as_ref(), new_subscriber.name.as_ref(),
Utc::now() Utc::now()
); );
transaction.execute(query).await.map_err(|e| { transaction.execute(query).await?;
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(subscriber_id) Ok(subscriber_id)
} }
@@ -111,10 +170,7 @@ async fn store_token(
subscription_token, subscription_token,
subscriber_id, subscriber_id,
); );
transaction.execute(query).await.map_err(|e| { transaction.execute(query).await?;
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(()) Ok(())
} }

View File

@@ -62,21 +62,23 @@ pub fn app(connection_pool: PgPool, email_client: EmailClient, base_url: String)
.route("/subscriptions", post(subscribe)) .route("/subscriptions", post(subscribe))
.route("/subscriptions/confirm", get(confirm)) .route("/subscriptions/confirm", get(confirm))
.layer( .layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { TraceLayer::new_for_http()
let matched_path = request .make_span_with(|request: &Request<_>| {
.extensions() let matched_path = request
.get::<MatchedPath>() .extensions()
.map(MatchedPath::as_str); .get::<MatchedPath>()
let request_id = Uuid::new_v4().to_string(); .map(MatchedPath::as_str);
let request_id = Uuid::new_v4().to_string();
tracing::info_span!( tracing::info_span!(
"http_request", "http_request",
method = ?request.method(), method = ?request.method(),
matched_path, matched_path,
request_id, request_id,
some_other_field = tracing::field::Empty, some_other_field = tracing::field::Empty,
) )
}), })
.on_failure(()),
) )
.with_state(app_state) .with_state(app_state)
} }

View File

@@ -122,3 +122,19 @@ async fn subscribe_sends_a_confirmation_email_with_a_link() {
let confirmation_links = app.get_confirmation_links(email_request); let confirmation_links = app.get_confirmation_links(email_request);
assert_eq!(confirmation_links.html, confirmation_links.text); 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);
}