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>
296 lines
14 KiB
Rust
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")
|
|
);
|
|
}
|
|
}
|