use crate::http_input::error::HttpInputError;
use reqwest::header::{HeaderName, HeaderValue};
use std::fmt;
use std::time::Duration;
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(15);
pub const DEFAULT_READ_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
pub const STOP_POLL_TICK: Duration = Duration::from_millis(25);
const MAX_HEADER_NAME_BYTES: usize = 8 * 1024;
const MAX_HEADER_VALUE_BYTES: usize = 8 * 1024;
const MAX_HEADER_TOTAL_BYTES: usize = 32 * 1024;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct HttpTimeouts {
pub connect: Duration,
pub response_headers: Duration,
pub read_idle: Option<Duration>,
}
impl Default for HttpTimeouts {
fn default() -> Self {
Self {
connect: DEFAULT_CONNECT_TIMEOUT,
response_headers: DEFAULT_HEADER_TIMEOUT,
read_idle: Some(DEFAULT_READ_IDLE_TIMEOUT),
}
}
}
impl HttpTimeouts {
pub(crate) fn validate(&self) -> Result<(), HttpInputError> {
if self.connect.is_zero() || self.response_headers.is_zero() {
return Err(HttpInputError::InvalidTimeout);
}
if let Some(idle) = self.read_idle {
if idle.is_zero() {
return Err(HttpInputError::InvalidTimeout);
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ReconnectPolicy {
pub enabled: bool,
pub reconnect_streamed: bool,
pub reconnect_at_eof: bool,
pub max_retries: u32,
pub max_delay: Duration,
pub max_total_delay: Duration,
pub respect_retry_after: bool,
pub retry_http_statuses: Vec<u16>,
pub require_validator: bool,
}
impl Default for ReconnectPolicy {
fn default() -> Self {
Self {
enabled: false,
reconnect_streamed: false,
reconnect_at_eof: false,
max_retries: 0,
max_delay: Duration::from_secs(30),
max_total_delay: Duration::ZERO,
respect_retry_after: true,
retry_http_statuses: vec![408, 429, 500, 502, 503, 504],
require_validator: true,
}
}
}
impl ReconnectPolicy {
pub fn seekable_default() -> Self {
Self {
enabled: true,
reconnect_streamed: false,
reconnect_at_eof: false,
max_retries: 5,
max_delay: Duration::from_secs(30),
max_total_delay: Duration::from_secs(60),
respect_retry_after: true,
retry_http_statuses: vec![408, 429, 500, 502, 503, 504],
require_validator: true,
}
}
pub fn streamed_default() -> Self {
Self {
enabled: true,
reconnect_streamed: true,
reconnect_at_eof: true,
max_retries: 5,
max_delay: Duration::from_secs(30),
max_total_delay: Duration::from_secs(60),
respect_retry_after: true,
retry_http_statuses: vec![408, 429, 500, 502, 503, 504],
require_validator: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RootPolicy {
System,
CustomOnly,
}
#[derive(Clone, Default)]
#[non_exhaustive]
pub enum ProxyPolicy {
#[default]
Environment,
Disabled,
Explicit(ProxyConfig),
}
impl fmt::Debug for ProxyPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Environment => f.write_str("Environment"),
Self::Disabled => f.write_str("Disabled"),
Self::Explicit(cfg) => f.debug_tuple("Explicit").field(cfg).finish(),
}
}
}
#[derive(Clone)]
pub struct ProxyConfig {
url: String,
username: Option<String>,
password: Option<String>,
}
impl fmt::Debug for ProxyConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProxyConfig")
.field("url", &redact_proxy_url(&self.url))
.field("username", &self.username.as_ref().map(|_| "***"))
.field("password", &self.password.as_ref().map(|_| "***"))
.finish()
}
}
fn redact_proxy_url(raw: &str) -> String {
match reqwest::Url::parse(raw) {
Ok(mut url) => {
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
url.to_string()
}
Err(_) => "[invalid-proxy-url]".into(),
}
}
impl ProxyConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
username: None,
password: None,
}
}
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub(crate) fn url(&self) -> &str {
&self.url
}
pub(crate) fn username_ref(&self) -> Option<&str> {
self.username.as_deref()
}
pub(crate) fn password_ref(&self) -> Option<&str> {
self.password.as_deref()
}
}
const RESERVED_HEADERS: &[&str] = &[
"host",
"content-length",
"content-range",
"transfer-encoding",
"connection",
"range",
"if-range",
"accept-encoding",
"proxy-authorization",
];
pub(crate) fn is_reserved_header(name: &str) -> bool {
RESERVED_HEADERS
.iter()
.any(|reserved| name.eq_ignore_ascii_case(reserved))
}
pub(crate) fn validate_header(name: &str, value: &str) -> Result<(), HttpInputError> {
if name.len() > MAX_HEADER_NAME_BYTES || value.len() > MAX_HEADER_VALUE_BYTES {
return Err(HttpInputError::HeaderInvalid {
name: name.to_string(),
});
}
if is_reserved_header(name) {
return Err(HttpInputError::HeaderReserved {
name: name.to_string(),
});
}
if HeaderName::from_bytes(name.as_bytes()).is_err()
|| HeaderValue::from_bytes(value.as_bytes()).is_err()
{
return Err(HttpInputError::HeaderInvalid {
name: name.to_string(),
});
}
Ok(())
}
pub(crate) fn validate_header_set(headers: &[(String, String)]) -> Result<(), HttpInputError> {
let mut total = 0usize;
for (name, value) in headers {
validate_header(name, value)?;
total = total.saturating_add(name.len()).saturating_add(value.len());
if total > MAX_HEADER_TOTAL_BYTES {
return Err(HttpInputError::HeaderInvalid { name: name.clone() });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserved_headers_are_rejected() {
for name in ["Accept-Encoding", "RANGE", "host", "Proxy-Authorization"] {
let err = validate_header(name, "x").unwrap_err();
assert!(
matches!(err, HttpInputError::HeaderReserved { .. }),
"{err}"
);
}
}
#[test]
fn authorization_is_allowed() {
validate_header("Authorization", "Bearer abc").unwrap();
validate_header("Cookie", "a=b").unwrap();
}
#[test]
fn zero_timeout_is_rejected() {
let t = HttpTimeouts {
connect: Duration::ZERO,
..Default::default()
};
assert!(matches!(t.validate(), Err(HttpInputError::InvalidTimeout)));
}
#[test]
fn proxy_debug_redacts_password() {
let cfg = ProxyConfig::new("http://127.0.0.1:8080")
.username("u")
.password("super-secret");
let rendered = format!("{cfg:?}");
assert!(!rendered.contains("super-secret"), "{rendered}");
assert!(rendered.contains("***"), "{rendered}");
}
#[test]
fn proxy_debug_redacts_userinfo() {
let cfg = ProxyConfig::new("http://user:hunter2@127.0.0.1:8080").username("u");
let rendered = format!("{cfg:?}");
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(!rendered.contains("user:"), "{rendered}");
assert!(!rendered.contains("username: Some(\"u\")"), "{rendered}");
assert!(rendered.contains("127.0.0.1:8080"), "{rendered}");
}
#[test]
fn require_validator_defaults_true() {
assert!(ReconnectPolicy::default().require_validator);
assert!(ReconnectPolicy::seekable_default().require_validator);
assert!(!ReconnectPolicy::default().enabled);
assert!(ReconnectPolicy::seekable_default().enabled);
}
#[test]
fn streamed_default_has_a_usable_retry_budget() {
let policy = ReconnectPolicy::streamed_default();
assert!(policy.enabled);
assert!(policy.reconnect_streamed);
assert!(policy.reconnect_at_eof);
assert!(
policy.max_retries > 0,
"reconnect_streamed without max_retries is a silent no-op"
);
assert!(policy.require_validator);
}
}