The backend had never been run through rustfmt. Doing it in one mechanical pass (134 files) so no future functional diff is buried under formatting churn, then gating `cargo fmt --check` in checks.yml so it stays clean. Formatting only — no logic, SQL, or behaviour changed. Verified after the reformat: cargo test 56 passed, clippy --all-targets -D warnings clean, cargo fmt --check clean. This is the deferred cleanup noted when CI's Format step was first left out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 lines
3.5 KiB
Rust
99 lines
3.5 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),
|
|
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),
|
|
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::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::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::NotFound(msg)
|
|
| Self::Conflict(msg) => msg.clone(),
|
|
Self::TooManyRequests(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();
|
|
let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self {
|
|
Some(*secs)
|
|
} else {
|
|
None
|
|
};
|
|
let message = self.message();
|
|
|
|
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 {
|
|
Self::Internal(err.into())
|
|
}
|
|
}
|