Files
EventSnap/backend/src/error.rs
fabi f5c55d6f92 chore(backend): route wiring, error mapping, and a crossbeam-epoch bump
Cargo.lock moves crossbeam-epoch to 0.9.20, clearing RUSTSEC-2026-0204. Targeted rather
than a broad `cargo update` across 406 crates, which is not a change to make days before a
live event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:44:26 +02:00

296 lines
14 KiB
Rust

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<u64>),
/// 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<u64>),
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<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
Self::Internal(err)
}
}
impl From<sqlx::Error> 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")
);
}
}