max / makenotwork
1 file changed,
+52 insertions,
-21 deletions
| @@ -9,7 +9,7 @@ pub use tokens::*; | |||
| 9 | 9 | ||
| 10 | 10 | use std::sync::Arc; | |
| 11 | 11 | ||
| 12 | - | use crate::error::{AppError, Result, ResultExt}; | |
| 12 | + | use crate::error::{AppError, Result}; | |
| 13 | 13 | ||
| 14 | 14 | /// Format an optional display name as a greeting suffix: " Alice" or "". | |
| 15 | 15 | fn greeting(name: Option<&str>) -> String { | |
| @@ -319,26 +319,57 @@ impl PostmarkTransport { | |||
| 319 | 319 | payload["Headers"] = serde_json::Value::Array(headers); | |
| 320 | 320 | } | |
| 321 | 321 | ||
| 322 | - | let response = self.http_client | |
| 323 | - | .post("https://api.postmarkapp.com/email") | |
| 324 | - | .header("X-Postmark-Server-Token", token) | |
| 325 | - | .header("Content-Type", "application/json") | |
| 326 | - | .json(&payload) | |
| 327 | - | .send() | |
| 328 | - | .await | |
| 329 | - | .context("postmark http request")?; | |
| 330 | - | ||
| 331 | - | if response.status().is_success() { | |
| 332 | - | tracing::info!(recipient = %to, subject = %subject, "email sent"); | |
| 333 | - | Ok(()) | |
| 334 | - | } else { | |
| 335 | - | let status = response.status(); | |
| 336 | - | let error_text = response.text().await.unwrap_or_default(); | |
| 337 | - | tracing::error!(status = %status, error = %error_text, "failed to send email"); | |
| 338 | - | Err(AppError::Internal(anyhow::anyhow!( | |
| 339 | - | "Failed to send email: {}", | |
| 340 | - | status | |
| 341 | - | ))) | |
| 322 | + | // Retry transient failures (network/timeout, 5xx, 429) with bounded | |
| 323 | + | // exponential backoff so a brief Postmark blip doesn't permanently drop | |
| 324 | + | // critical mail — password resets, purchase receipts, Fan+ credit codes | |
| 325 | + | // (Run 20 Resilience). Permanent 4xx (bad request, inactive recipient, | |
| 326 | + | // hard bounce) are NOT retried: retrying can't help and only delays the | |
| 327 | + | // caller. Bounded to EMAIL_SEND_MAX_ATTEMPTS so an awaited caller adds at | |
| 328 | + | // most ~1s on a failing send. | |
| 329 | + | const EMAIL_SEND_MAX_ATTEMPTS: u32 = 3; | |
| 330 | + | let mut attempt: u32 = 0; | |
| 331 | + | loop { | |
| 332 | + | attempt += 1; | |
| 333 | + | let send_result = self.http_client | |
| 334 | + | .post("https://api.postmarkapp.com/email") | |
| 335 | + | .header("X-Postmark-Server-Token", token) | |
| 336 | + | .header("Content-Type", "application/json") | |
| 337 | + | .json(&payload) | |
| 338 | + | .send() | |
| 339 | + | .await; | |
| 340 | + | ||
| 341 | + | match send_result { | |
| 342 | + | Ok(response) if response.status().is_success() => { | |
| 343 | + | tracing::info!(recipient = %to, subject = %subject, attempt, "email sent"); | |
| 344 | + | return Ok(()); | |
| 345 | + | } | |
| 346 | + | Ok(response) => { | |
| 347 | + | let status = response.status(); | |
| 348 | + | let transient = status.is_server_error() || status.as_u16() == 429; | |
| 349 | + | let error_text = response.text().await.unwrap_or_default(); | |
| 350 | + | if transient && attempt < EMAIL_SEND_MAX_ATTEMPTS { | |
| 351 | + | let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1)); | |
| 352 | + | tracing::warn!(status = %status, attempt, error = %error_text, "transient email send failure, retrying after backoff"); | |
| 353 | + | tokio::time::sleep(backoff).await; | |
| 354 | + | continue; | |
| 355 | + | } | |
| 356 | + | tracing::error!(status = %status, error = %error_text, attempt, "failed to send email"); | |
| 357 | + | return Err(AppError::Internal(anyhow::anyhow!( | |
| 358 | + | "Failed to send email: {}", | |
| 359 | + | status | |
| 360 | + | ))); | |
| 361 | + | } | |
| 362 | + | Err(e) => { | |
| 363 | + | // Network/timeout: always transient. | |
| 364 | + | if attempt < EMAIL_SEND_MAX_ATTEMPTS { | |
| 365 | + | let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1)); | |
| 366 | + | tracing::warn!(attempt, error = %e, "email send request error, retrying after backoff"); | |
| 367 | + | tokio::time::sleep(backoff).await; | |
| 368 | + | continue; | |
| 369 | + | } | |
| 370 | + | return Err(AppError::Internal(anyhow::anyhow!("postmark http request: {e}"))); | |
| 371 | + | } | |
| 372 | + | } | |
| 342 | 373 | } | |
| 343 | 374 | } | |
| 344 | 375 | } |