mod http_sender;
mod reqwest_sender;
use std::io::Read;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub(crate) use http_sender::{DefaultRng, ThreadSleeper};
pub use http_sender::{Headers, HttpRequest, HttpResponse, HttpSender, Rng, Sleeper};
pub(crate) use reqwest_sender::ReqwestSender;
use serde_json::{Map, Value};
use crate::errors::{Api2ConvertError, Result};
use crate::version::VERSION;
const RETRYABLE_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
const MAX_BACKOFF: f64 = 8.0;
const MAX_RETRY_AFTER: Duration = Duration::from_secs(120);
pub(crate) struct Transport {
api_key: String,
base_url: String,
timeout: Duration,
max_retries: u32,
poll_interval: Duration,
poll_max_interval: Duration,
poll_timeout: Duration,
max_download_bytes: u64,
sender: Arc<dyn HttpSender>,
sleeper: Arc<dyn Sleeper>,
rng: Arc<dyn Rng>,
}
#[allow(clippy::too_many_arguments)]
impl Transport {
pub(crate) fn new(
api_key: String,
base_url: String,
timeout: Duration,
max_retries: u32,
poll_interval: Duration,
poll_max_interval: Duration,
poll_timeout: Duration,
max_download_bytes: u64,
sender: Arc<dyn HttpSender>,
sleeper: Arc<dyn Sleeper>,
rng: Arc<dyn Rng>,
) -> Self {
Transport {
api_key,
base_url,
timeout,
max_retries,
poll_interval,
poll_max_interval,
poll_timeout,
max_download_bytes,
sender,
sleeper,
rng,
}
}
pub(crate) fn poll_interval(&self) -> Duration {
self.poll_interval
}
pub(crate) fn poll_max_interval(&self) -> Duration {
self.poll_max_interval
}
pub(crate) fn poll_timeout(&self) -> Duration {
self.poll_timeout
}
pub(crate) fn max_download_bytes(&self) -> u64 {
self.max_download_bytes
}
#[cfg(test)]
pub(crate) fn debug_base_url(&self) -> String {
self.base_url.clone()
}
fn user_agent(&self) -> String {
format!("api2convert-rust/{} ({})", VERSION, std::env::consts::OS)
}
fn base_headers(&self) -> Vec<(String, String)> {
vec![
("Accept".to_string(), "application/json".to_string()),
("User-Agent".to_string(), self.user_agent()),
]
}
fn build_url(&self, path: &str, query: &[(&str, String)]) -> String {
let mut url = format!("{}/{}", self.base_url, path.trim_start_matches('/'));
if !query.is_empty() {
url.push('?');
let encoded: Vec<String> = query
.iter()
.map(|(k, v)| format!("{}={}", pct_encode(k), pct_encode(v)))
.collect();
url.push_str(&encoded.join("&"));
}
url
}
pub(crate) fn account_request(
&self,
method: &str,
path: &str,
query: &[(&str, String)],
body: Option<Value>,
idempotency_key: Option<&str>,
) -> Result<Value> {
let mut headers = self.base_headers();
headers.push(("X-Api2convert-Api-Key".to_string(), self.api_key.clone()));
let body_bytes = match &body {
Some(v) => {
headers.push(("Content-Type".to_string(), "application/json".to_string()));
Some(serde_json::to_vec(v).map_err(|e| {
Api2ConvertError::Config(format!("failed to encode request body: {e}"))
})?)
}
None => None,
};
if let Some(key) = idempotency_key {
if !key.is_empty() {
headers.push(("Idempotency-Key".to_string(), key.to_string()));
}
}
let req = HttpRequest {
method: method.to_string(),
url: self.build_url(path, query),
headers,
body: body_bytes,
make_body: None,
follow_redirects: false,
replayable: true,
timeout: Some(self.timeout),
};
self.send(req)
}
pub(crate) fn send(&self, req: HttpRequest) -> Result<Value> {
let resp = self.execute(req)?;
self.interpret(resp)
}
fn execute(&self, req: HttpRequest) -> Result<HttpResponse> {
let idempotent = is_idempotent(&req);
let mut attempt: u32 = 0;
loop {
attempt += 1;
match self.sender.send(&req) {
Ok(resp) => {
if attempt <= self.max_retries && RETRYABLE_STATUSES.contains(&resp.status) {
let retry = if resp.status == 429 {
req.replayable
} else {
req.replayable && idempotent
};
if retry {
let delay = self.retry_delay(attempt, resp.headers.get("retry-after"));
drop(resp);
self.sleeper.sleep(delay);
continue;
}
}
return Ok(resp);
}
Err(e) => {
if attempt <= self.max_retries && req.replayable && idempotent {
let delay = self.retry_delay(attempt, None);
self.sleeper.sleep(delay);
continue;
}
return Err(e);
}
}
}
}
fn interpret(&self, mut resp: HttpResponse) -> Result<Value> {
if (300..400).contains(&resp.status) {
return Err(Api2ConvertError::Network(
"the API responded with an unexpected redirect".to_string(),
));
}
let bytes = read_all(&mut resp.body)?;
if resp.status >= 400 {
return Err(self.error_from(resp.status, &resp.headers, &bytes));
}
if bytes.is_empty() {
return Ok(Value::Object(Map::new()));
}
serde_json::from_slice(&bytes).map_err(|_| {
Api2ConvertError::Network("the API returned a non-JSON response".to_string())
})
}
fn error_from(&self, status: u16, headers: &Headers, bytes: &[u8]) -> Api2ConvertError {
let raw: Value = serde_json::from_slice(bytes).unwrap_or(Value::Null);
let body = crate::redact::redact_body(&raw);
let message = body
.get("message")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| status_reason(status));
let request_id = headers.get("x-request-id").map(str::to_string);
let mut err = Api2ConvertError::api(status, message, request_id, body);
if status == 429 {
if let Api2ConvertError::RateLimit { retry_after, .. } = &mut err {
*retry_after = headers
.get("retry-after")
.and_then(parse_retry_after)
.map(|d| d.as_secs() as i64);
}
}
err
}
fn retry_delay(&self, attempt: u32, retry_after: Option<&str>) -> Duration {
if let Some(h) = retry_after {
if let Some(d) = parse_retry_after(h) {
return d.min(MAX_RETRY_AFTER);
}
}
let base = 0.5 * 2f64.powi((attempt - 1) as i32);
let capped = base.min(MAX_BACKOFF);
let jitter = 1.0 + 0.25 * self.rng.next_f64();
Duration::from_secs_f64(capped * jitter)
}
pub(crate) fn poll_pause(&self, interval: Duration) {
let jitter = 1.0 + 0.25 * self.rng.next_f64();
self.sleeper
.sleep(Duration::from_secs_f64(interval.as_secs_f64() * jitter));
}
pub(crate) fn open_download(
&self,
uri: &str,
download_password: Option<&str>,
) -> Result<HttpResponse> {
let mut headers = vec![("User-Agent".to_string(), self.user_agent())];
if let Some(pw) = download_password {
if !pw.is_empty() {
headers.push((
"X-Api2convert-Download-Password".to_string(),
pw.to_string(),
));
}
}
let carries_secret = headers.iter().any(|(k, _)| {
let lk = k.to_ascii_lowercase();
lk.starts_with("x-api2convert-") || lk.starts_with("x-oc-")
});
let req = HttpRequest {
method: "GET".to_string(),
url: uri.to_string(),
headers,
body: None,
make_body: None,
follow_redirects: !carries_secret,
replayable: true,
timeout: None,
};
let mut resp = self.execute(req)?;
if (300..400).contains(&resp.status) {
return Err(Api2ConvertError::Network(
"the download did not resolve: a redirect was not followed because the request \
carried a secret header"
.to_string(),
));
}
if resp.status >= 400 {
let bytes = read_all(&mut resp.body).unwrap_or_default();
return Err(self.error_from(resp.status, &resp.headers, &bytes));
}
Ok(resp)
}
}
fn is_idempotent(req: &HttpRequest) -> bool {
let method_ok = matches!(
req.method.to_ascii_uppercase().as_str(),
"GET" | "HEAD" | "PUT" | "DELETE" | "OPTIONS" | "TRACE"
);
let has_idempotency_key = req
.headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("idempotency-key") && !v.is_empty());
method_ok || has_idempotency_key
}
fn read_all(body: &mut Box<dyn Read + Send>) -> Result<Vec<u8>> {
let mut buf = Vec::new();
body.read_to_end(&mut buf)
.map_err(|_| Api2ConvertError::Network("failed to read response body".to_string()))?;
Ok(buf)
}
pub(crate) fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub(crate) fn encode_segment(s: &str) -> String {
pct_encode(s)
}
fn parse_retry_after(value: &str) -> Option<Duration> {
let v = value.trim();
if let Ok(secs) = v.parse::<i64>() {
return Some(Duration::from_secs(secs.max(0) as u64));
}
let target = parse_http_date(v)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Some(Duration::from_secs((target - now).max(0) as u64))
}
fn parse_http_date(s: &str) -> Option<i64> {
let tokens: Vec<&str> = s.split_whitespace().collect();
if tokens.len() < 5 {
return None;
}
let day: i64 = tokens[1].parse().ok()?;
if !(1..=31).contains(&day) {
return None;
}
let month = month_num(tokens[2])?;
let year: i64 = tokens[3].parse().ok()?;
if !(0..=999_999).contains(&year) {
return None;
}
let time: Vec<&str> = tokens[4].split(':').collect();
if time.len() != 3 {
return None;
}
let hh: i64 = time[0].parse().ok()?;
let mm: i64 = time[1].parse().ok()?;
let ss: i64 = time[2].parse().ok()?;
let days = days_from_civil(year, month, day);
Some(days * 86400 + hh * 3600 + mm * 60 + ss)
}
fn month_num(m: &str) -> Option<i64> {
Some(match m {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
})
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn status_reason(status: u16) -> String {
let reason = match status {
400 => "Bad Request",
401 => "Unauthorized",
402 => "Payment Required",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
422 => "Unprocessable Entity",
429 => "Too Many Requests",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
_ => "",
};
if reason.is_empty() {
format!("HTTP {status}")
} else {
format!("HTTP {status} {reason}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pct_encode_keeps_unreserved_and_escapes_the_rest() {
assert_eq!(pct_encode("abcABC123-._~"), "abcABC123-._~");
assert_eq!(pct_encode("a b/c?d"), "a%20b%2Fc%3Fd");
assert_eq!(encode_segment("../evil"), "..%2Fevil");
}
#[test]
fn retry_after_seconds_form() {
assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5)));
assert_eq!(parse_retry_after(" 0 "), Some(Duration::from_secs(0)));
assert_eq!(parse_retry_after("-3"), Some(Duration::from_secs(0)));
assert_eq!(parse_retry_after("garbage"), None);
}
#[test]
fn days_from_civil_known_dates() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(days_from_civil(1970, 1, 2), 1);
assert_eq!(days_from_civil(2000, 1, 1), 10957);
}
#[test]
fn http_date_parses_to_unix_seconds() {
assert_eq!(
parse_http_date("Wed, 21 Oct 2015 07:28:00 GMT"),
Some(1_445_412_480)
);
assert_eq!(parse_http_date("not a date"), None);
}
#[test]
fn http_date_rejects_absurd_values_without_overflowing() {
assert_eq!(
parse_http_date("Mon, 01 Jan 300000000000 00:00:00 GMT"),
None
);
assert_eq!(
parse_http_date("Mon, 01 Jan 99999999999999999 00:00:00 GMT"),
None
);
assert_eq!(parse_http_date("Mon, 99 Jan 2020 00:00:00 GMT"), None);
}
#[test]
fn idempotency_rules() {
let mk = |method: &str, headers: Vec<(String, String)>| HttpRequest {
method: method.to_string(),
url: "http://x".to_string(),
headers,
body: None,
make_body: None,
follow_redirects: false,
replayable: true,
timeout: None,
};
assert!(is_idempotent(&mk("GET", vec![])));
assert!(is_idempotent(&mk("delete", vec![])));
assert!(!is_idempotent(&mk("POST", vec![])));
assert!(is_idempotent(&mk(
"POST",
vec![("Idempotency-Key".into(), "abc".into())]
)));
assert!(!is_idempotent(&mk(
"POST",
vec![("Idempotency-Key".into(), "".into())]
)));
}
}