Skip to content

Commit 0d29635

Browse files
committed
fix: improve error handling
1 parent 332cb3c commit 0d29635

File tree

8 files changed

+146
-68
lines changed

8 files changed

+146
-68
lines changed

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ serde-aux = "4.5"
2828
unicode-segmentation = "1.11"
2929
validator = { version = "0.17", features = ["derive"] }
3030
rand = { version = "0.8", features = ["std_rng"] }
31+
thiserror = "1.0.58"
32+
anyhow = "1.0.81"
3133

3234
[dependencies.reqwest]
3335
version = "0.12"

justfile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,12 @@ run:
4343
test:
4444
RUST_BACKTRACE=1 cargo test
4545

46-
## Run the tests with verbose output
47-
testv:
48-
RUST_BACKTRACE=1 cargo test -- --nocapture
46+
## Run the tests with verbose and colored output
47+
testv testset:
48+
RUST_LOG="sqlx=error,info" \
49+
TEST_LOG=true \
50+
RUST_BACKTRACE=1 \
51+
cargo test {{testset}} -- --nocapture | bunyan
4952

5053
## Format the code
5154
format:

src/routes/helpers.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
pub fn error_chain_fmt(
2+
e: &impl std::error::Error,
3+
f: &mut std::fmt::Formatter<'_>,
4+
) -> std::fmt::Result {
5+
writeln!(f, "{}\n", e)?;
6+
let mut current = e.source();
7+
while let Some(cause) = current {
8+
writeln!(f, "Caused by:\n\t{}", cause)?;
9+
current = cause.source();
10+
}
11+
Ok(())
12+
}

src/routes/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
mod health_check;
2+
mod helpers;
23
mod subscriptions;
34
mod subscriptions_confirm;
45

56
pub use health_check::*;
7+
pub use helpers::*;
68
pub use subscriptions::*;
79
pub use subscriptions_confirm::*;

src/routes/subscriptions.rs

Lines changed: 73 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
22
use crate::email_client::EmailClient;
3+
use crate::routes::error_chain_fmt;
34
use crate::startup::ApplicationBaseUrl;
4-
use actix_web::{web, HttpResponse};
5+
use actix_web::http::StatusCode;
6+
use actix_web::{web, HttpResponse, ResponseError};
7+
use anyhow::Context;
58
use chrono::Utc;
69
use rand::distributions::Alphanumeric;
710
use rand::{thread_rng, Rng};
11+
use sqlx;
812
use sqlx::{Executor, PgPool, Postgres, Transaction};
13+
use std::convert::{TryFrom, TryInto};
914
use uuid::Uuid;
1015

1116
#[derive(serde::Deserialize)]
@@ -24,6 +29,29 @@ impl TryFrom<FormData> for NewSubscriber {
2429
}
2530
}
2631

32+
#[derive(thiserror::Error)]
33+
pub enum SubscribeError {
34+
#[error("{0}")]
35+
ValidationError(String),
36+
#[error(transparent)]
37+
UnexpectedError(#[from] anyhow::Error),
38+
}
39+
40+
impl std::fmt::Debug for SubscribeError {
41+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42+
error_chain_fmt(self, f)
43+
}
44+
}
45+
46+
impl ResponseError for SubscribeError {
47+
fn status_code(&self) -> StatusCode {
48+
match self {
49+
SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
50+
SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
51+
}
52+
}
53+
}
54+
2755
/// Generate a random 25-character long case-sensitive alphanumeric token.
2856
fn generate_subscription_token() -> String {
2957
thread_rng()
@@ -47,41 +75,32 @@ pub async fn subscribe(
4775
pool: web::Data<PgPool>,
4876
email_client: web::Data<EmailClient>,
4977
base_url: web::Data<ApplicationBaseUrl>,
50-
) -> HttpResponse {
51-
let new_subscriber = match form.0.try_into() {
52-
Ok(subscriber) => subscriber,
53-
Err(_) => return HttpResponse::BadRequest().finish(),
54-
};
55-
let mut transaction = match pool.begin().await {
56-
Ok(transaction) => transaction,
57-
Err(_) => return HttpResponse::InternalServerError().finish(),
58-
};
59-
let subscriber_id = match insert_subscriber(&mut transaction, &new_subscriber).await {
60-
Ok(subscriber_id) => subscriber_id,
61-
Err(_) => return HttpResponse::InternalServerError().finish(),
62-
};
78+
) -> Result<HttpResponse, SubscribeError> {
79+
let new_subscriber = form.0.try_into().map_err(SubscribeError::ValidationError)?;
80+
let mut transaction = pool
81+
.begin()
82+
.await
83+
.context("Failed to acquire a Postgres connection from the pool")?;
84+
let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
85+
.await
86+
.context("Failed to insert new subscriber in the database.")?;
6387
let subscription_token = generate_subscription_token();
64-
if store_token(&mut transaction, subscriber_id, &subscription_token)
88+
store_token(&mut transaction, subscriber_id, &subscription_token)
6589
.await
66-
.is_err()
67-
{
68-
return HttpResponse::InternalServerError().finish();
69-
}
70-
if transaction.commit().await.is_err() {
71-
return HttpResponse::InternalServerError().finish();
72-
}
73-
if send_confirmation_email(
90+
.context("Failed to store the confirmation token for a new subscriber.")?;
91+
transaction
92+
.commit()
93+
.await
94+
.context("Failed to commit SQL transaction to store a new subscriber.")?;
95+
send_confirmation_email(
7496
&email_client,
7597
new_subscriber,
7698
&base_url.0,
7799
&subscription_token,
78100
)
79101
.await
80-
.is_err()
81-
{
82-
return HttpResponse::InternalServerError().finish();
83-
}
84-
HttpResponse::Ok().finish()
102+
.context("Failed to send a confirmation email.")?;
103+
Ok(HttpResponse::Ok().finish())
85104
}
86105

87106
#[tracing::instrument(
@@ -111,6 +130,29 @@ pub async fn send_confirmation_email(
111130
.await
112131
}
113132

133+
pub struct StoreTokenError(sqlx::Error);
134+
135+
impl std::error::Error for StoreTokenError {
136+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
137+
Some(&self.0)
138+
}
139+
}
140+
141+
impl std::fmt::Debug for StoreTokenError {
142+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143+
error_chain_fmt(self, f)
144+
}
145+
}
146+
147+
impl std::fmt::Display for StoreTokenError {
148+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149+
write!(
150+
f,
151+
"A database failure was encountered while trying to store a subscription token."
152+
)
153+
}
154+
}
155+
114156
#[tracing::instrument(
115157
name = "Store subscription token in the database",
116158
skip(subscription_token, transaction)
@@ -119,17 +161,14 @@ pub async fn store_token(
119161
transaction: &mut Transaction<'_, Postgres>,
120162
subscriber_id: Uuid,
121163
subscription_token: &str,
122-
) -> Result<(), sqlx::Error> {
164+
) -> Result<(), StoreTokenError> {
123165
let query = sqlx::query!(
124166
r#"INSERT INTO subscription_tokens (subscription_token, subscriber_id)
125167
VALUES ($1, $2)"#,
126168
subscription_token,
127169
subscriber_id
128170
);
129-
transaction.execute(query).await.map_err(|e| {
130-
tracing::error!("Failed to execute query: {:?}", e);
131-
e
132-
})?;
171+
transaction.execute(query).await.map_err(StoreTokenError)?;
133172
Ok(())
134173
}
135174

@@ -151,9 +190,6 @@ pub async fn insert_subscriber(
151190
new_subscriber.name.as_ref(),
152191
Utc::now()
153192
);
154-
transaction.execute(query).await.map_err(|e| {
155-
tracing::error!("Failed to execute query: {:?}", e);
156-
e
157-
})?;
193+
transaction.execute(query).await?;
158194
Ok(subscriber_id)
159195
}

src/routes/subscriptions_confirm.rs

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use actix_web::{web, HttpResponse};
1+
use crate::routes::error_chain_fmt;
2+
use actix_web::http::StatusCode;
3+
use actix_web::{web, HttpResponse, ResponseError};
4+
use anyhow::Context;
25
use sqlx::PgPool;
36
use uuid::Uuid;
47

@@ -7,23 +10,44 @@ pub struct Parameters {
710
subscription_token: String,
811
}
912

10-
#[tracing::instrument(name = "Confirm a pending subscriber", skip(parameters, pool))]
11-
pub async fn confirm(parameters: web::Query<Parameters>, pool: web::Data<PgPool>) -> HttpResponse {
12-
let id = match get_subscriber_id_by_token(&pool, &parameters.subscription_token).await {
13-
Ok(id) => id,
14-
Err(_) => return HttpResponse::InternalServerError().finish(),
15-
};
16-
match id {
17-
Some(subscriber_id) => {
18-
if confirm_subscriber(&pool, subscriber_id).await.is_err() {
19-
return HttpResponse::InternalServerError().finish();
20-
}
21-
HttpResponse::Ok().finish()
13+
#[derive(thiserror::Error)]
14+
pub enum ConfirmationError {
15+
#[error(transparent)]
16+
UnexpectedError(#[from] anyhow::Error),
17+
#[error("There is no subscriber associated with the provided token.")]
18+
UnknownToken,
19+
}
20+
21+
impl std::fmt::Debug for ConfirmationError {
22+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23+
error_chain_fmt(self, f)
24+
}
25+
}
26+
27+
impl ResponseError for ConfirmationError {
28+
fn status_code(&self) -> StatusCode {
29+
match self {
30+
Self::UnknownToken => StatusCode::UNAUTHORIZED,
31+
Self::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
2232
}
23-
None => return HttpResponse::Unauthorized().finish(),
2433
}
2534
}
2635

36+
#[tracing::instrument(name = "Confirm a pending subscriber", skip(parameters, pool))]
37+
pub async fn confirm(
38+
parameters: web::Query<Parameters>,
39+
pool: web::Data<PgPool>,
40+
) -> Result<HttpResponse, ConfirmationError> {
41+
let subscriber_id = get_subscriber_id_from_token(&pool, &parameters.subscription_token)
42+
.await
43+
.context("Failed to retrieve the subscriber id associated with the provided token.")?
44+
.ok_or(ConfirmationError::UnknownToken)?;
45+
confirm_subscriber(&pool, subscriber_id)
46+
.await
47+
.context("Failed to update the subscriber status to `confirmed`.")?;
48+
Ok(HttpResponse::Ok().finish())
49+
}
50+
2751
pub async fn confirm_subscriber(pool: &PgPool, subscriber_id: Uuid) -> Result<(), sqlx::Error> {
2852
sqlx::query!(
2953
r#"
@@ -34,19 +58,15 @@ pub async fn confirm_subscriber(pool: &PgPool, subscriber_id: Uuid) -> Result<()
3458
subscriber_id
3559
)
3660
.execute(pool)
37-
.await
38-
.map_err(|e| {
39-
tracing::error!("Failed to execute query: {:?}", e);
40-
e
41-
})?;
61+
.await?;
4262
Ok(())
4363
}
4464

4565
#[tracing::instrument(
46-
name = "Fetch a subscriber_id by subscription_token.",
66+
name = "Fetch a subscriber_id given a subscription_token.",
4767
skip(subscription_token, pool)
4868
)]
49-
pub async fn get_subscriber_id_by_token(
69+
pub async fn get_subscriber_id_from_token(
5070
pool: &PgPool,
5171
subscription_token: &str,
5272
) -> Result<Option<Uuid>, sqlx::Error> {
@@ -59,11 +79,6 @@ pub async fn get_subscriber_id_by_token(
5979
subscription_token
6080
)
6181
.fetch_optional(pool)
62-
.await
63-
.map_err(|e| {
64-
tracing::error!("Failed to execute query: {:?}", e);
65-
e
66-
})?;
67-
82+
.await?;
6883
Ok(result.map(|r| r.subscriber_id))
6984
}

tests/api/subscriptions.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async fn subscribe_fails_if_there_is_a_fatal_database_error() {
7070
let app = spawn_app().await;
7171
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
7272
// Sabotage the database
73-
sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email;",)
73+
sqlx::query!("ALTER TABLE subscription_tokens DROP COLUMN subscription_token;",)
7474
.execute(&app.db_pool)
7575
.await
7676
.unwrap();

0 commit comments

Comments
 (0)