use std::collections::{BTreeMap, BTreeSet};
#[cfg(feature = "_async")]
use std::error::Error as StdError;
use std::fmt;
#[cfg(feature = "_async")]
use std::io;
use std::io::Write;
use std::time::{Duration, SystemTime};
use bytes::Bytes;
use flate2::Compression;
use flate2::write::GzEncoder;
use http::header::{
AUTHORIZATION, CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, EXPECT,
HOST, PROXY_AUTHORIZATION, TE, TRAILER, TRANSFER_ENCODING, UPGRADE,
};
use http::{HeaderMap, StatusCode, header::HeaderName};
use crate::advanced::{AdaptiveConcurrencyPolicy, ClientProfile, StatusPolicy};
use crate::client::Client;
use crate::content_encoding::should_decode_content_encoded_body;
use crate::content_encoding::{DecodeContentEncodingError, decode_content_encoded_body_limited};
use crate::error::{Error, ErrorCode, TimeoutPhase, TransportErrorKind, transport_error};
use crate::execution::{select_base_url, status_retry_delay};
use crate::extensions::{
EndpointSelector, OtelPathNormalizer, StandardOtelPathNormalizer, SystemClock,
};
use crate::proxy::{NoProxyRule, normalize_tunnel_target_uri, should_bypass_proxy_uri};
use crate::response::Response;
use crate::retry::{RetryDecision, RetryPolicy, RetryReason, request_supports_retry};
use crate::tls::{TlsBackend, TlsOptions, TlsRootStore, TlsVersion, tls_version_bounds};
#[cfg(feature = "_blocking")]
use crate::util::is_timeout_io_error;
use crate::util::{
append_query_pairs, bounded_retry_delay, default_port, ensure_accept_encoding_async,
join_base_path, parse_retry_after, rate_limit_bucket_key, redact_uri_for_logs,
resolve_redirect_uri, resolve_uri, same_origin, sanitize_headers_for_redirect,
};
#[cfg(feature = "_async")]
use crate::util::{
classify_transport_error_source_for_test, classify_transport_error_text_for_test,
};
#[test]
fn join_base_path_handles_slashes() {
assert_eq!(
join_base_path("https://api.example.com/v1/", "/users"),
"https://api.example.com/v1/users"
);
}
#[test]
fn join_base_path_preserves_extra_leading_slashes() {
assert_eq!(
join_base_path("https://api.example.com/v1/", "//users"),
"https://api.example.com/v1//users"
);
assert_eq!(
join_base_path("https://api.example.com/v1/", "///users"),
"https://api.example.com/v1///users"
);
}
#[derive(Debug)]
struct StaticEndpointSelector(&'static str);
impl EndpointSelector for StaticEndpointSelector {
fn select_base_url(
&self,
_method: &http::Method,
_path: &str,
_configured_base_url: &str,
) -> crate::Result<String> {
Ok(self.0.to_owned())
}
}
#[test]
fn select_base_url_rejects_invalid_endpoint_selector_value() {
let selector = StaticEndpointSelector("https://api.example.com:/v1");
let error = select_base_url(
&selector,
&http::Method::GET,
"/users",
"https://fallback.example.com/v1",
)
.expect_err("invalid endpoint selector base url should be rejected");
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://api.example.com:/v1");
}
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn join_base_path_keeps_query_and_fragment_on_base_resource() {
assert_eq!(
join_base_path("https://api.example.com/v1/", "?page=1"),
"https://api.example.com/v1?page=1"
);
assert_eq!(
join_base_path("https://api.example.com/v1/", "#section"),
"https://api.example.com/v1#section"
);
assert_eq!(
join_base_path("https://api.example.com/v1/", "?page=1#section"),
"https://api.example.com/v1?page=1#section"
);
}
#[test]
fn resolve_uri_keeps_absolute_uri() {
let (uri_text, uri) = resolve_uri("https://api.example.com/v1", "https://x.test/a")
.expect("absolute uri should parse");
assert_eq!(uri_text, "https://x.test/a");
assert_eq!(uri.to_string(), "https://x.test/a");
}
#[test]
fn resolve_uri_keeps_absolute_uri_with_uppercase_scheme() {
let (uri_text, uri) = resolve_uri("https://api.example.com/v1", "HTTPS://x.test/a")
.expect("absolute uri with uppercase scheme should parse");
assert_eq!(uri_text, "HTTPS://x.test/a");
assert_eq!(uri.host().expect("host should be present"), "x.test",);
}
#[test]
fn resolve_uri_rejects_non_http_absolute_uri() {
let error = resolve_uri("https://api.example.com/v1", "ftp://x.test/a")
.expect_err("non-http absolute uri should be rejected");
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "ftp://x.test/a");
}
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn resolve_uri_rejects_absolute_uri_with_userinfo() {
let error = resolve_uri("https://api.example.com/v1", "https://user:pass@x.test/a")
.expect_err("absolute uri with userinfo should be rejected");
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://x.test/a");
}
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn resolve_uri_rejects_malformed_http_absolute_uri() {
let error = resolve_uri("https://api.example.com/v1", "HTTPS:/x")
.expect_err("malformed absolute http URI should be rejected");
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "HTTPS:/x");
}
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn resolve_uri_rejects_absolute_uri_with_invalid_authority() {
let error = resolve_uri(
"https://api.example.com/v1",
"https://example.com:invalid/v1/new?token=secret#frag",
)
.expect_err("absolute uri with invalid authority should be rejected");
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://example.com:invalid/v1/new");
}
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn resolve_uri_rejects_absolute_uri_with_empty_port() {
let error = resolve_uri(
"https://api.example.com/v1",
"https://example.com:/v1/new?token=secret",
)
.expect_err("absolute uri with empty authority port should be rejected");
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://example.com:/v1/new");
}
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn resolve_uri_keeps_query_only_relative_on_base_resource() {
let (uri_text, uri) = resolve_uri("https://api.example.com/v1", "?page=1")
.expect("query-only relative URI should resolve against the base resource");
assert_eq!(uri_text, "https://api.example.com/v1?page=1");
assert_eq!(uri.to_string(), "https://api.example.com/v1?page=1");
}
#[test]
fn resolve_uri_preserves_extra_leading_slashes_in_relative_path() {
let (uri_text, uri) = resolve_uri("https://api.example.com/v1", "//users")
.expect("double-slash relative path should resolve against the base resource");
assert_eq!(uri_text, "https://api.example.com/v1//users");
assert_eq!(uri.to_string(), "https://api.example.com/v1//users");
}
#[test]
fn resolve_uri_rejects_malformed_http_absolute_uri_after_query_append() {
let query_pairs = vec![("page".to_owned(), "1".to_owned())];
let path = append_query_pairs("HTTPS:/x", &query_pairs);
let error = resolve_uri("https://api.example.com/v1", &path)
.expect_err("malformed absolute http URI should stay rejected after query append");
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "HTTPS:/x");
}
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn normalize_tunnel_target_uri_sets_https_default_port() {
let uri: http::Uri = "https://api.example.com/v1/users"
.parse()
.expect("uri should parse");
let normalized = normalize_tunnel_target_uri(uri);
assert_eq!(
normalized.to_string(),
"https://api.example.com:443/v1/users"
);
}
#[test]
fn normalize_tunnel_target_uri_sets_http_default_port() {
let uri: http::Uri = "http://api.example.com/v1/users"
.parse()
.expect("uri should parse");
let normalized = normalize_tunnel_target_uri(uri);
assert_eq!(normalized.to_string(), "http://api.example.com:80/v1/users");
}
#[test]
fn normalize_tunnel_target_uri_keeps_explicit_port() {
let uri: http::Uri = "https://api.example.com:9443/v1/users"
.parse()
.expect("uri should parse");
let normalized = normalize_tunnel_target_uri(uri);
assert_eq!(
normalized.to_string(),
"https://api.example.com:9443/v1/users"
);
}
#[test]
fn normalize_tunnel_target_uri_handles_uppercase_scheme() {
let uri: http::Uri = "HTTPS://api.example.com/v1/users"
.parse()
.expect("uri should parse");
let normalized = normalize_tunnel_target_uri(uri);
assert_eq!(normalized.host(), Some("api.example.com"));
assert_eq!(normalized.port_u16(), Some(443));
assert!(
normalized
.scheme_str()
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("https"))
);
}
#[test]
fn default_port_handles_uppercase_scheme() {
let https: http::Uri = "HTTPS://api.example.com/path"
.parse()
.expect("uri should parse");
let http: http::Uri = "HTTP://api.example.com/path"
.parse()
.expect("uri should parse");
assert_eq!(default_port(&https), Some(443));
assert_eq!(default_port(&http), Some(80));
}
#[test]
fn rate_limit_bucket_key_uses_default_port_for_uppercase_scheme() {
let uri: http::Uri = "HTTPS://api.example.com/path"
.parse()
.expect("uri should parse");
assert_eq!(
rate_limit_bucket_key(&uri).as_deref(),
Some("api.example.com:443")
);
}
#[test]
fn rate_limit_bucket_key_normalizes_trailing_dot_hosts() {
let uri: http::Uri = "https://api.example.com./path"
.parse()
.expect("uri should parse");
assert_eq!(
rate_limit_bucket_key(&uri).as_deref(),
Some("api.example.com:443")
);
}
#[test]
fn rate_limit_bucket_key_brackets_ipv6_hosts() {
let uri: http::Uri = "https://[2001:db8::1]/path"
.parse()
.expect("uri should parse");
assert_eq!(
rate_limit_bucket_key(&uri).as_deref(),
Some("[2001:db8::1]:443")
);
}
#[test]
fn same_origin_handles_uppercase_scheme() {
let left: http::Uri = "HTTPS://api.example.com/path"
.parse()
.expect("left uri should parse");
let right: http::Uri = "https://api.example.com:443/other"
.parse()
.expect("right uri should parse");
assert!(same_origin(&left, &right));
}
#[test]
fn same_origin_normalizes_trailing_dot_hosts() {
let left: http::Uri = "https://api.example.com./path"
.parse()
.expect("left uri should parse");
let right: http::Uri = "https://api.example.com:443/other"
.parse()
.expect("right uri should parse");
assert!(same_origin(&left, &right));
}
#[test]
fn resolve_redirect_uri_rejects_non_http_scheme() {
let current: http::Uri = "https://api.example.com/v1/old"
.parse()
.expect("current uri should parse");
assert!(resolve_redirect_uri(¤t, "mailto:user:pass@example.com").is_none());
assert!(resolve_redirect_uri(¤t, "javascript:alert(1)").is_none());
}
#[test]
fn resolve_redirect_uri_rejects_userinfo_location() {
let current: http::Uri = "https://api.example.com/v1/old"
.parse()
.expect("current uri should parse");
assert!(resolve_redirect_uri(¤t, "https://user:pass@example.com/v1/new").is_none());
}
#[test]
fn resolve_redirect_uri_rejects_malformed_http_absolute_location() {
let current: http::Uri = "https://api.example.com/v1/old"
.parse()
.expect("current uri should parse");
assert!(resolve_redirect_uri(¤t, "http:/x").is_none());
assert!(resolve_redirect_uri(¤t, "https:/x").is_none());
assert!(resolve_redirect_uri(¤t, "https:///x").is_none());
assert!(resolve_redirect_uri(¤t, "http:foo").is_none());
assert!(resolve_redirect_uri(¤t, "https://example.com:invalid/v1/new").is_none());
}
#[test]
fn resolve_redirect_uri_rejects_network_path_location_with_empty_port() {
let current: http::Uri = "https://api.example.com/v1/old"
.parse()
.expect("current uri should parse");
assert!(resolve_redirect_uri(¤t, "//example.com:/v1/new").is_none());
}
#[test]
fn sanitize_headers_for_redirect_removes_proxy_authorization() {
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_ENCODING,
"gzip".parse().expect("content-encoding should parse"),
);
headers.insert(
CONTENT_LENGTH,
"3".parse().expect("content-length should parse"),
);
headers.insert(
CONTENT_TYPE,
"application/json"
.parse()
.expect("content-type should parse"),
);
headers.insert(EXPECT, "100-continue".parse().expect("expect should parse"));
headers.insert(
"content-digest",
"sha-256=:abc=:"
.parse()
.expect("content-digest should parse"),
);
headers.insert(
"content-md5",
"Q2hlY2sgSW50ZWdyaXR5IQ=="
.parse()
.expect("content-md5 should parse"),
);
headers.insert(
"digest",
"SHA-256=xyz".parse().expect("digest should parse"),
);
headers.insert(
TRAILER,
"x-checksum".parse().expect("trailer header should parse"),
);
headers.insert(
TRANSFER_ENCODING,
"chunked".parse().expect("transfer-encoding should parse"),
);
headers.insert(HOST, "api.example.com".parse().expect("host should parse"));
headers.insert(
AUTHORIZATION,
"Bearer token".parse().expect("authorization should parse"),
);
headers.insert(COOKIE, "session=abc".parse().expect("cookie should parse"));
headers.insert(
PROXY_AUTHORIZATION,
"Basic dXNlcjpwYXNz"
.parse()
.expect("proxy-authorization should parse"),
);
sanitize_headers_for_redirect(&mut headers, true, true);
assert!(!headers.contains_key(CONTENT_ENCODING));
assert!(!headers.contains_key(CONTENT_LENGTH));
assert!(!headers.contains_key(CONTENT_TYPE));
assert!(!headers.contains_key("content-digest"));
assert!(!headers.contains_key("content-md5"));
assert!(!headers.contains_key("digest"));
assert!(!headers.contains_key(EXPECT));
assert!(!headers.contains_key(TRAILER));
assert!(!headers.contains_key(TRANSFER_ENCODING));
assert!(!headers.contains_key(HOST));
assert!(headers.contains_key(AUTHORIZATION));
assert!(headers.contains_key(COOKIE));
assert!(!headers.contains_key(PROXY_AUTHORIZATION));
}
#[test]
fn sanitize_headers_for_redirect_removes_hop_by_hop_headers() {
let mut headers = HeaderMap::new();
let connection_option = HeaderName::from_static("x-connection-option");
headers.insert(
CONNECTION,
"keep-alive, x-connection-option"
.parse()
.expect("connection header should parse"),
);
headers.insert(
"keep-alive",
"timeout=5".parse().expect("keep-alive should parse"),
);
headers.insert(
"proxy-connection",
"keep-alive".parse().expect("proxy-connection should parse"),
);
headers.insert(TE, "trailers".parse().expect("te should parse"));
headers.insert(TRAILER, "x-checksum".parse().expect("trailer should parse"));
headers.insert(
TRANSFER_ENCODING,
"chunked".parse().expect("transfer-encoding should parse"),
);
headers.insert(UPGRADE, "websocket".parse().expect("upgrade should parse"));
headers.insert(
connection_option,
"present"
.parse()
.expect("connection-scoped header should parse"),
);
headers.insert(
AUTHORIZATION,
"Bearer token".parse().expect("authorization should parse"),
);
sanitize_headers_for_redirect(&mut headers, false, true);
assert!(!headers.contains_key(CONNECTION));
assert!(!headers.contains_key("keep-alive"));
assert!(!headers.contains_key("proxy-connection"));
assert!(!headers.contains_key(TE));
assert!(!headers.contains_key(TRAILER));
assert!(!headers.contains_key(TRANSFER_ENCODING));
assert!(!headers.contains_key(UPGRADE));
assert!(!headers.contains_key("x-connection-option"));
assert!(headers.contains_key(AUTHORIZATION));
}
#[test]
fn standard_otel_path_normalizer_truncates_on_segment_boundary() {
let input = format!(
"/{}",
std::iter::repeat_n("segment", 30)
.collect::<Vec<_>>()
.join("/")
);
let normalizer = StandardOtelPathNormalizer;
let normalized = normalizer.normalize_path(&input);
assert!(normalized.len() <= 128);
assert!(
normalized
.split('/')
.skip(1)
.all(|segment| segment == "segment"),
"truncated path should not keep partial segments: {normalized}"
);
}
#[test]
fn redact_uri_for_logs_masks_credential_like_path_segments_and_removes_query() {
let redacted = redact_uri_for_logs(
"https://api.telegram.org/bot123456:AAABBBCCCDDDEE/getUpdates?offset=10",
);
assert_eq!(
redacted,
"https://api.telegram.org/bot123456:redacted/getUpdates"
);
}
#[test]
fn redact_uri_for_logs_masks_userinfo() {
let redacted = redact_uri_for_logs("http://user:pass@proxy.example.com:7890/path");
assert_eq!(redacted, "http://proxy.example.com:7890/path");
}
#[test]
fn redact_uri_for_logs_fallback_masks_userinfo_query_and_fragment() {
let redacted =
redact_uri_for_logs("https://user:pass@proxy.example.com:badport/path?token=secret#frag");
assert_eq!(redacted, "https://proxy.example.com:badport/path");
}
#[test]
fn redact_uri_for_logs_masks_non_authority_credentials() {
let redacted = redact_uri_for_logs("mailto:user:pass@example.com?subject=secret");
assert_eq!(redacted, "mailto:<redacted>@example.com");
}
#[test]
fn redact_uri_for_logs_keeps_unknown_non_authority_uri_shape() {
let redacted = redact_uri_for_logs("urn:example:foo@bar?token=secret");
assert_eq!(redacted, "urn:example:foo@bar");
}
#[test]
fn append_query_pairs_merges_existing_query_and_fragment() {
let query_pairs = vec![
("name".to_owned(), "alice bob".to_owned()),
("page".to_owned(), "2".to_owned()),
];
let merged = append_query_pairs("/v1/users?active=true#section", &query_pairs);
assert!(merged.starts_with("/v1/users?"));
assert!(merged.ends_with("#section"));
let query_text = merged
.split_once('?')
.and_then(|(_, right)| right.split_once('#').map(|(query, _)| query))
.unwrap_or_default();
let parsed: BTreeMap<String, String> = url::form_urlencoded::parse(query_text.as_bytes())
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect();
assert_eq!(parsed.get("active"), Some(&"true".to_owned()));
assert_eq!(parsed.get("name"), Some(&"alice bob".to_owned()));
assert_eq!(parsed.get("page"), Some(&"2".to_owned()));
}
#[test]
fn append_query_pairs_handles_absolute_url() {
let query_pairs = vec![
("topic".to_owned(), "rust sdk".to_owned()),
("lang".to_owned(), "zh".to_owned()),
];
let merged = append_query_pairs("https://api.example.com/search?q=hello", &query_pairs);
let parsed = url::Url::parse(&merged).expect("merged url should parse");
let parsed_query: BTreeMap<String, String> = parsed
.query_pairs()
.map(|pair| (pair.0.into_owned(), pair.1.into_owned()))
.collect();
assert_eq!(parsed_query.get("q"), Some(&"hello".to_owned()));
assert_eq!(parsed_query.get("topic"), Some(&"rust sdk".to_owned()));
assert_eq!(parsed_query.get("lang"), Some(&"zh".to_owned()));
}
#[test]
fn append_query_pairs_preserves_malformed_http_absolute_shape() {
let query_pairs = vec![("page".to_owned(), "1".to_owned())];
let merged = append_query_pairs("HTTPS:/x", &query_pairs);
assert_eq!(merged, "HTTPS:/x?page=1");
}
#[test]
fn response_json_decode_error_contains_body() {
let response = Response::new(
http::StatusCode::OK,
http::HeaderMap::new(),
bytes::Bytes::from_static(b"not-json"),
);
let error = response
.json::<serde_json::Value>()
.expect_err("invalid json should return error");
match error {
Error::DeserializeJson { body, .. } => assert_eq!(body, "not-json"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn retry_policy_backoff_is_capped() {
let retry_policy = RetryPolicy::standard()
.base_backoff(Duration::from_millis(100))
.max_backoff(Duration::from_millis(250))
.jitter_ratio(0.0);
assert_eq!(
retry_policy.backoff_for_retry(1),
Duration::from_millis(100)
);
assert_eq!(
retry_policy.backoff_for_retry(2),
Duration::from_millis(200)
);
assert_eq!(
retry_policy.backoff_for_retry(3),
Duration::from_millis(250)
);
}
#[test]
fn retry_policy_can_filter_transport_error_kinds() {
let retry_policy =
RetryPolicy::standard().retryable_transport_error_kinds([TransportErrorKind::Connect]);
let connect_decision = RetryDecision::new(
1,
3,
http::Method::GET,
"https://example.com".to_owned(),
RetryReason::Transport(TransportErrorKind::Connect),
);
let dns_decision = RetryDecision::new(
1,
3,
http::Method::GET,
"https://example.com".to_owned(),
RetryReason::Transport(TransportErrorKind::Dns),
);
assert!(retry_policy.should_retry_decision(&connect_decision));
assert!(!retry_policy.should_retry_decision(&dns_decision));
}
#[test]
fn retry_policy_standard_skips_tls_and_other_transport_errors() {
let retry_policy = RetryPolicy::standard();
let tls_decision = RetryDecision::new(
1,
3,
http::Method::GET,
"https://example.com/tls".to_owned(),
RetryReason::Transport(TransportErrorKind::Tls),
);
let other_decision = RetryDecision::new(
1,
3,
http::Method::GET,
"https://example.com/tls".to_owned(),
RetryReason::Transport(TransportErrorKind::Other),
);
assert!(!retry_policy.should_retry_decision(&tls_decision));
assert!(!retry_policy.should_retry_decision(&other_decision));
}
#[test]
fn classify_transport_error_text_detects_dns_tls_and_connect() {
assert_eq!(
classify_transport_error_text_for_test("failed to lookup address", true),
TransportErrorKind::Dns
);
assert_eq!(
classify_transport_error_text_for_test("tls handshake eof", true),
TransportErrorKind::Tls
);
assert_eq!(
classify_transport_error_text_for_test("connection refused", true),
TransportErrorKind::Connect
);
assert_eq!(
classify_transport_error_text_for_test("received fatal alert: ProtocolVersion", true),
TransportErrorKind::Tls
);
}
#[test]
fn classify_transport_error_text_avoids_over_broad_read_matches() {
assert_eq!(
classify_transport_error_text_for_test("request already sent", false),
TransportErrorKind::Other
);
assert_eq!(
classify_transport_error_text_for_test("connection reset by peer", false),
TransportErrorKind::Read
);
}
#[cfg(feature = "_async")]
#[derive(Debug)]
struct WrappedSourceError {
source: Box<dyn StdError + Send + Sync>,
}
#[cfg(feature = "_async")]
impl fmt::Display for WrappedSourceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("wrapped source error")
}
}
#[cfg(feature = "_async")]
impl StdError for WrappedSourceError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(self.source.as_ref())
}
}
#[cfg(feature = "_async")]
#[test]
fn classify_transport_error_source_chain_prefers_structured_io_errors() {
let dns = io::Error::new(io::ErrorKind::NotFound, "resolver failed");
assert_eq!(
classify_transport_error_source_for_test(&dns, true),
Some(TransportErrorKind::Dns)
);
let connect = WrappedSourceError {
source: Box::new(io::Error::new(
io::ErrorKind::ConnectionRefused,
"connection refused",
)),
};
assert_eq!(
classify_transport_error_source_for_test(&connect, true),
Some(TransportErrorKind::Connect)
);
let read = io::Error::new(io::ErrorKind::ConnectionReset, "connection reset");
assert_eq!(
classify_transport_error_source_for_test(&read, false),
Some(TransportErrorKind::Read)
);
let host_unreachable = io::Error::new(io::ErrorKind::HostUnreachable, "host unreachable");
assert_eq!(
classify_transport_error_source_for_test(&host_unreachable, true),
Some(TransportErrorKind::Connect)
);
let network_down = io::Error::new(io::ErrorKind::NetworkDown, "network down");
assert_eq!(
classify_transport_error_source_for_test(&network_down, true),
Some(TransportErrorKind::Connect)
);
}
#[cfg(all(
feature = "_async",
any(
feature = "async-tls-rustls-ring",
feature = "async-tls-rustls-aws-lc-rs"
)
))]
#[test]
fn classify_transport_error_source_chain_detects_rustls_errors() {
let tls_error = WrappedSourceError {
source: Box::new(rustls::Error::General("handshake failed".into())),
};
assert_eq!(
classify_transport_error_source_for_test(&tls_error, true),
Some(TransportErrorKind::Tls)
);
}
#[cfg(all(
feature = "_async",
any(
feature = "async-tls-rustls-ring",
feature = "async-tls-rustls-aws-lc-rs"
)
))]
#[test]
fn classify_transport_error_source_chain_prefers_nested_tls_over_io_kind() {
let tls_io = io::Error::new(
io::ErrorKind::UnexpectedEof,
rustls::Error::General("handshake failed".into()),
);
assert_eq!(
classify_transport_error_source_for_test(&tls_io, true),
Some(TransportErrorKind::Tls)
);
}
#[test]
fn classify_transport_error_text_maps_proxy_tunnel_connect_failures() {
assert_eq!(
classify_transport_error_text_for_test("tunnel error: unexpected end of file", true),
TransportErrorKind::Connect
);
assert_eq!(
classify_transport_error_text_for_test("tunnel error: io error establishing tunnel", true),
TransportErrorKind::Connect
);
assert_eq!(
classify_transport_error_text_for_test(
"tunnel error: failed to create underlying connection",
true
),
TransportErrorKind::Connect
);
assert_eq!(
classify_transport_error_text_for_test("tunnel error: proxy authorization required", true),
TransportErrorKind::Other
);
}
#[test]
fn retry_policy_status_retry_window_caps_followup_attempts() {
let retry_policy = RetryPolicy::standard()
.retryable_status_codes([429_u16, 503_u16])
.status_retry_window(429, 2);
let first_429 = RetryDecision::new(
1,
5,
http::Method::GET,
"https://example.com/rate".to_owned(),
RetryReason::Status(http::StatusCode::TOO_MANY_REQUESTS),
);
let second_429 = RetryDecision::new(
2,
5,
http::Method::GET,
"https://example.com/rate".to_owned(),
RetryReason::Status(http::StatusCode::TOO_MANY_REQUESTS),
);
let third_503 = RetryDecision::new(
3,
5,
http::Method::GET,
"https://example.com/rate".to_owned(),
RetryReason::Status(http::StatusCode::SERVICE_UNAVAILABLE),
);
assert!(retry_policy.should_retry_decision(&first_429));
assert!(!retry_policy.should_retry_decision(&second_429));
assert!(retry_policy.should_retry_decision(&third_503));
}
#[test]
fn retry_policy_timeout_and_read_body_windows_are_configurable() {
let retry_policy = RetryPolicy::standard()
.retryable_timeout_phases([TimeoutPhase::Transport])
.timeout_retry_window(TimeoutPhase::Transport, 2)
.response_body_read_retry_window(2);
let transport_timeout_first = RetryDecision::new(
1,
5,
http::Method::GET,
"https://example.com/timeout".to_owned(),
RetryReason::Timeout(TimeoutPhase::Transport),
);
let transport_timeout_second = RetryDecision::new(
2,
5,
http::Method::GET,
"https://example.com/timeout".to_owned(),
RetryReason::Timeout(TimeoutPhase::Transport),
);
let response_timeout = RetryDecision::new(
1,
5,
http::Method::GET,
"https://example.com/timeout".to_owned(),
RetryReason::Timeout(TimeoutPhase::ResponseBody),
);
let read_body_first = RetryDecision::new(
1,
5,
http::Method::GET,
"https://example.com/timeout".to_owned(),
RetryReason::ResponseBodyRead,
);
let read_body_second = RetryDecision::new(
2,
5,
http::Method::GET,
"https://example.com/timeout".to_owned(),
RetryReason::ResponseBodyRead,
);
assert!(retry_policy.should_retry_decision(&transport_timeout_first));
assert!(!retry_policy.should_retry_decision(&transport_timeout_second));
assert!(!retry_policy.should_retry_decision(&response_timeout));
assert!(retry_policy.should_retry_decision(&read_body_first));
assert!(!retry_policy.should_retry_decision(&read_body_second));
}
#[test]
fn post_without_idempotency_key_is_not_retryable() {
let headers = http::HeaderMap::new();
assert!(!request_supports_retry(&http::Method::POST, &headers));
}
#[test]
fn post_with_idempotency_key_is_retryable() {
let mut headers = http::HeaderMap::new();
headers.insert("idempotency-key", http::HeaderValue::from_static("abc"));
assert!(request_supports_retry(&http::Method::POST, &headers));
}
#[test]
fn body_stream_accepts_send_non_sync_stream() {
use std::cell::Cell;
use std::pin::Pin;
use std::task::{Context, Poll};
use futures_core::Stream;
struct NonSyncByteStream {
emitted: Cell<bool>,
}
impl Stream for NonSyncByteStream {
type Item = Result<bytes::Bytes, std::io::Error>;
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
if this.emitted.replace(true) {
Poll::Ready(None)
} else {
Poll::Ready(Some(Ok(bytes::Bytes::from_static(b"x"))))
}
}
}
let client = Client::builder("https://api.example.com")
.build()
.expect("client should build");
let _request = client.post("/v1/upload").body_stream(NonSyncByteStream {
emitted: Cell::new(false),
});
}
#[test]
fn parse_retry_after_header_seconds() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::RETRY_AFTER,
http::HeaderValue::from_static("5"),
);
assert_eq!(
parse_retry_after(&headers, SystemTime::UNIX_EPOCH),
Some(Duration::from_secs(5))
);
}
#[test]
fn parse_retry_after_header_http_date() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let retry_at = now + Duration::from_secs(30);
let mut headers = http::HeaderMap::new();
let retry_at_text = httpdate::fmt_http_date(retry_at);
headers.insert(
http::header::RETRY_AFTER,
http::HeaderValue::from_str(&retry_at_text).expect("valid retry-after date"),
);
assert_eq!(
parse_retry_after(&headers, now),
Some(Duration::from_secs(30))
);
}
#[test]
fn status_retry_delay_caps_retry_after_to_max_delay() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::RETRY_AFTER,
http::HeaderValue::from_static("120"),
);
let clock = SystemClock;
let delay = status_retry_delay(
&clock,
&headers,
Duration::from_millis(200),
Duration::from_secs(45),
);
assert_eq!(delay, Duration::from_secs(45));
}
#[test]
fn status_retry_delay_uses_configured_cap_when_small() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::RETRY_AFTER,
http::HeaderValue::from_static("120"),
);
let clock = SystemClock;
let delay = status_retry_delay(
&clock,
&headers,
Duration::from_millis(200),
Duration::from_secs(2),
);
assert_eq!(delay, Duration::from_secs(2));
}
#[test]
fn bounded_retry_delay_respects_total_timeout() {
let start = std::time::Instant::now();
let retry_delay = Duration::from_millis(100);
let total_timeout = Some(Duration::from_millis(100));
assert_eq!(bounded_retry_delay(retry_delay, total_timeout, start), None);
}
#[test]
fn error_code_maps_expected_variant() {
let error = Error::InvalidUri {
uri: "bad://uri".to_owned(),
};
assert_eq!(error.code(), ErrorCode::InvalidUri);
assert_eq!(error.code().as_str(), "invalid_uri");
}
#[test]
fn error_display_redacts_query_from_request_uri() {
let error = transport_error(
TransportErrorKind::Connect,
http::Method::GET,
crate::util::redact_uri_for_logs("https://api.example.com/v1/items?token=secret"),
std::io::Error::other("connect failed"),
);
let display = error.to_string();
let debug = format!("{error:?}");
assert!(!display.contains("token=secret"));
assert!(!debug.contains("token=secret"));
assert!(display.contains("/v1/items"));
}
#[derive(Debug)]
struct TestNestedError {
message: &'static str,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
impl TestNestedError {
fn new(message: &'static str) -> Self {
Self {
message,
source: None,
}
}
fn with_source(
message: &'static str,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self {
message,
source: Some(Box::new(source)),
}
}
}
impl fmt::Display for TestNestedError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message)
}
}
impl std::error::Error for TestNestedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.as_deref().map(|source| source as _)
}
}
#[test]
fn transport_error_display_includes_nested_source_chain() {
let source = TestNestedError::with_source(
"client error (Connect)",
TestNestedError::new("received fatal alert: ProtocolVersion"),
);
let error = transport_error(
TransportErrorKind::Other,
http::Method::POST,
"https://example.com/v1/login".to_owned(),
source,
);
let display = error.to_string();
assert!(display.contains("client error (Connect)"));
assert!(display.contains("received fatal alert: ProtocolVersion"));
}
#[test]
fn tls_version_bounds_validate_range() {
let tls_options = TlsOptions {
min_protocol_version: Some(TlsVersion::V1_2),
max_protocol_version: Some(TlsVersion::V1_3),
..TlsOptions::default()
};
assert_eq!(
tls_version_bounds(TlsBackend::RustlsRing, &tls_options)
.expect("version bounds should validate"),
crate::tls::TlsVersionBounds {
min: Some(TlsVersion::V1_2),
max: Some(TlsVersion::V1_3),
}
);
let invalid = TlsOptions {
min_protocol_version: Some(TlsVersion::V1_3),
max_protocol_version: Some(TlsVersion::V1_2),
..TlsOptions::default()
};
match tls_version_bounds(TlsBackend::RustlsRing, &invalid) {
Ok(_) => panic!("invalid tls version bounds should fail"),
Err(Error::TlsConfig { message, .. }) => {
assert!(message.contains("min version"));
}
Err(other) => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "async-tls-rustls-ring")]
#[test]
fn selecting_tls_version_for_rustls_ring_builds() {
let client = Client::builder("https://example.com")
.tls_backend(TlsBackend::RustlsRing)
.tls_version(TlsVersion::V1_2)
.build()
.expect("rustls ring client should build with explicit tls version");
assert_eq!(client.tls_backend(), TlsBackend::RustlsRing);
}
#[cfg(feature = "async-tls-rustls-aws-lc-rs")]
#[test]
fn selecting_tls_version_for_rustls_aws_lc_builds() {
let client = Client::builder("https://example.com")
.tls_backend(TlsBackend::RustlsAwsLcRs)
.tls_max_version(TlsVersion::V1_2)
.build()
.expect("rustls aws-lc client should build with max tls version");
assert_eq!(client.tls_backend(), TlsBackend::RustlsAwsLcRs);
}
#[cfg(feature = "async-tls-native")]
#[test]
fn selecting_tls_version_for_native_tls12_builds() {
let client = Client::builder("https://example.com")
.tls_backend(TlsBackend::NativeTls)
.tls_version(TlsVersion::V1_2)
.build()
.expect("native tls client should build with explicit tls 1.2");
assert_eq!(client.tls_backend(), TlsBackend::NativeTls);
}
#[cfg(feature = "async-tls-native")]
#[test]
fn selecting_tls13_bounds_for_native_tls_builds() {
let client = Client::builder("https://example.com")
.tls_backend(TlsBackend::NativeTls)
.tls_min_version(TlsVersion::V1_2)
.tls_max_version(TlsVersion::V1_3)
.build()
.expect("native tls client should build with tls 1.2..=1.3 bounds");
assert_eq!(client.tls_backend(), TlsBackend::NativeTls);
}
#[test]
fn error_safe_request_accessors_return_method_uri_and_path() {
let error = Error::HttpStatus {
status: 404,
method: http::Method::POST,
uri: "https://api.example.com/v1/items".to_owned(),
headers: Box::new(http::HeaderMap::new()),
body: String::new(),
};
assert_eq!(
error.request_method().map(http::Method::as_str),
Some("POST")
);
assert_eq!(
error.request_uri_redacted(),
Some("https://api.example.com/v1/items")
);
assert_eq!(
error.request_uri_redacted_owned().as_deref(),
Some("https://api.example.com/v1/items")
);
assert_eq!(error.request_path().as_deref(), Some("/v1/items"));
}
#[cfg(feature = "_blocking")]
#[test]
fn blocking_timeout_io_error_helper_detects_plain_and_wrapped_timeouts() {
let plain = std::io::Error::new(std::io::ErrorKind::TimedOut, "read timed out");
assert!(is_timeout_io_error(&plain));
let would_block = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
assert!(is_timeout_io_error(&would_block));
let wrapped = std::io::Error::other(ureq::Error::Timeout(ureq::Timeout::RecvBody));
assert!(is_timeout_io_error(&wrapped));
let reset = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
assert!(!is_timeout_io_error(&reset));
}
#[test]
fn error_code_contract_table_is_stable() {
let codes = ErrorCode::all();
assert_eq!(codes.len(), 30);
let names: Vec<&str> = codes.iter().map(|code| code.as_str()).collect();
assert_eq!(
names,
vec![
"invalid_uri",
"invalid_no_proxy_rule",
"invalid_proxy_config",
"invalid_adaptive_concurrency_policy",
"serialize_json",
"serialize_query",
"serialize_form",
"request_build",
"transport",
"timeout",
"deadline_exceeded",
"read_body",
"write_body",
"response_body_too_large",
"http_status",
"deserialize_json",
"decode_text",
"invalid_header_name",
"invalid_header_value",
"decode_content_encoding",
"concurrency_limit_closed",
"tls_backend_unavailable",
"tls_backend_init",
"tls_config",
"retry_budget_exhausted",
"circuit_open",
"missing_redirect_location",
"invalid_redirect_location",
"redirect_limit_exceeded",
"redirect_body_not_replayable",
]
);
let unique: BTreeSet<&str> = names.iter().copied().collect();
assert_eq!(unique.len(), names.len());
}
#[test]
fn error_code_maps_tls_config_variant() {
let error = Error::TlsConfig {
backend: "native-tls",
message: "bad cert".to_owned(),
};
assert_eq!(error.code(), ErrorCode::TlsConfig);
assert_eq!(error.code().as_str(), "tls_config");
}
#[test]
fn error_code_maps_invalid_proxy_config_variant() {
let error = Error::InvalidProxyConfig {
proxy_uri: "http://proxy.example.com:8080".to_owned(),
message: "bad proxy configuration".to_owned(),
};
assert_eq!(error.code(), ErrorCode::InvalidProxyConfig);
assert_eq!(error.code().as_str(), "invalid_proxy_config");
}
#[test]
fn error_code_maps_proxy_authorization_requires_http_proxy_variant() {
let error = Error::ProxyAuthorizationRequiresHttpProxy;
assert_eq!(error.code(), ErrorCode::InvalidProxyConfig);
assert_eq!(error.code().as_str(), "invalid_proxy_config");
}
#[test]
fn error_code_maps_invalid_adaptive_concurrency_policy_variant() {
let error = Error::InvalidAdaptiveConcurrencyPolicy {
min_limit: 10,
initial_limit: 8,
max_limit: 5,
message: "min_limit must be <= max_limit",
};
assert_eq!(error.code(), ErrorCode::InvalidAdaptiveConcurrencyPolicy);
assert_eq!(error.code().as_str(), "invalid_adaptive_concurrency_policy");
}
#[test]
fn error_code_maps_redirect_limit_exceeded_variant() {
let error = Error::RedirectLimitExceeded {
max_redirects: 3,
method: http::Method::GET,
uri: "https://example.com/a".to_owned(),
};
assert_eq!(error.code(), ErrorCode::RedirectLimitExceeded);
assert_eq!(error.code().as_str(), "redirect_limit_exceeded");
}
#[test]
fn error_code_maps_retry_budget_exhausted_variant() {
let error = Error::RetryBudgetExhausted {
method: http::Method::GET,
uri: "https://example.com/retry-budget".to_owned(),
};
assert_eq!(error.code(), ErrorCode::RetryBudgetExhausted);
assert_eq!(error.code().as_str(), "retry_budget_exhausted");
}
#[test]
fn error_code_maps_circuit_open_variant() {
let error = Error::CircuitOpen {
method: http::Method::GET,
uri: "https://example.com/circuit".to_owned(),
retry_after_ms: 1000,
};
assert_eq!(error.code(), ErrorCode::CircuitOpen);
assert_eq!(error.code().as_str(), "circuit_open");
}
#[test]
fn invalid_tls_root_ca_pem_returns_tls_config_error() {
let result = Client::builder("https://api.example.com")
.tls_root_ca_pem("not-a-pem-certificate")
.build();
let error = match result {
Ok(_) => panic!("invalid root ca pem should fail"),
Err(error) => error,
};
match error {
Error::TlsConfig { .. } => {}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_invalid_base_url_early() {
let result = Client::builder("not-a-valid-base-url").build();
let error = match result {
Ok(_) => panic!("invalid base url should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "not-a-valid-base-url");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_base_url_with_empty_port_authority() {
let result = Client::builder("https://api.example.com:/v1").build();
let error = match result {
Ok(_) => panic!("base url with empty authority port should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://api.example.com:/v1");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_non_http_base_url_scheme() {
let result = Client::builder("ftp://api.example.com").build();
let error = match result {
Ok(_) => panic!("non-http base url should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "ftp://api.example.com/");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_non_http_proxy_scheme() {
let proxy_uri: http::Uri = "https://proxy.example.com:8443"
.parse()
.expect("proxy uri should parse");
let result = Client::builder("https://api.example.com")
.http_proxy(proxy_uri)
.build();
let error = match result {
Ok(_) => panic!("non-http proxy scheme should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidProxyConfig { proxy_uri, message } => {
assert_eq!(proxy_uri, "https://proxy.example.com:8443/");
assert!(message.contains("http scheme"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_http_proxy_uri_with_invalid_authority() {
let proxy_uri: http::Uri = "http://proxy.example.com:invalid"
.parse()
.expect("proxy uri should parse");
let result = Client::builder("https://api.example.com")
.http_proxy(proxy_uri)
.build();
let error = match result {
Ok(_) => panic!("http_proxy URI with invalid authority should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidProxyConfig { proxy_uri, message } => {
assert_eq!(proxy_uri, "http://proxy.example.com:invalid/");
assert!(message.contains("valid authority"));
assert!(message.contains("numeric port"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_http_proxy_uri_with_empty_port() {
let proxy_uri: http::Uri = "http://proxy.example.com:/"
.parse()
.expect("proxy uri should parse");
let result = Client::builder("https://api.example.com")
.http_proxy(proxy_uri)
.build();
let error = match result {
Ok(_) => panic!("http_proxy URI with empty port should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidProxyConfig { proxy_uri, message } => {
assert_eq!(proxy_uri, "http://proxy.example.com:/");
assert!(message.contains("valid authority"));
assert!(message.contains("numeric port"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_http_proxy_uri_with_credentials() {
let proxy_uri: http::Uri = "http://user:pass@proxy.example.com:8080"
.parse()
.expect("proxy uri should parse");
let result = Client::builder("https://api.example.com")
.http_proxy(proxy_uri)
.build();
let error = match result {
Ok(_) => panic!("async http_proxy URI with credentials should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidProxyConfig { proxy_uri, message } => {
assert_eq!(proxy_uri, "http://proxy.example.com:8080/");
assert!(message.contains("must not include credentials"));
assert!(message.contains("proxy_authorization"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_proxy_authorization_without_http_proxy() {
let result = Client::builder("https://api.example.com")
.try_proxy_authorization("Basic dXNlcjpwYXNz")
.expect("proxy authorization header should parse")
.build();
let error = match result {
Ok(_) => panic!("proxy authorization without http_proxy should fail at build time"),
Err(error) => error,
};
match error {
Error::ProxyAuthorizationRequiresHttpProxy => {}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_base_url_with_query() {
let result = Client::builder("https://api.example.com/v1?token=abc").build();
let error = match result {
Ok(_) => panic!("base url with query should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://api.example.com/v1");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_base_url_with_fragment() {
let result = Client::builder("https://api.example.com/v1#anchor").build();
let error = match result {
Ok(_) => panic!("base url with fragment should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://api.example.com/v1");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_base_url_with_userinfo() {
let result = Client::builder("https://user:pass@api.example.com/v1").build();
let error = match result {
Ok(_) => panic!("base url with userinfo should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://api.example.com/v1");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn build_rejects_base_url_with_surrounding_whitespace() {
let result = Client::builder(" https://api.example.com/v1 ").build();
let error = match result {
Ok(_) => panic!("base url with surrounding whitespace should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidUri { uri } => {
assert_eq!(uri, "https://api.example.com/v1");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn client_profile_and_direct_builder_overrides_compose() {
let client = Client::builder("https://api.example.com")
.profile(ClientProfile::LowLatency)
.request_timeout(Duration::from_secs(4))
.total_timeout(Duration::from_secs(9))
.max_response_body_bytes(16 * 1024)
.default_status_policy(StatusPolicy::Response)
.build()
.expect("client should build with profile and direct overrides");
assert_eq!(client.default_status_policy(), StatusPolicy::Response);
}
#[test]
fn response_text_accepts_valid_utf8() {
let response = Response::new(
StatusCode::OK,
HeaderMap::new(),
Bytes::from_static(b"hello"),
);
assert_eq!(response.text().expect("text should decode"), "hello");
}
#[test]
fn response_text_rejects_invalid_utf8() {
let response = Response::new(
StatusCode::OK,
HeaderMap::new(),
Bytes::from_static(b"hello\xff"),
);
let error = response.text().expect_err("invalid utf-8 should fail");
match error {
Error::DecodeText { body, .. } => assert_eq!(body, "hello\u{fffd}"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn write_body_error_accessors_expose_request_context() {
let error = Error::WriteBody {
method: http::Method::GET,
uri: "https://api.example.com/v1/download".to_owned(),
source: Box::new(std::io::Error::other("writer failed")),
};
assert_eq!(error.code(), ErrorCode::WriteBody);
assert_eq!(
error.request_method().map(http::Method::as_str),
Some("GET")
);
assert_eq!(
error.request_uri_redacted(),
Some("https://api.example.com/v1/download")
);
assert_eq!(error.request_path().as_deref(), Some("/v1/download"));
}
#[test]
fn build_rejects_invalid_adaptive_concurrency_policy() {
let policy = AdaptiveConcurrencyPolicy::standard()
.min_limit(10)
.initial_limit(8)
.max_limit(5);
let result = Client::builder("https://api.example.com")
.adaptive_concurrency_policy(policy)
.build();
let error = match result {
Ok(_) => panic!("invalid adaptive concurrency policy should fail"),
Err(error) => error,
};
match error {
Error::InvalidAdaptiveConcurrencyPolicy {
min_limit,
initial_limit,
max_limit,
..
} => {
assert_eq!(min_limit, 10);
assert_eq!(initial_limit, 8);
assert_eq!(max_limit, 5);
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn tls_root_store_specific_without_roots_returns_tls_config_error() {
let result = Client::builder("https://api.example.com")
.tls_root_store(TlsRootStore::Specific)
.build();
let error = match result {
Ok(_) => panic!("specific root store without roots should fail"),
Err(error) => error,
};
match error {
Error::TlsConfig { message, .. } => {
assert!(message.contains("TlsRootStore::Specific"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn custom_root_ca_requires_explicit_root_store() {
let result = Client::builder("https://api.example.com")
.tls_root_ca_der([1_u8, 2, 3, 4])
.build();
let error = match result {
Ok(_) => panic!("custom root ca should require an explicit root store"),
Err(error) => error,
};
match error {
Error::TlsConfig { message, .. } => {
assert!(message.contains("TlsRootStore::System"));
assert!(message.contains("TlsRootStore::Specific"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn rustls_backend_rejects_pkcs12_identity_configuration() {
#[cfg(feature = "async-tls-rustls-ring")]
let backend = Some(TlsBackend::RustlsRing);
#[cfg(all(
not(feature = "async-tls-rustls-ring"),
feature = "async-tls-rustls-aws-lc-rs"
))]
let backend = Some(TlsBackend::RustlsAwsLcRs);
#[cfg(all(
not(feature = "async-tls-rustls-ring"),
not(feature = "async-tls-rustls-aws-lc-rs")
))]
let backend: Option<TlsBackend> = None;
let Some(backend) = backend else {
return;
};
let result = Client::builder("https://api.example.com")
.tls_backend(backend)
.tls_client_identity_pkcs12(vec![0x30, 0x82], "secret")
.build();
let error = match result {
Ok(_) => panic!("rustls should reject pkcs12 identity"),
Err(error) => error,
};
match error {
Error::TlsConfig { message, .. } => {
assert!(message.contains("PKCS#12 identity"));
}
other => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "async-tls-native")]
#[test]
fn native_tls_invalid_pkcs12_identity_returns_tls_config_error() {
let result = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::NativeTls)
.tls_client_identity_pkcs12(vec![1, 2, 3, 4], "secret")
.build();
let error = match result {
Ok(_) => panic!("invalid native tls identity should fail"),
Err(error) => error,
};
match error {
Error::TlsConfig { .. } => {}
other => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "async-tls-native")]
#[test]
fn native_tls_webpki_root_store_is_rejected() {
let result = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::NativeTls)
.tls_root_store(TlsRootStore::WebPki)
.build();
let error = match result {
Ok(_) => panic!("native tls should reject webpki root store"),
Err(error) => error,
};
match error {
Error::TlsConfig { message, .. } => {
assert!(message.contains("TlsRootStore::WebPki"));
}
other => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "blocking-tls-native")]
#[test]
fn blocking_native_tls_webpki_root_store_is_rejected() {
let result = crate::blocking::Client::builder("https://api.example.com")
.tls_backend(TlsBackend::NativeTls)
.tls_root_store(TlsRootStore::WebPki)
.build();
let error = match result {
Ok(_) => panic!("blocking native tls should reject webpki root store"),
Err(error) => error,
};
match error {
Error::TlsConfig { message, .. } => {
assert!(message.contains("TlsRootStore::WebPki"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn no_proxy_rule_matches_domain_and_subdomain() {
let rule = NoProxyRule::parse(".example.com").expect("valid rule");
assert!(rule.matches("example.com", None));
assert!(rule.matches("api.example.com", None));
assert!(!rule.matches("another.com", None));
}
#[test]
fn no_proxy_rule_matches_trailing_dot_fqdn_hosts() {
let rule = NoProxyRule::parse(".example.com.").expect("valid rule");
assert!(rule.matches("example.com.", None));
assert!(rule.matches("api.example.com.", None));
assert!(!rule.matches("another.com.", None));
}
#[test]
fn no_proxy_rule_parses_bracketed_ipv6_with_port() {
let rule = NoProxyRule::parse("[::1]:8080").expect("valid ipv6 rule");
assert!(rule.matches("::1", Some(8080)));
assert!(!rule.matches("::1", Some(8081)));
assert!(!rule.matches("::2", Some(8080)));
}
#[test]
fn no_proxy_rule_keeps_plain_ipv6_without_port() {
let rule = NoProxyRule::parse("2001:db8::1").expect("valid ipv6 rule");
assert!(rule.matches("2001:db8::1", None));
assert!(!rule.matches("2001:db8::2", None));
}
#[test]
fn no_proxy_rule_with_port_requires_matching_port() {
let rule = NoProxyRule::parse("api.example.com:8443").expect("valid host:port rule");
assert!(rule.matches("api.example.com", Some(8443)));
assert!(!rule.matches("api.example.com", Some(443)));
assert!(!rule.matches("api.example.com", None));
}
#[test]
fn no_proxy_rule_accepts_url_authority_without_path() {
let rule = NoProxyRule::parse("https://api.example.com:8443").expect("valid url-shaped rule");
assert!(rule.matches("api.example.com", Some(8443)));
assert!(!rule.matches("api.example.com", Some(443)));
}
#[test]
fn no_proxy_bypass_uses_default_uri_port_when_missing() {
let rules = vec![NoProxyRule::parse("api.example.com:443").expect("valid host:port rule")];
let https_uri: http::Uri = "https://api.example.com/v1"
.parse()
.expect("uri should parse");
let http_uri: http::Uri = "http://api.example.com/v1"
.parse()
.expect("uri should parse");
assert!(should_bypass_proxy_uri(&rules, &https_uri));
assert!(!should_bypass_proxy_uri(&rules, &http_uri));
}
#[test]
fn no_proxy_bypass_matches_trailing_dot_uri_host() {
let rules = vec![NoProxyRule::parse("api.example.com").expect("valid host rule")];
let uri: http::Uri = "https://api.example.com./v1"
.parse()
.expect("uri should parse");
assert!(should_bypass_proxy_uri(&rules, &uri));
}
#[test]
fn no_proxy_rule_rejects_non_numeric_port_suffix() {
assert!(
NoProxyRule::parse("example.com:abc").is_none(),
"non-numeric no_proxy port suffix must be rejected"
);
}
#[test]
fn no_proxy_rule_rejects_url_with_path_query_or_userinfo() {
assert!(NoProxyRule::parse("https://api.example.com/v1").is_none());
assert!(NoProxyRule::parse("https://api.example.com?scope=one").is_none());
assert!(NoProxyRule::parse("https://user:pass@api.example.com").is_none());
}
#[test]
fn no_proxy_rule_rejects_url_with_non_http_scheme() {
assert!(NoProxyRule::parse("ftp://api.example.com").is_none());
assert!(NoProxyRule::parse("socks5://api.example.com:1080").is_none());
}
#[test]
fn no_proxy_rule_rejects_malformed_http_url_shapes() {
assert!(NoProxyRule::parse("http:/api.example.com").is_none());
assert!(NoProxyRule::parse("https:api.example.com").is_none());
}
#[test]
fn no_proxy_rule_rejects_url_with_invalid_authority() {
assert!(NoProxyRule::parse("https://api.example.com:invalid").is_none());
}
#[test]
fn no_proxy_rule_rejects_url_with_empty_port() {
assert!(NoProxyRule::parse("https://api.example.com:/").is_none());
assert!(NoProxyRule::parse("https:///api.example.com").is_none());
}
#[test]
fn try_add_no_proxy_rejects_invalid_rule() {
let result = Client::builder("https://api.example.com").try_add_no_proxy("[::1]not-a-port");
let error = match result {
Ok(_) => panic!("invalid no_proxy rule should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "[::1]not-a-port"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn try_no_proxy_rejects_url_rule_with_path() {
let result =
Client::builder("https://api.example.com").try_no_proxy(["https://api.example.com/v1"]);
let error = match result {
Ok(_) => panic!("url-shaped no_proxy rule with path should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "https://api.example.com/v1"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn try_no_proxy_redacts_sensitive_url_shaped_rule() {
let result = Client::builder("https://api.example.com")
.try_no_proxy(["https://user:pass@api.example.com/v1?token=secret"]);
let error = match result {
Ok(_) => panic!("sensitive invalid no_proxy rule should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "https://api.example.com/v1"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn try_no_proxy_rejects_url_rule_with_non_http_scheme() {
let result = Client::builder("https://api.example.com").try_no_proxy(["ftp://api.example.com"]);
let error = match result {
Ok(_) => panic!("url-shaped no_proxy rule with non-http scheme should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "ftp://api.example.com"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn try_no_proxy_rejects_malformed_http_url_shape() {
let result = Client::builder("https://api.example.com").try_no_proxy(["http:/api.example.com"]);
let error = match result {
Ok(_) => panic!("malformed http no_proxy rule should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "http:/api.example.com"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn try_no_proxy_rejects_url_rule_with_invalid_authority() {
let result = Client::builder("https://api.example.com")
.try_no_proxy(["https://api.example.com:invalid"]);
let error = match result {
Ok(_) => panic!("url-shaped no_proxy rule with invalid authority should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "https://api.example.com:invalid"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn try_no_proxy_rejects_url_rule_with_empty_port() {
let result =
Client::builder("https://api.example.com").try_no_proxy(["https://api.example.com:/"]);
let error = match result {
Ok(_) => panic!("url-shaped no_proxy rule with empty port should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "https://api.example.com:/"),
other => panic!("unexpected error variant: {other}"),
}
}
#[test]
fn try_no_proxy_rejects_invalid_rule() {
let result =
Client::builder("https://api.example.com").try_no_proxy(["example.com", "[::1]not-a-port"]);
let error = match result {
Ok(_) => panic!("invalid no_proxy rule should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
}
#[test]
fn try_no_proxy_rejects_non_numeric_port_suffix() {
let result = Client::builder("https://api.example.com").try_no_proxy(["example.com:abc"]);
let error = match result {
Ok(_) => panic!("invalid no_proxy rule should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
}
#[test]
fn no_proxy_records_invalid_rule_and_build_fails() {
let result = Client::builder("https://api.example.com")
.no_proxy(["example.com", "[::1]not-a-port"])
.build();
let error = match result {
Ok(_) => panic!("invalid no_proxy rule should fail at build time"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
}
#[test]
fn no_proxy_build_error_redacts_sensitive_url_shaped_rule() {
let result = Client::builder("https://api.example.com")
.no_proxy(["https://user:pass@api.example.com/v1?token=secret"])
.build();
let error = match result {
Ok(_) => panic!("sensitive invalid no_proxy rule should fail at build time"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "https://api.example.com/v1"),
other => panic!("unexpected error variant: {other}"),
}
}
#[cfg(feature = "_blocking")]
#[test]
fn blocking_try_add_no_proxy_rejects_invalid_rule() {
let result = crate::blocking::Client::builder("https://api.example.com")
.try_add_no_proxy("[::1]not-a-port");
let error = match result {
Ok(_) => panic!("invalid no_proxy rule should fail"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
}
#[cfg(feature = "_blocking")]
#[test]
fn blocking_no_proxy_records_invalid_rule_and_build_fails() {
let result = crate::blocking::Client::builder("https://api.example.com")
.no_proxy(["example.com", "[::1]not-a-port"])
.build();
let error = match result {
Ok(_) => panic!("invalid no_proxy rule should fail at build time"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
}
#[cfg(feature = "_blocking")]
#[test]
fn blocking_no_proxy_build_error_redacts_sensitive_url_shaped_rule() {
let result = crate::blocking::Client::builder("https://api.example.com")
.no_proxy(["https://user:pass@api.example.com/v1?token=secret"])
.build();
let error = match result {
Ok(_) => panic!("sensitive invalid no_proxy rule should fail at build time"),
Err(error) => error,
};
assert_eq!(error.code(), ErrorCode::InvalidNoProxyRule);
match error {
Error::InvalidNoProxyRule { rule } => assert_eq!(rule, "https://api.example.com/v1"),
other => panic!("unexpected error variant: {other}"),
}
}
#[cfg(feature = "_blocking")]
#[test]
fn blocking_build_rejects_non_http_proxy_scheme() {
let proxy_uri: http::Uri = "https://proxy.example.com:8443"
.parse()
.expect("proxy uri should parse");
let result = crate::blocking::Client::builder("https://api.example.com")
.http_proxy(proxy_uri)
.build();
let error = match result {
Ok(_) => panic!("non-http proxy scheme should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidProxyConfig { proxy_uri, message } => {
assert_eq!(proxy_uri, "https://proxy.example.com:8443/");
assert!(message.contains("http scheme"));
}
other => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "_blocking")]
#[test]
fn blocking_build_rejects_http_proxy_uri_with_invalid_authority() {
let proxy_uri: http::Uri = "http://proxy.example.com:invalid"
.parse()
.expect("proxy uri should parse");
let result = crate::blocking::Client::builder("https://api.example.com")
.http_proxy(proxy_uri)
.build();
let error = match result {
Ok(_) => panic!("http_proxy URI with invalid authority should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidProxyConfig { proxy_uri, message } => {
assert_eq!(proxy_uri, "http://proxy.example.com:invalid/");
assert!(message.contains("valid authority"));
assert!(message.contains("numeric port"));
}
other => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "_blocking")]
#[test]
fn blocking_build_rejects_http_proxy_uri_with_empty_port() {
let proxy_uri: http::Uri = "http://proxy.example.com:/"
.parse()
.expect("proxy uri should parse");
let result = crate::blocking::Client::builder("https://api.example.com")
.http_proxy(proxy_uri)
.build();
let error = match result {
Ok(_) => panic!("http_proxy URI with empty port should fail at build time"),
Err(error) => error,
};
match error {
Error::InvalidProxyConfig { proxy_uri, message } => {
assert_eq!(proxy_uri, "http://proxy.example.com:/");
assert!(message.contains("valid authority"));
assert!(message.contains("numeric port"));
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn ensure_accept_encoding_sets_default_when_absent() {
let mut headers = http::HeaderMap::new();
ensure_accept_encoding_async(&http::Method::GET, &mut headers);
assert_eq!(
headers
.get(http::header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok()),
Some("gzip, br, deflate, zstd")
);
}
#[test]
fn ensure_accept_encoding_skips_default_for_head() {
let mut headers = http::HeaderMap::new();
ensure_accept_encoding_async(&http::Method::HEAD, &mut headers);
assert!(
headers.get(http::header::ACCEPT_ENCODING).is_none(),
"HEAD should not auto-negotiate content-encoding"
);
}
#[test]
fn should_decode_content_encoded_body_only_when_body_semantics_allow() {
assert!(!should_decode_content_encoded_body(
&http::Method::HEAD,
http::StatusCode::OK,
32
));
assert!(!should_decode_content_encoded_body(
&http::Method::GET,
http::StatusCode::NO_CONTENT,
32
));
assert!(!should_decode_content_encoded_body(
&http::Method::GET,
http::StatusCode::NOT_MODIFIED,
32
));
assert!(!should_decode_content_encoded_body(
&http::Method::GET,
http::StatusCode::OK,
0
));
assert!(should_decode_content_encoded_body(
&http::Method::GET,
http::StatusCode::OK,
32
));
}
#[test]
fn decode_content_encoded_body_decodes_gzip_payload() {
let source = br#"{"ok":true}"#;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(source)
.expect("write gzip source bytes should succeed");
let compressed = encoder.finish().expect("finish gzip stream should succeed");
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::CONTENT_ENCODING,
http::HeaderValue::from_static("gzip"),
);
let decoded =
decode_content_encoded_body_limited(bytes::Bytes::from(compressed), &headers, 1024)
.expect("gzip payload should decode");
assert_eq!(decoded.as_ref(), source);
}
#[test]
fn decode_content_encoded_body_rejects_unknown_encoding() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::CONTENT_ENCODING,
http::HeaderValue::from_static("x-custom"),
);
let error =
decode_content_encoded_body_limited(bytes::Bytes::from_static(b"abc"), &headers, 64)
.expect_err("unknown content-encoding should fail");
match error {
DecodeContentEncodingError::Decode { encoding, .. } => assert_eq!(encoding, "x-custom"),
other => panic!("unexpected decode error: {other:?}"),
}
}
#[test]
fn decode_content_encoded_body_limited_rejects_expanded_payload() {
let source = vec![b'a'; 16 * 1024];
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(&source)
.expect("write gzip source bytes should succeed");
let compressed = encoder.finish().expect("finish gzip stream should succeed");
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::CONTENT_ENCODING,
http::HeaderValue::from_static("gzip"),
);
let error = decode_content_encoded_body_limited(bytes::Bytes::from(compressed), &headers, 512)
.expect_err("expanded payload should exceed decode limit");
match error {
DecodeContentEncodingError::TooLarge { actual_bytes } => {
assert!(actual_bytes > 512);
}
other => panic!("unexpected decode error: {other:?}"),
}
}
#[cfg(feature = "async-tls-rustls-ring")]
#[test]
fn selecting_rustls_ring_backend_builds_when_feature_enabled() {
let client = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::RustlsRing)
.build()
.expect("rustls ring backend should build when feature is enabled");
assert_eq!(client.tls_backend(), TlsBackend::RustlsRing);
}
#[cfg(not(feature = "async-tls-rustls-ring"))]
#[test]
fn selecting_rustls_ring_backend_returns_unavailable_when_feature_disabled() {
let result = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::RustlsRing)
.build();
let error = match result {
Ok(_) => panic!("rustls ring backend should be unavailable when feature is disabled"),
Err(error) => error,
};
match error {
Error::TlsBackendUnavailable { backend } => {
assert_eq!(backend, TlsBackend::RustlsRing.as_str());
}
other => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "async-tls-rustls-aws-lc-rs")]
#[test]
fn selecting_rustls_aws_lc_backend_builds_when_feature_enabled() {
let client = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::RustlsAwsLcRs)
.build()
.expect("rustls aws-lc-rs backend should build when feature is enabled");
assert_eq!(client.tls_backend(), TlsBackend::RustlsAwsLcRs);
}
#[cfg(not(feature = "async-tls-rustls-aws-lc-rs"))]
#[test]
fn selecting_rustls_aws_lc_backend_returns_unavailable_when_feature_disabled() {
let result = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::RustlsAwsLcRs)
.build();
let error = match result {
Ok(_) => panic!("rustls aws-lc-rs backend should be unavailable when feature is disabled"),
Err(error) => error,
};
match error {
Error::TlsBackendUnavailable { backend } => {
assert_eq!(backend, TlsBackend::RustlsAwsLcRs.as_str());
}
other => panic!("unexpected error: {other}"),
}
}
#[cfg(feature = "async-tls-native")]
#[test]
fn selecting_native_tls_backend_builds_when_feature_enabled() {
let client = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::NativeTls)
.build()
.expect("native tls backend should build when feature is enabled");
assert_eq!(client.tls_backend(), TlsBackend::NativeTls);
}
#[cfg(not(feature = "async-tls-native"))]
#[test]
fn selecting_native_tls_backend_returns_unavailable_when_feature_disabled() {
let result = Client::builder("https://api.example.com")
.tls_backend(TlsBackend::NativeTls)
.build();
let error = match result {
Ok(_) => panic!("native tls backend should be unavailable when feature is disabled"),
Err(error) => error,
};
match error {
Error::TlsBackendUnavailable { backend } => {
assert_eq!(backend, TlsBackend::NativeTls.as_str());
}
other => panic!("unexpected error: {other}"),
}
}