5b369e6370
This updates async_http_range_reader to v0.11.0 to add the missing range request bounds validation: https://github.com/astral-sh/async_http_range_reader/pull/8. An open question is how we want to behave when the server has an incorrect range request implementation (while advertising range request support). In the current implementation, it warns with the index URL, so that the user is aware that the massive slowdown is caused by a server advertising broken features. Also removes a dependency where the corresponding repo was deleted. Fixes https://github.com/astral-sh/uv/issues/18316
791 lines
27 KiB
Rust
791 lines
27 KiB
Rust
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<String>,
|
|
|
|
/// The HTTP status code generated by the origin server for this occurrence of the problem.
|
|
pub status: Option<u16>,
|
|
|
|
/// A human-readable explanation specific to this occurrence of the problem.
|
|
pub detail: Option<String>,
|
|
|
|
/// A URI reference that identifies the specific occurrence of the problem.
|
|
pub instance: Option<String>,
|
|
}
|
|
|
|
/// 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<Self> {
|
|
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<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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get a human-readable description of the problem
|
|
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 {
|
|
/// 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::<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),
|
|
))
|
|
}
|
|
|
|
/// 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::<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
|
|
}
|
|
|
|
/// 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<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),
|
|
|
|
/// 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<uv_pypi_types::MetadataError>,
|
|
),
|
|
|
|
/// 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<ProblemDetails>,
|
|
) -> 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<Box<ProblemDetails>>,
|
|
}
|
|
|
|
impl WrappedReqwestError {
|
|
/// Create a new `WrappedReqwestError` with optional problem details
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// 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::<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,
|
|
}
|
|
}
|
|
|
|
/// 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::<reqwest::Error>() {
|
|
Some(err)
|
|
} else if let Some(reqwest_middleware::Error::Reqwest(err)) =
|
|
err.downcast_ref::<reqwest_middleware::Error>()
|
|
{
|
|
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<reqwest::Error> 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<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() {
|
|
// 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`"
|
|
);
|
|
}
|
|
}
|