mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-20 11:56:03 +00:00
<!-- Thank you for contributing to uv! To help us out with reviewing, please consider the following: - Does this pull request include a summary of the change? (See below.) - Does this pull request include a descriptive title? - Does this pull request include references to any relevant issues? --> ## Summary HTTP1.1 [RFC 9112 - HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9112.html#name-status-line) section 4 defines the response status code to optionally include a text description (human readable) of the reason for the status code. [RFC9113 - HTTP/2](https://www.rfc-editor.org/rfc/rfc9113) is the HTTP2 protocol standard and the response status only considers the [status code](https://www.rfc-editor.org/rfc/rfc9113#name-response-pseudo-header-fiel) and not the reason phrase, and as such important information can be lost in helping the client determine a route cause of a failure. As per discussion on this [PR](https://github.com/astral-sh/uv/pull/15979) the current feeling is that implementing the RFC9457 standard might be the preferred route. This PR makes those changes to aid the discussion which has also been moved to the [PEP board](https://discuss.python.org/t/block-download-of-components-when-violating-policy/104021/1) ## Test Plan Pulling components that violate our policy over HTTP2 and without any RFC9457 implementation the following message is presented to the user: <img width="1482" height="104" alt="image" src="https://github.com/user-attachments/assets/0afcd0d8-ca67-4f94-a6c2-131e3b6d8dcc" /> With the RFC9457 standard implemented, below you can see the advantage in the extra context as to why the component has been blocked: <img width="2171" height="127" alt="image" src="https://github.com/user-attachments/assets/25bb5465-955d-4a76-9f30-5477fc2c866f" /> --------- Co-authored-by: konstin <konstin@mailbox.org>
651 lines
22 KiB
Rust
651 lines
22 KiB
Rust
use std::fmt::{Display, Formatter};
|
|
use std::ops::Deref;
|
|
|
|
use async_http_range_reader::AsyncHttpRangeReaderError;
|
|
use async_zip::error::ZipError;
|
|
use serde::Deserialize;
|
|
|
|
use uv_distribution_filename::{WheelFilename, WheelFilenameError};
|
|
use uv_normalize::PackageName;
|
|
use uv_redacted::DisplaySafeUrl;
|
|
|
|
use crate::middleware::OfflineError;
|
|
use crate::{FlatIndexError, html};
|
|
|
|
/// 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 {
|
|
/// 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,
|
|
}
|
|
|
|
impl Display for Error {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
if self.retries > 0 {
|
|
write!(
|
|
f,
|
|
"Request failed after {retries} retries",
|
|
retries = self.retries
|
|
)
|
|
} 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) -> Self {
|
|
Self {
|
|
kind: Box::new(kind),
|
|
retries,
|
|
}
|
|
}
|
|
|
|
/// Return the number of retries that were attempted before this error was returned.
|
|
pub fn retries(&self) -> u32 {
|
|
self.retries
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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) -> 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 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() {
|
|
if let Some(inner) = inner.downcast_ref::<AsyncHttpRangeReaderError>() {
|
|
if matches!(
|
|
inner,
|
|
AsyncHttpRangeReaderError::HttpRangeRequestUnsupported
|
|
) {
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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")]
|
|
PackageNotFound(String),
|
|
|
|
/// The package was not found in the local (file-based) index.
|
|
#[error("Package `{0}` was not found in the local index")]
|
|
FileNotFound(String),
|
|
|
|
/// 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>,
|
|
),
|
|
|
|
/// The metadata file was not found in the wheel.
|
|
#[error("Metadata file `{0}` was not found in {1}")]
|
|
MetadataNotFound(WheelFilename, String),
|
|
|
|
/// An error that happened while making a request or in a reqwest middleware.
|
|
#[error("Failed to fetch: `{0}`")]
|
|
WrappedReqwestError(DisplaySafeUrl, #[source] WrappedReqwestError),
|
|
|
|
/// Add the number of failed retries to the error.
|
|
#[error("Request failed after {retries} retries")]
|
|
RequestWithRetries {
|
|
source: Box<ErrorKind>,
|
|
retries: u32,
|
|
},
|
|
|
|
#[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(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),
|
|
|
|
#[error("Invalid cache control header: `{0}`")]
|
|
InvalidCacheControl(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_middleware::Error`].
|
|
pub(crate) fn from_reqwest_middleware(
|
|
url: DisplaySafeUrl,
|
|
err: reqwest_middleware::Error,
|
|
) -> Self {
|
|
if let reqwest_middleware::Error::Middleware(ref underlying) = err {
|
|
if let Some(err) = underlying.downcast_ref::<OfflineError>() {
|
|
return Self::Offline(err.url().to_string());
|
|
}
|
|
}
|
|
|
|
Self::WrappedReqwestError(url, WrappedReqwestError::from(err))
|
|
}
|
|
|
|
/// 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,
|
|
problem_details: problem_details.map(Box::new),
|
|
}
|
|
}
|
|
|
|
/// Return the inner [`reqwest::Error`] from the error chain, if it exists.
|
|
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 {
|
|
error: error.into(),
|
|
problem_details: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<reqwest_middleware::Error> for WrappedReqwestError {
|
|
fn from(error: reqwest_middleware::Error) -> Self {
|
|
Self {
|
|
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())
|
|
);
|
|
}
|
|
}
|