Error handling with thiserror and anyhow
This commit is contained in:
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user