Spaces:
Paused
Paused
File size: 9,968 Bytes
62f0fc0 0fcb5c2 a38005e 0fcb5c2 a38005e 0fcb5c2 937da1f 0fcb5c2 8b1a6cb a38005e 0fcb5c2 62f0fc0 937da1f 0fcb5c2 a38005e 5f0e2ae 0fcb5c2 7ab1c55 a38005e 0fcb5c2 dd7a3f0 5f0e2ae dd7a3f0 5f0e2ae dd7a3f0 5f0e2ae 62f0fc0 937da1f dd7a3f0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("invalid id type: {0}")]
InvalidIdType(String),
#[error("id not found: {0}")]
IdNotFound(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden: {0}")]
Forbidden(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("API error: {0}")]
Api(#[source] reqwest::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("image error: {0}")]
Image(#[from] image::ImageError),
#[error("database error: {0}")]
Db(#[from] sea_orm::DbErr),
#[error("database error: {0}")]
DbError(String),
#[error("{0}")]
Other(String),
}
impl AppError {
/// Recover a typed `AppError` from moka's `Arc`-wrapped cache error.
///
/// `moka`'s `try_get_with` returns the init closure's error as
/// `Arc<AppError>`, and on concurrent calls for the same key it hands
/// every waiter a *clone* of that `Arc`. Calling `Arc::try_unwrap` then
/// fails for all waiters (strong count > 1), so naively collapsing the
/// `Err` arm to [`AppError::Other`] turns a client-facing 404/400 into a
/// spurious 500 whenever requests race. Reconstruct the client-facing
/// variants by reference instead so the HTTP status is preserved; only
/// genuinely-internal variants (which are 500 regardless) collapse to
/// [`AppError::Other`].
pub fn from_cached(arc: Arc<AppError>) -> AppError {
// Sole owner (no concurrent waiters): move the exact error out,
// preserving full detail for internal variants too.
match Arc::try_unwrap(arc) {
Ok(err) => err,
Err(arc) => match arc.as_ref() {
AppError::InvalidIdType(msg) => AppError::InvalidIdType(msg.clone()),
AppError::IdNotFound(msg) => AppError::IdNotFound(msg.clone()),
AppError::BadRequest(msg) => AppError::BadRequest(msg.clone()),
AppError::Forbidden(msg) => AppError::Forbidden(msg.clone()),
AppError::Unauthorized => AppError::Unauthorized,
other => AppError::Other(other.to_string()),
},
}
}
}
/// Strip the query string from a reqwest error's embedded URL.
///
/// reqwest stores the full request URL — including the query string — inside its
/// errors and renders it in both `Display` and `Debug` (`… for url (…)`). Our
/// upstream clients pass API keys as query parameters (`?api_key=…` / `?apikey=…`
/// for TMDB, OMDb, MDBList, fanart), so an upstream 404/timeout/decode error
/// would otherwise leak the key into the logs. Dropping the query keeps the
/// scheme/host/path — useful for debugging and never secret — while removing the
/// credential. Trakt sends its key as a header, which reqwest never embeds, so it
/// is unaffected.
///
/// Applied at every boundary where a `reqwest::Error` becomes loggable: the
/// `From` impl below (covers every `?`/`error_for_status()?` site) and the
/// direct construction in `services::retry`.
pub(crate) fn redact_url_secrets(mut err: reqwest::Error) -> reqwest::Error {
if let Some(url) = err.url_mut() {
url.set_query(None);
}
err
}
impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
AppError::Api(redact_url_secrets(err))
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::InvalidIdType(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::IdNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".into()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into()),
};
if status.is_server_error() {
tracing::error!(%status, error = %self);
} else {
tracing::info!(%status, error = %self);
}
(status, axum::Json(json!({ "error": message }))).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use http_body_util::BodyExt;
fn status_of(err: AppError) -> StatusCode {
err.into_response().status()
}
async fn body_of(err: AppError) -> serde_json::Value {
let resp = err.into_response();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
#[test]
fn invalid_id_type_is_400() {
assert_eq!(
status_of(AppError::InvalidIdType("x".into())),
StatusCode::BAD_REQUEST
);
}
#[test]
fn id_not_found_is_404() {
assert_eq!(
status_of(AppError::IdNotFound("x".into())),
StatusCode::NOT_FOUND
);
}
#[test]
fn unauthorized_is_401() {
assert_eq!(status_of(AppError::Unauthorized), StatusCode::UNAUTHORIZED);
}
#[test]
fn forbidden_is_403() {
assert_eq!(
status_of(AppError::Forbidden("x".into())),
StatusCode::FORBIDDEN
);
}
#[test]
fn bad_request_is_400() {
assert_eq!(
status_of(AppError::BadRequest("x".into())),
StatusCode::BAD_REQUEST
);
}
#[test]
fn db_error_is_500() {
assert_eq!(
status_of(AppError::DbError("x".into())),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn other_is_500() {
assert_eq!(
status_of(AppError::Other("x".into())),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn internal_errors_redact_details() {
let body = body_of(AppError::DbError("connection refused to 10.0.0.5:5432".into())).await;
assert_eq!(body["error"], "Internal server error");
}
#[tokio::test]
async fn internal_error_other_redacts_details() {
let body = body_of(AppError::Other("secret internal info".into())).await;
assert_eq!(body["error"], "Internal server error");
}
#[tokio::test]
async fn client_errors_preserve_message() {
let body = body_of(AppError::BadRequest("missing field".into())).await;
assert_eq!(body["error"], "missing field");
}
#[test]
fn from_cached_sole_owner_preserves_variant() {
let arc = Arc::new(AppError::IdNotFound("no logo available".into()));
assert_eq!(
status_of(AppError::from_cached(arc)),
StatusCode::NOT_FOUND
);
}
#[test]
fn from_cached_shared_arc_preserves_client_status() {
// The concurrent-waiter case: moka hands each waiter a clone of the
// same Arc, so strong_count > 1 and Arc::try_unwrap fails. This must
// still yield a 404, not a spurious 500.
let arc = Arc::new(AppError::IdNotFound("no logo available".into()));
let _waiter = arc.clone();
assert_eq!(
status_of(AppError::from_cached(arc)),
StatusCode::NOT_FOUND
);
}
#[test]
fn from_cached_shared_arc_collapses_internal_to_500() {
let arc = Arc::new(AppError::DbError("connection refused".into()));
let _waiter = arc.clone();
assert_eq!(
status_of(AppError::from_cached(arc)),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn api_error_strips_api_key_from_url() {
// Regression for the MDBList `?apikey=…` key leaking into logs: reqwest
// embeds the full request URL (query string included) in its error, and
// we format `AppError::Api` straight into the logs. Converting through
// `From<reqwest::Error>` must drop the query so the key can't escape.
//
// A local server returns 404 so `error_for_status()` produces exactly the
// error shape seen in production (`HTTP status … for url (…?apikey=…)`),
// with no external network so the test stays deterministic.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// An empty router 404s every path — all we need to drive `error_for_status`.
tokio::spawn(async move {
let _ = axum::serve(listener, axum::Router::new()).await;
});
let url = format!("http://{addr}/imdb/show/tt33306109?apikey=SUPERSECRETKEY");
let raw = reqwest::Client::new()
.get(&url)
.send()
.await
.expect("request reaches the local server")
.error_for_status()
.expect_err("server returns 404");
// Sanity-check that reqwest really leaks the key by default; otherwise this
// test could pass vacuously and never catch a regression.
let unsanitized = format!("{raw}");
assert!(
unsanitized.contains("SUPERSECRETKEY"),
"precondition: reqwest is expected to embed the key in its error, got: {unsanitized}"
);
let app: AppError = raw.into();
let display = format!("{app}");
let debug = format!("{app:?}");
for rendered in [&display, &debug] {
assert!(!rendered.contains("SUPERSECRETKEY"), "api key leaked: {rendered}");
assert!(!rendered.contains("apikey"), "query string leaked: {rendered}");
}
// The non-secret host/path is preserved so failures are still debuggable.
assert!(display.contains(&addr.to_string()), "host should be kept: {display}");
}
}
|