use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; #[derive(Debug)] pub enum AppError { BadRequest(String), Unauthorized(String), Forbidden(String), /// Uploads are temporarily locked (event closed / gallery released). Distinct from /// `Forbidden` so the client can tell this REVERSIBLE 403 apart from a permanent one /// (banned user, quota): the queued blob is kept and retried if the host reopens, /// instead of being purged like a genuinely-terminal rejection. UploadsLocked(String), /// The gallery has been RELEASED — the keepsake was snapshotted, so a late upload could /// never appear in it. Mechanically this is still reversible (a host reopen clears /// `export_released_at` and bumps the epoch), which is why the blob must still be kept. /// /// Distinct from `UploadsLocked` because the two differ in *expectation*, and the client's /// retry policy has to differ with them. A closed event is a pause the host means to undo; /// a released gallery is the end of the event, and nobody reopens it. Under one shared code /// the queue kept auto-retrying a released event forever — re-streaming a multi-megabyte /// photo over cellular on every budget refill, for a request whose answer will not change, /// while telling the guest to tap a camera button that 403s. `gallery_released` lets the /// client park the item visibly and wait for an actual `event-opened` instead of guessing. GalleryReleased(String), /// The uploader is banned. A 403 like `Forbidden`, but tagged `user_banned` so the client /// keeps the queued blob instead of purging it. /// /// A ban is reversible — `unban_user` exists, and the host's own confirm copy promises the /// photos come back — but the client classified the generic `forbidden` code as permanent, /// deleted the blob from IndexedDB, and moved the row to `blocked`, which has no retry /// button. So an unban could restore everything except the photos that were in flight when /// the ban landed, and a ban issued by mistake destroyed them with no way back. UserBanned(String), NotFound(String), Conflict(String), /// Second field: optional retry-after seconds to include in the response. TooManyRequests(String, Option), /// Per-user storage quota exhausted. Distinct from `TooManyRequests` (rate limit) so /// the client can treat it as *terminal* (413, no retry) instead of backing off and /// retrying a permanently-failing upload forever. QuotaExceeded(String), /// The server is temporarily unable to serve this request — currently only pool /// saturation. Distinct from `Internal` because it is TRANSIENT and the client should be /// told so: a 500 reads as "this request is broken", while a 503 + Retry-After reads as /// "come back shortly", which is what the upload queue's retry classifier needs to make /// the right call. Second field: optional retry-after seconds. ServiceUnavailable(String, Option), Internal(anyhow::Error), } impl AppError { fn status_and_code(&self) -> (StatusCode, &str) { match self { Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), Self::UploadsLocked(_) => (StatusCode::FORBIDDEN, "uploads_locked"), Self::GalleryReleased(_) => (StatusCode::FORBIDDEN, "gallery_released"), Self::UserBanned(_) => (StatusCode::FORBIDDEN, "user_banned"), Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"), Self::ServiceUnavailable(..) => { (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable") } Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), } } fn message(&self) -> String { match self { Self::BadRequest(msg) | Self::Unauthorized(msg) | Self::Forbidden(msg) | Self::UploadsLocked(msg) | Self::GalleryReleased(msg) | Self::UserBanned(msg) | Self::NotFound(msg) | Self::Conflict(msg) => msg.clone(), Self::TooManyRequests(msg, _) => msg.clone(), Self::ServiceUnavailable(msg, _) => msg.clone(), Self::QuotaExceeded(msg) => msg.clone(), Self::Internal(err) => { tracing::error!("internal error: {err:#}"); "Ein interner Fehler ist aufgetreten.".to_string() } } } } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, code) = self.status_and_code(); // BOTH retry-carrying variants must be matched here. `message()` would fail to // compile on a missing arm; this one would not — it would silently drop the header and // the `retry_after_secs` body field, which is exactly the sort of omission that only // shows up under the load the 503 exists for. let retry_after_secs = match &self { Self::TooManyRequests(_, secs) | Self::ServiceUnavailable(_, secs) => *secs, _ => None, }; let message = self.message(); // Log every 4xx. Until now they were invisible at ANY log level: tower_http's // `ServerErrorsAsFailures` classifier counts a 4xx as a *success*, so it goes to // `DefaultOnResponse` at DEBUG, and production runs at `info`. The consequence is that a // misconfigured limit leaves no trace at all — if guests spend the evening hitting 429s // on `upload_rate_per_hour`, or 413s on the storage quota, `docker compose logs` after // the event contains nothing about it and the cause is unknowable. // // WARN rather than INFO because every variant here is a request that did not do what // the guest asked. 5xx is excluded: `Internal` already logs with its full source chain // in `message()` above, and the pool-exhaustion 503 logs at construction — logging again // here would double every server-side failure. // // No request context is available: `into_response` receives only the error, so there is // no path, method or user id to attach. Status + code + message is what can honestly be // reported from here, and it is enough to see the SHAPE of a bad evening. Raising // `tower_http` to DEBUG instead was considered and rejected — see the note in main.rs. // // `detail = ?message`, NOT `%message`. Two reasons, both learned the hard way: // // * `message` is tracing's own reserved field for an event's format literal, so `%message` // printed unlabelled and would collide under a JSON layer. // * Debug formatting QUOTES AND ESCAPES the string, and several 4xx messages interpolate // attacker-chosen text — the guest's name in `Der Name "X" ist bereits vergeben.`, and // multipart/parse errors that echo their input. With Display formatting, a value // carrying a newline plus a plausible log prefix lets two unauthenticated requests // forge lines in the only forensic record an unattended event has. // `validate_display_name` now rejects control characters, so the name route is closed // at the source as well — but that is ONE input, and this line formats every 4xx // message in the app. Escaping here is what makes the guarantee general; do not // "simplify" it to `%message` on the grounds that names are already validated. // // 401 and 404 are logged at DEBUG rather than WARN. They carry no operator signal (an // expired session, a mistyped URL) and they are the cheapest lines for a scanner to // generate — at ~260 bytes each against the 30 MB the json-file driver retains // (docker-compose.yml), a sustained flood could otherwise roll the whole window in // minutes and destroy the post-event forensics this logging exists to provide. if status.is_client_error() { let noisy = status == StatusCode::UNAUTHORIZED || status == StatusCode::NOT_FOUND; if noisy { tracing::debug!(status = status.as_u16(), code, detail = ?message, "request rejected"); } else { tracing::warn!(status = status.as_u16(), code, detail = ?message, "request rejected"); } } let mut body = serde_json::json!({ "error": code, "message": message, "status": status.as_u16(), }); if let Some(secs) = retry_after_secs { body["retry_after_secs"] = secs.into(); } let mut resp = (status, axum::Json(body)).into_response(); if let Some(secs) = retry_after_secs && let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) { resp.headers_mut() .insert(axum::http::header::RETRY_AFTER, val); } resp } } impl From for AppError { fn from(err: anyhow::Error) -> Self { Self::Internal(err) } } impl From for AppError { fn from(err: sqlx::Error) -> Self { match err { // Pool saturation is load, not a bug. Reporting it as a 500 was actively harmful: // the frontend's upload-queue classifier treats 5xx as transient and retries, so // the retries piled straight back into the saturated pool with no Retry-After to // pace them. A 503 says the same thing honestly and carries the backoff. // // `PoolClosed` stays `Internal` — it only happens during shutdown, where a 503 // would invite a retry against a server that is going away. sqlx::Error::PoolTimedOut => { tracing::warn!("database pool exhausted; shedding a request with 503"); Self::ServiceUnavailable( "Server ist gerade ausgelastet. Bitte versuche es in ein paar Sekunden erneut." .into(), Some(POOL_TIMEOUT_RETRY_AFTER_SECS), ) } other => Self::Internal(other.into()), } } } /// Retry-After for a shed request. Short: pool saturation clears in seconds once the queue /// drains, and a long value would make a brief spike feel like an outage. const POOL_TIMEOUT_RETRY_AFTER_SECS: u64 = 3; #[cfg(test)] mod tests { use super::*; /// `into_response` extracts `retry_after_secs` by MATCHING ON VARIANTS, so unlike /// `message()` a missing arm is not a compile error — it silently drops the header. Pin the /// behaviour for both retry-carrying variants. #[test] fn both_retry_carrying_variants_emit_retry_after() { for err in [ AppError::TooManyRequests("slow down".into(), Some(42)), AppError::ServiceUnavailable("busy".into(), Some(3)), ] { let expected = match &err { AppError::TooManyRequests(_, Some(s)) | AppError::ServiceUnavailable(_, Some(s)) => s.to_string(), _ => unreachable!(), }; let resp = err.into_response(); assert_eq!( resp.headers() .get(axum::http::header::RETRY_AFTER) .and_then(|v| v.to_str().ok()), Some(expected.as_str()), "a shed/throttled client must be told when to come back" ); } } /// 4xx must be logged and 5xx must not be logged HERE — `Internal` logs its source chain in /// `message()` and the pool-exhaustion 503 logs at construction, so a second line in /// `into_response` would double every server-side failure in the post-event logs. /// /// The guard is `status.is_client_error()`, so this pins the classification rather than the /// logging itself (which needs a subscriber to observe). #[test] fn only_client_errors_are_in_the_logged_band() { for err in [ AppError::BadRequest("x".into()), AppError::Unauthorized("x".into()), AppError::Forbidden("x".into()), AppError::UploadsLocked("x".into()), AppError::NotFound("x".into()), AppError::Conflict("x".into()), AppError::TooManyRequests("x".into(), Some(1)), AppError::QuotaExceeded("x".into()), ] { let (status, _) = err.status_and_code(); assert!( status.is_client_error(), "{status} should be in the 4xx band this logs" ); } for err in [ AppError::ServiceUnavailable("x".into(), Some(3)), AppError::Internal(anyhow::anyhow!("boom")), ] { let (status, _) = err.status_and_code(); assert!( !status.is_client_error(), "{status} logs elsewhere; logging it here would double it" ); } } /// Pool saturation is load, not a bug. A 500 makes the frontend's retry classifier pile /// straight back into the saturated pool with no backoff to pace it. #[test] fn pool_exhaustion_sheds_with_503_but_shutdown_does_not() { let shed: AppError = sqlx::Error::PoolTimedOut.into(); assert_eq!( shed.status_and_code(), (StatusCode::SERVICE_UNAVAILABLE, "service_unavailable") ); // PoolClosed only happens during shutdown; a 503 there would invite a retry against a // server that is going away. let closing: AppError = sqlx::Error::PoolClosed.into(); assert_eq!( closing.status_and_code(), (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") ); // Everything else must keep its existing mapping. let missing: AppError = sqlx::Error::RowNotFound.into(); assert_eq!( missing.status_and_code(), (StatusCode::INTERNAL_SERVER_ERROR, "internal_error") ); } }