use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::path::PathBuf; use std::time::{Duration, Instant}; use async_http_range_reader::AsyncHttpRangeReaderError; use async_zip::error::ZipError; use reqwest::Response; use serde::Deserialize; use tracing::warn; use crate::middleware::OfflineError; use crate::{FlatIndexError, html}; use uv_cache::Error as CacheError; use uv_distribution_filename::{WheelFilename, WheelFilenameError}; use uv_distribution_types::IndexUrl; use uv_normalize::PackageName; use uv_redacted::DisplaySafeUrl; /// RFC 9457 Problem Details for HTTP APIs /// /// This structure represents the standard format for machine-readable details /// of errors in HTTP response bodies as defined in RFC 9457. #[derive(Debug, Clone, Deserialize)] pub struct ProblemDetails { /// A URI reference that identifies the problem type. /// When dereferenced, it SHOULD provide human-readable documentation for the problem type. #[serde(rename = "type", default = "default_problem_type")] pub problem_type: String, /// A short, human-readable summary of the problem type. pub title: Option, /// The HTTP status code generated by the origin server for this occurrence of the problem. pub status: Option, /// A human-readable explanation specific to this occurrence of the problem. pub detail: Option, /// A URI reference that identifies the specific occurrence of the problem. pub instance: Option, } /// Default problem type URI as per RFC 9457 #[inline] fn default_problem_type() -> String { "about:blank".to_string() } impl ProblemDetails { /// The content type for RFC 9457 Problem Details responses. pub const CONTENT_TYPE: &str = "application/problem+json"; /// Try to parse a response body as RFC 9457 Problem Details. /// /// Returns `None` if parsing fails. The caller is responsible for checking /// that the content type is `application/problem+json` before calling this. pub fn try_from_response_body(body: &[u8]) -> Option { match serde_json::from_slice(body) { Ok(details) => Some(details), Err(err) => { warn!("Failed to parse problem details: {err}"); None } } } /// Try to extract RFC 9457 Problem Details from an HTTP response. /// /// Returns `None` if the content type is not `application/problem+json` /// or if parsing fails. Only consumes the response body if the content /// type matches. pub async fn try_from_response(response: Response) -> Option { let is_problem = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|ct| ct.to_str().ok()) .is_some_and(|ct| ct == Self::CONTENT_TYPE); if !is_problem { return None; } match response.bytes().await { Ok(bytes) => Self::try_from_response_body(&bytes), Err(err) => { warn!("Failed to read response body for problem details: {err}"); None } } } /// Get a human-readable description of the problem pub fn description(&self) -> Option { match self { Self { title: Some(title), detail: Some(detail), .. } => Some(format!("Server message: {title}, {detail}")), Self { title: Some(title), .. } => Some(format!("Server message: {title}")), Self { detail: Some(detail), .. } => Some(format!("Server message: {detail}")), Self { status: Some(status), .. } => Some(format!("HTTP error {status}")), _ => None, } } } #[derive(Debug)] pub struct Error { kind: Box, retries: u32, duration: Duration, } impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if self.retries > 0 { write!( f, "Request failed after {retries} {subject} in {duration:.1}s", retries = self.retries, subject = if self.retries > 1 { "retries" } else { "retry" }, duration = self.duration.as_secs_f32(), ) } else { Display::fmt(&self.kind, f) } } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { if self.retries > 0 { Some(&self.kind) } else { self.kind.source() } } } impl Error { /// Create a new [`Error`] with the given [`ErrorKind`] and number of retries. pub fn new(kind: ErrorKind, retries: u32, duration: Duration) -> Self { Self { kind: Box::new(kind), retries, duration, } } /// Return the number of retries that were attempted before this error was returned. pub fn retries(&self) -> u32 { self.retries } /// Return the time taken for network requests, including retries, backoff and jitter, /// before this error was returned. pub fn duration(&self) -> Duration { self.duration } /// Convert this error into an [`ErrorKind`]. pub fn into_kind(self) -> ErrorKind { *self.kind } /// Return the [`ErrorKind`] of this error. pub fn kind(&self) -> &ErrorKind { &self.kind } pub(crate) fn with_retries(mut self, retries: u32) -> Self { self.retries = retries; self } /// Create a new error from a JSON parsing error. pub(crate) fn from_json_err(err: serde_json::Error, url: DisplaySafeUrl) -> Self { ErrorKind::BadJson { source: err, url }.into() } /// Create a new error from an HTML parsing error. pub(crate) fn from_html_err(err: html::Error, url: DisplaySafeUrl) -> Self { ErrorKind::BadHtml { source: err, url }.into() } /// Create a new error from a `MessagePack` parsing error. pub(crate) fn from_msgpack_err(err: rmp_serde::decode::Error, url: DisplaySafeUrl) -> Self { ErrorKind::BadMessagePack { source: err, url }.into() } /// Create an [`Error`] from a [`reqwest_middleware::Error`]. pub(crate) fn from_reqwest_middleware( url: DisplaySafeUrl, err: reqwest_middleware::Error, start: Instant, ) -> Self { if let reqwest_middleware::Error::Middleware(ref underlying) = err { if let Some(offline_err) = underlying.downcast_ref::() { return ErrorKind::Offline(offline_err.url().to_string()).into(); } if let Some(reqwest_retry::RetryError::WithRetries { retries, .. }) = underlying.downcast_ref::() { let retries = *retries; return Self::new( ErrorKind::WrappedReqwestError(url, WrappedReqwestError::from(err)), retries, start.elapsed(), ); } } Self::from(ErrorKind::WrappedReqwestError( url, WrappedReqwestError::from(err), )) } /// Returns `true` if this error corresponds to an offline error. pub(crate) fn is_offline(&self) -> bool { matches!(&*self.kind, ErrorKind::Offline(_)) } /// Returns `true` if this error corresponds to an I/O "not found" error. pub(crate) fn is_file_not_exists(&self) -> bool { let ErrorKind::Io(err) = &*self.kind else { return false; }; matches!(err.kind(), std::io::ErrorKind::NotFound) } /// Returns `true` if the error is due to an SSL error. pub fn is_ssl(&self) -> bool { matches!(&*self.kind, ErrorKind::WrappedReqwestError(.., err) if err.is_ssl()) } /// Returns `true` if the error is due to the server not supporting HTTP range requests. pub fn is_http_range_requests_unsupported( &self, url: &DisplaySafeUrl, index: Option<&IndexUrl>, ) -> bool { match &*self.kind { // The server doesn't support range requests (as reported by the `HEAD` check). ErrorKind::AsyncHttpRangeReader( _, AsyncHttpRangeReaderError::HttpRangeRequestUnsupported, ) => { return true; } // The server doesn't support range requests (it doesn't return the necessary headers). ErrorKind::AsyncHttpRangeReader( _, AsyncHttpRangeReaderError::ContentLengthMissing | AsyncHttpRangeReaderError::ContentRangeMissing, ) => { return true; } // The server advertises range request support, but doesn't implement it correctly. ErrorKind::AsyncHttpRangeReader( _, AsyncHttpRangeReaderError::RangeMismatch { .. } | AsyncHttpRangeReaderError::ResponseTooShort { .. } | AsyncHttpRangeReaderError::ResponseTooLong { .. }, ) => { let url = if let Some(index) = index { index.url() } else { url }; warn!( "Invalid range request response from server that declares HTTP range request \ support, falling back to streaming: {url}" ); return true; } // The server returned a "Method Not Allowed" error, indicating it doesn't support // HEAD requests, so we can't check for range requests. ErrorKind::WrappedReqwestError(_, err) => { if let Some(status) = err.status() { // If the server doesn't support HEAD requests, we can't check for range // requests. if status == reqwest::StatusCode::METHOD_NOT_ALLOWED { return true; } // In some cases, registries return a 404 for HEAD requests when they're not // supported. In the worst case, we'll now just proceed to attempt to stream the // entire file, so it's fine to be somewhat lenient here. if status == reqwest::StatusCode::NOT_FOUND { return true; } // In some cases, registries (like PyPICloud) return a 403 for HEAD requests // when they're not supported. Again, it's better to be lenient here. if status == reqwest::StatusCode::FORBIDDEN { return true; } // In some cases, registries (like Alibaba Cloud) return a 400 for HEAD requests // when they're not supported. Again, it's better to be lenient here. if status == reqwest::StatusCode::BAD_REQUEST { return true; } } } // The server doesn't support range requests, but we only discovered this while // unzipping due to erroneous server behavior. ErrorKind::Zip(_, ZipError::UpstreamReadError(err)) => { if let Some(inner) = err.get_ref() && let Some(range_reader_error) = inner.downcast_ref::() { match range_reader_error { AsyncHttpRangeReaderError::HttpRangeRequestUnsupported | AsyncHttpRangeReaderError::ContentLengthMissing | AsyncHttpRangeReaderError::ContentRangeMissing => { return true; } AsyncHttpRangeReaderError::RangeMismatch { .. } | AsyncHttpRangeReaderError::ResponseTooShort { .. } | AsyncHttpRangeReaderError::ResponseTooLong { .. } => { let url = if let Some(index) = index { index.url() } else { url }; warn!( "Invalid range request response from server that declares HTTP \ range request support, falling back to streaming: {url}" ); return true; } _ => {} } } } _ => {} } false } /// Returns `true` if the error is due to the server not supporting HTTP streaming. Most /// commonly, this is due to serving ZIP files with features that are incompatible with /// streaming, like data descriptors. pub fn is_http_streaming_unsupported(&self) -> bool { matches!( &*self.kind, ErrorKind::Zip(_, ZipError::FeatureNotSupported(_)) ) } } impl From for Error { fn from(kind: ErrorKind) -> Self { Self { kind: Box::new(kind), retries: 0, duration: Duration::default(), } } } #[derive(Debug, thiserror::Error)] pub enum ErrorKind { #[error(transparent)] InvalidUrl(#[from] uv_distribution_types::ToUrlError), #[error(transparent)] Flat(#[from] FlatIndexError), #[error("Expected a file URL, but received: {0}")] NonFileUrl(DisplaySafeUrl), #[error("Expected an index URL, but received non-base URL: {0}")] CannotBeABase(DisplaySafeUrl), #[error("Failed to read metadata: `{0}`")] Metadata(String, #[source] uv_metadata::Error), #[error("{0} isn't available locally, but making network requests to registries was banned")] NoIndex(String), /// The package was not found in the registry. /// /// Make sure the package name is spelled correctly and that you've /// configured the right registry to fetch it from. #[error("Package `{0}` was not found in the registry")] RemotePackageNotFound(PackageName), /// The package was not found in the local (file-based) index. #[error("Package `{0}` was not found in the local index")] LocalPackageNotFound(PackageName), /// The root was not found in the local (file-based) index. #[error("Local index not found at: `{}`", _0.display())] LocalIndexNotFound(PathBuf), /// The metadata file could not be parsed. #[error("Couldn't parse metadata of {0} from {1}")] MetadataParseError( WheelFilename, String, #[source] Box, ), /// An error that happened while making a request or in a reqwest middleware. #[error("Failed to fetch: `{0}`")] WrappedReqwestError(DisplaySafeUrl, #[source] WrappedReqwestError), #[error("Received some unexpected JSON from {}", url)] BadJson { source: serde_json::Error, url: DisplaySafeUrl, }, #[error("Received some unexpected HTML from {}", url)] BadHtml { source: html::Error, url: DisplaySafeUrl, }, #[error("Received some unexpected MessagePack from {}", url)] BadMessagePack { source: rmp_serde::decode::Error, url: DisplaySafeUrl, }, #[error("Failed to read zip with range requests: `{0}`")] AsyncHttpRangeReader(DisplaySafeUrl, #[source] AsyncHttpRangeReaderError), #[error("{0} is not a valid wheel filename")] WheelFilename(#[source] WheelFilenameError), #[error("Package metadata name `{metadata}` does not match given name `{given}`")] NameMismatch { given: PackageName, metadata: PackageName, }, #[error("Failed to unzip wheel: {0}")] Zip(WheelFilename, #[source] ZipError), #[error("Failed to write to the client cache")] CacheWrite(#[source] std::io::Error), #[error("Failed to acquire lock on the client cache")] CacheLock(#[source] CacheError), #[error(transparent)] Io(std::io::Error), #[error("Cache deserialization failed")] Decode(#[source] rmp_serde::decode::Error), #[error("Cache serialization failed")] Encode(#[source] rmp_serde::encode::Error), #[error("Missing `Content-Type` header for {0}")] MissingContentType(DisplaySafeUrl), #[error("Invalid `Content-Type` header for {0}")] InvalidContentTypeHeader(DisplaySafeUrl, #[source] http::header::ToStrError), #[error("Unsupported `Content-Type` \"{1}\" for {0}. Expected JSON or HTML.")] UnsupportedMediaType(DisplaySafeUrl, String), #[error("Reading from cache archive failed: {0}")] ArchiveRead(String), #[error("Writing to cache archive failed: {0}")] ArchiveWrite(String), #[error( "Network connectivity is disabled, but the requested data wasn't found in the cache for: `{0}`" )] Offline(String), } impl ErrorKind { /// Create an [`ErrorKind`] from a [`reqwest::Error`]. pub(crate) fn from_reqwest(url: DisplaySafeUrl, error: reqwest::Error) -> Self { Self::WrappedReqwestError(url, WrappedReqwestError::from(error)) } /// Create an [`ErrorKind`] from a [`reqwest::Error`] with problem details. pub(crate) fn from_reqwest_with_problem_details( url: DisplaySafeUrl, error: reqwest::Error, problem_details: Option, ) -> Self { Self::WrappedReqwestError( url, WrappedReqwestError::with_problem_details(error.into(), problem_details), ) } } /// Handle the case with no internet by explicitly telling the user instead of showing an obscure /// DNS error. /// /// Wraps a [`reqwest_middleware::Error`] instead of an [`reqwest::Error`] since the actual reqwest /// error may be below some context in the [`anyhow::Error`]. #[derive(Debug)] pub struct WrappedReqwestError { error: reqwest_middleware::Error, problem_details: Option>, } impl WrappedReqwestError { /// Create a new `WrappedReqwestError` with optional problem details pub fn with_problem_details( error: reqwest_middleware::Error, problem_details: Option, ) -> Self { Self { error: Self::filter_retries_from_error(error), problem_details: problem_details.map(Box::new), } } /// Drop `RetryError::WithRetries` to avoid reporting the number of retries twice. /// /// We attach the number of errors outside by adding the retry counts from the retry middleware /// and from uv's outer retry loop for streaming bodies. Stripping the inner count from the /// error context avoids showing two numbers. fn filter_retries_from_error(error: reqwest_middleware::Error) -> reqwest_middleware::Error { match error { reqwest_middleware::Error::Middleware(error) => { match error.downcast::() { Ok( reqwest_retry::RetryError::WithRetries { err, .. } | reqwest_retry::RetryError::Error(err), ) => err, Err(error) => reqwest_middleware::Error::Middleware(error), } } error @ reqwest_middleware::Error::Reqwest(_) => error, } } /// Return the inner [`reqwest::Error`] from the error chain, if it exists. pub fn inner(&self) -> Option<&reqwest::Error> { match &self.error { reqwest_middleware::Error::Reqwest(err) => Some(err), reqwest_middleware::Error::Middleware(err) => err.chain().find_map(|err| { if let Some(err) = err.downcast_ref::() { Some(err) } else if let Some(reqwest_middleware::Error::Reqwest(err)) = err.downcast_ref::() { Some(err) } else { None } }), } } /// Check if the error chain contains a `reqwest` error that looks like this: /// * error sending request for url (...) /// * client error (Connect) /// * dns error: failed to lookup address information: Name or service not known /// * failed to lookup address information: Name or service not known fn is_likely_offline(&self) -> bool { if let Some(reqwest_err) = self.inner() { if !reqwest_err.is_connect() { return false; } // Self is "error sending request for url", the first source is "error trying to connect", // the second source is "dns error". We have to check for the string because hyper errors // are opaque. if std::error::Error::source(&reqwest_err) .and_then(|err| err.source()) .is_some_and(|err| err.to_string().starts_with("dns error: ")) { return true; } } false } /// Check if the error chain contains a `reqwest` error that looks like this: /// * invalid peer certificate: `UnknownIssuer` fn is_ssl(&self) -> bool { if let Some(reqwest_err) = self.inner() { if !reqwest_err.is_connect() { return false; } // Self is "error sending request for url", the first source is "error trying to connect", // the second source is "dns error". We have to check for the string because hyper errors // are opaque. if std::error::Error::source(&reqwest_err) .and_then(|err| err.source()) .is_some_and(|err| err.to_string().starts_with("invalid peer certificate: ")) { return true; } } false } } impl From for WrappedReqwestError { fn from(error: reqwest::Error) -> Self { Self { // No need to filter retries as this error does not have retries. error: error.into(), problem_details: None, } } } impl From for WrappedReqwestError { fn from(error: reqwest_middleware::Error) -> Self { Self { error: Self::filter_retries_from_error(error), problem_details: None, } } } impl Deref for WrappedReqwestError { type Target = reqwest_middleware::Error; fn deref(&self) -> &Self::Target { &self.error } } impl Display for WrappedReqwestError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { if self.is_likely_offline() { // Insert an extra hint, we'll show the wrapped error through `source` f.write_str("Could not connect, are you offline?") } else if let Some(problem_details) = &self.problem_details { // Show problem details if available match problem_details.description() { None => Display::fmt(&self.error, f), Some(message) => f.write_str(&message), } } else { // Show the wrapped error Display::fmt(&self.error, f) } } } impl std::error::Error for WrappedReqwestError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { if self.is_likely_offline() { // `Display` is inserting an extra message, so we need to show the wrapped error Some(&self.error) } else if self.problem_details.is_some() { // `Display` is showing problem details, so show the wrapped error as source Some(&self.error) } else { // `Display` is showing the wrapped error, continue with its source self.error.source() } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_problem_details_parsing() { let json = r#"{ "type": "https://example.com/probs/out-of-credit", "title": "You do not have enough credit.", "detail": "Your current balance is 30, but that costs 50.", "status": 403, "instance": "/account/12345/msgs/abc" }"#; let problem_details: ProblemDetails = serde_json::from_slice(json.as_bytes()).unwrap(); assert_eq!( problem_details.problem_type, "https://example.com/probs/out-of-credit" ); assert_eq!( problem_details.title, Some("You do not have enough credit.".to_string()) ); assert_eq!( problem_details.detail, Some("Your current balance is 30, but that costs 50.".to_string()) ); assert_eq!(problem_details.status, Some(403)); assert_eq!( problem_details.instance, Some("/account/12345/msgs/abc".to_string()) ); } #[test] fn test_problem_details_default_type() { let json = r#"{ "detail": "Something went wrong", "status": 500 }"#; let problem_details: ProblemDetails = serde_json::from_slice(json.as_bytes()).unwrap(); assert_eq!(problem_details.problem_type, "about:blank"); assert_eq!( problem_details.detail, Some("Something went wrong".to_string()) ); assert_eq!(problem_details.status, Some(500)); } #[test] fn test_problem_details_description() { let json = r#"{ "detail": "Detailed error message", "title": "Error Title", "status": 400 }"#; let problem_details: ProblemDetails = serde_json::from_slice(json.as_bytes()).unwrap(); assert_eq!( problem_details.description().unwrap(), "Server message: Error Title, Detailed error message" ); let json_no_detail = r#"{ "title": "Error Title", "status": 400 }"#; let problem_details: ProblemDetails = serde_json::from_slice(json_no_detail.as_bytes()).unwrap(); assert_eq!( problem_details.description().unwrap(), "Server message: Error Title" ); let json_minimal = r#"{ "status": 400 }"#; let problem_details: ProblemDetails = serde_json::from_slice(json_minimal.as_bytes()).unwrap(); assert_eq!(problem_details.description().unwrap(), "HTTP error 400"); } #[test] fn test_problem_details_with_extensions() { let json = r#"{ "type": "https://example.com/probs/out-of-credit", "title": "You do not have enough credit.", "detail": "Your current balance is 30, but that costs 50.", "status": 403, "balance": 30, "accounts": ["/account/12345", "/account/67890"] }"#; let problem_details: ProblemDetails = serde_json::from_slice(json.as_bytes()).unwrap(); assert_eq!( problem_details.title, Some("You do not have enough credit.".to_string()) ); } #[test] fn test_try_from_response_body() { let body = r#"{"type": "about:blank", "status": 400, "title": "Bad Request", "detail": "Missing required field `name`"}"#; let problem = ProblemDetails::try_from_response_body(body.as_bytes()) .expect("should parse problem details"); assert_eq!( problem.description().unwrap(), "Server message: Bad Request, Missing required field `name`" ); } }