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",
]
[[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",

View File

@@ -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"

View File

@@ -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<AppState>,
Form(form): Form<FormData>,
) -> 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<Response, SubscribeError> {
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(())
}

View File

@@ -62,7 +62,8 @@ 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<_>| {
TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
@@ -76,7 +77,8 @@ pub fn app(connection_pool: PgPool, email_client: EmailClient, base_url: String)
request_id,
some_other_field = tracing::field::Empty,
)
}),
})
.on_failure(()),
)
.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);
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);
}