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;
#[derive(Debug, Clone, Deserialize)]
pub struct ProblemDetails {
#[serde(rename = "type", default = "default_problem_type")]
pub problem_type: String,
pub title: Option<String>,
pub status: Option<u16>,
pub detail: Option<String>,
pub instance: Option<String>,
}
#[inline]
fn default_problem_type() -> String {
"about:blank".to_string()
}
impl ProblemDetails {
pub const CONTENT_TYPE: &str = "application/problem+json";
pub fn try_from_response_body(body: &[u8]) -> Option<Self> {
match serde_json::from_slice(body) {
Ok(details) => Some(details),
Err(err) => {
warn!("Failed to parse problem details: {err}");
None
}
}
}
pub async fn try_from_response(response: Response) -> Option<Self> {
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
}
}
}
pub fn description(&self) -> Option<String> {
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<ErrorKind>,
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 {
pub fn new(kind: ErrorKind, retries: u32, duration: Duration) -> Self {
Self {
kind: Box::new(kind),
retries,
duration,
}
}
pub fn retries(&self) -> u32 {
self.retries
}
pub fn duration(&self) -> Duration {
self.duration
}
pub fn into_kind(self) -> ErrorKind {
*self.kind
}
pub fn kind(&self) -> &ErrorKind {
&self.kind
}
pub(crate) fn with_retries(mut self, retries: u32) -> Self {
self.retries = retries;
self
}
pub(crate) fn from_json_err(err: serde_json::Error, url: DisplaySafeUrl) -> Self {
ErrorKind::BadJson { source: err, url }.into()
}
pub(crate) fn from_html_err(err: html::Error, url: DisplaySafeUrl) -> Self {
ErrorKind::BadHtml { source: err, url }.into()
}
pub(crate) fn from_msgpack_err(err: rmp_serde::decode::Error, url: DisplaySafeUrl) -> Self {
ErrorKind::BadMessagePack { source: err, url }.into()
}
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::<OfflineError>() {
return ErrorKind::Offline(offline_err.url().to_string()).into();
}
if let Some(reqwest_retry::RetryError::WithRetries { retries, .. }) =
underlying.downcast_ref::<reqwest_retry::RetryError>()
{
let retries = *retries;
return Self::new(
ErrorKind::WrappedReqwestError(url, WrappedReqwestError::from(err)),
retries,
start.elapsed(),
);
}
}
Self::from(ErrorKind::WrappedReqwestError(
url,
WrappedReqwestError::from(err),
))
}
pub(crate) fn is_offline(&self) -> bool {
matches!(&*self.kind, ErrorKind::Offline(_))
}
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)
}
pub fn is_ssl(&self) -> bool {
matches!(&*self.kind, ErrorKind::WrappedReqwestError(.., err) if err.is_ssl())
}
pub fn is_http_range_requests_unsupported(
&self,
url: &DisplaySafeUrl,
index: Option<&IndexUrl>,
) -> bool {
match &*self.kind {
ErrorKind::AsyncHttpRangeReader(
_,
AsyncHttpRangeReaderError::HttpRangeRequestUnsupported,
) => {
return true;
}
ErrorKind::AsyncHttpRangeReader(
_,
AsyncHttpRangeReaderError::ContentLengthMissing
| AsyncHttpRangeReaderError::ContentRangeMissing,
) => {
return true;
}
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;
}
ErrorKind::WrappedReqwestError(_, err) => {
if let Some(status) = err.status() {
if status == reqwest::StatusCode::METHOD_NOT_ALLOWED {
return true;
}
if status == reqwest::StatusCode::NOT_FOUND {
return true;
}
if status == reqwest::StatusCode::FORBIDDEN {
return true;
}
if status == reqwest::StatusCode::BAD_REQUEST {
return true;
}
}
}
ErrorKind::Zip(_, ZipError::UpstreamReadError(err)) => {
if let Some(inner) = err.get_ref()
&& let Some(range_reader_error) =
inner.downcast_ref::<AsyncHttpRangeReaderError>()
{
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
}
pub fn is_http_streaming_unsupported(&self) -> bool {
matches!(
&*self.kind,
ErrorKind::Zip(_, ZipError::FeatureNotSupported(_))
)
}
}
impl From<ErrorKind> 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),
#[error("Package `{0}` was not found in the registry")]
RemotePackageNotFound(PackageName),
#[error("Package `{0}` was not found in the local index")]
LocalPackageNotFound(PackageName),
#[error("Local index not found at: `{}`", _0.display())]
LocalIndexNotFound(PathBuf),
#[error("Couldn't parse metadata of {0} from {1}")]
MetadataParseError(
WheelFilename,
String,
#[source] Box<uv_pypi_types::MetadataError>,
),
#[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 {
pub(crate) fn from_reqwest(url: DisplaySafeUrl, error: reqwest::Error) -> Self {
Self::WrappedReqwestError(url, WrappedReqwestError::from(error))
}
pub(crate) fn from_reqwest_with_problem_details(
url: DisplaySafeUrl,
error: reqwest::Error,
problem_details: Option<ProblemDetails>,
) -> Self {
Self::WrappedReqwestError(
url,
WrappedReqwestError::with_problem_details(error.into(), problem_details),
)
}
}
#[derive(Debug)]
pub struct WrappedReqwestError {
error: reqwest_middleware::Error,
problem_details: Option<Box<ProblemDetails>>,
}
impl WrappedReqwestError {
pub fn with_problem_details(
error: reqwest_middleware::Error,
problem_details: Option<ProblemDetails>,
) -> Self {
Self {
error: Self::filter_retries_from_error(error),
problem_details: problem_details.map(Box::new),
}
}
fn filter_retries_from_error(error: reqwest_middleware::Error) -> reqwest_middleware::Error {
match error {
reqwest_middleware::Error::Middleware(error) => {
match error.downcast::<reqwest_retry::RetryError>() {
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,
}
}
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::<reqwest::Error>() {
Some(err)
} else if let Some(reqwest_middleware::Error::Reqwest(err)) =
err.downcast_ref::<reqwest_middleware::Error>()
{
Some(err)
} else {
None
}
}),
}
}
fn is_likely_offline(&self) -> bool {
if let Some(reqwest_err) = self.inner() {
if !reqwest_err.is_connect() {
return false;
}
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
}
fn is_ssl(&self) -> bool {
if let Some(reqwest_err) = self.inner() {
if !reqwest_err.is_connect() {
return false;
}
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<reqwest::Error> for WrappedReqwestError {
fn from(error: reqwest::Error) -> Self {
Self {
error: error.into(),
problem_details: None,
}
}
}
impl From<reqwest_middleware::Error> 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() {
f.write_str("Could not connect, are you offline?")
} else if let Some(problem_details) = &self.problem_details {
match problem_details.description() {
None => Display::fmt(&self.error, f),
Some(message) => f.write_str(&message),
}
} else {
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() {
Some(&self.error)
} else if self.problem_details.is_some() {
Some(&self.error)
} else {
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`"
);
}
}