Skip to main content

pacenotes_server/
error.rs

1/// A request failure, answered with a status and a `shared::ApiError` body.
2pub struct AppError {
3    pub status: axum::http::StatusCode,
4    pub code: &'static str,
5    pub message: String,
6    /// Seconds for a `Retry-After` header (429).
7    pub retry_after: Option<u64>,
8}
9
10impl AppError {
11    pub fn unauthorized() -> Self {
12        Self {
13            status: axum::http::StatusCode::UNAUTHORIZED,
14            code: "unauthorized",
15            message: "A valid access key is required for this package.".to_string(),
16            retry_after: None,
17        }
18    }
19
20    /// `/whoami` without a live key: the code of `unauthorized`, worded for the key itself.
21    pub fn invalid_key() -> Self {
22        Self {
23            status: axum::http::StatusCode::UNAUTHORIZED,
24            code: "unauthorized",
25            message: "This access key is not valid, or has expired.".to_string(),
26            retry_after: None,
27        }
28    }
29
30    pub fn not_found() -> Self {
31        Self {
32            status: axum::http::StatusCode::NOT_FOUND,
33            code: "not_found",
34            message: "No such package.".to_string(),
35            retry_after: None,
36        }
37    }
38
39    /// The file on disk differs from what the manifest promises, almost always
40    /// a package being replaced mid-publish, so the client should retry.
41    pub fn unavailable() -> Self {
42        Self {
43            status: axum::http::StatusCode::SERVICE_UNAVAILABLE,
44            code: "unavailable",
45            message: "This package is being updated on the server. Try again shortly.".to_string(),
46            retry_after: None,
47        }
48    }
49
50    /// Too many conflict reports from this client, or overall.
51    pub fn rate_limited(retry_after: u64) -> Self {
52        Self {
53            status: axum::http::StatusCode::TOO_MANY_REQUESTS,
54            code: "rate_limited",
55            message: "Too many reports. Try again later.".to_string(),
56            retry_after: Some(retry_after),
57        }
58    }
59
60    /// A report that failed validation, with a reason that never echoes the request.
61    pub fn invalid_report(reason: &'static str) -> Self {
62        Self {
63            status: axum::http::StatusCode::BAD_REQUEST,
64            code: "invalid_report",
65            message: reason.to_string(),
66            retry_after: None,
67        }
68    }
69
70    /// Logs the cause and answers 500 without it.
71    pub fn internal(context: &str, error: impl std::fmt::Display) -> Self {
72        tracing::error!(error = %error, "{context}");
73        Self {
74            status: axum::http::StatusCode::INTERNAL_SERVER_ERROR,
75            code: "internal",
76            message: "Internal server error.".to_string(),
77            retry_after: None,
78        }
79    }
80}
81
82impl axum::response::IntoResponse for AppError {
83    fn into_response(self) -> axum::response::Response {
84        let body = pacenotes_shared::ApiError {
85            code: self.code.to_string(),
86            message: self.message,
87        };
88        let mut response = (self.status, axum::Json(body)).into_response();
89        if let Some(seconds) = self.retry_after {
90            if let Ok(value) = axum::http::HeaderValue::from_str(&seconds.to_string()) {
91                response
92                    .headers_mut()
93                    .insert(axum::http::header::RETRY_AFTER, value);
94            }
95        }
96        // RFC 9110: a 401 names the scheme it wants.
97        if self.status == axum::http::StatusCode::UNAUTHORIZED {
98            response.headers_mut().insert(
99                axum::http::header::WWW_AUTHENTICATE,
100                axum::http::HeaderValue::from_static("Bearer"),
101            );
102        }
103        response
104    }
105}