Skip to main content

max / makenotwork

email: retry transient Postmark failures with bounded backoff (audit Run 20 Phase 3) send_via_postmark was single-attempt: any 5xx, 429, or network/timeout permanently dropped the message. Critical mail — password resets, purchase receipts, Fan+ credit codes — was lost on a brief Postmark blip. Retry transient failures (network/timeout, 5xx, 429) with bounded exponential backoff (3 attempts, ~1s max added for an awaited caller). Permanent 4xx (bad request, inactive recipient, hard bounce) are not retried — retrying can't help and only delays the caller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-03 07:30 UTC
Commit: 0a4010bbcffdfd77c2dd0ad428d709792b87f8d5
Parent: f958136
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 }