use std::fmt::Write as _;
use std::io;
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
use ureq::config::IpFamily;
const REFUSED: &str = "fetch::get makes no requests under cfg(test)";
pub fn get(
url: &str,
timeout: Duration,
user_agent: Option<&'static str>,
) -> Result<String, ureq::Error> {
if cfg!(test) {
return Err(ureq::Error::Io(io::Error::other(REFUSED)));
}
with_family_fallback(&mut |family| {
agent_for(user_agent, family)
.get(url)
.config()
.timeout_global(Some(timeout))
.build()
.call()?
.body_mut()
.read_to_string()
})
}
static AGENTS: Mutex<Vec<(AgentKey, ureq::Agent)>> = Mutex::new(Vec::new());
type AgentKey = (Option<&'static str>, IpFamily);
fn agent_for(user_agent: Option<&'static str>, family: IpFamily) -> ureq::Agent {
let key = (user_agent, family);
let mut agents = AGENTS.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((_, agent)) = agents.iter().find(|(held, _)| *held == key) {
return agent.clone();
}
let mut config = ureq::Agent::config_builder().ip_family(family);
if let Some(ua) = user_agent {
config = config.user_agent(ua);
}
let agent = config.build().new_agent();
agents.push((key, agent.clone()));
agent
}
pub fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(char::from(byte));
}
other => {
let _ = write!(out, "%{other:02X}");
}
}
}
out
}
fn with_family_fallback(
attempt: &mut dyn FnMut(IpFamily) -> Result<String, ureq::Error>,
) -> Result<String, ureq::Error> {
let original = match attempt(IpFamily::Any) {
Err(e) if is_unroutable(&e) => e,
other => return other,
};
for family in [IpFamily::Ipv4Only, IpFamily::Ipv6Only] {
match attempt(family) {
Err(e) if is_unroutable(&e) || matches!(e, ureq::Error::HostNotFound) => {}
other => return other,
}
}
Err(original)
}
fn is_unroutable(error: &ureq::Error) -> bool {
let ureq::Error::Io(io) = error else {
return false;
};
matches!(
io.kind(),
io::ErrorKind::HostUnreachable
| io::ErrorKind::NetworkUnreachable
| io::ErrorKind::AddrNotAvailable
)
}
#[cfg(test)]
mod tests {
use super::*;
fn unroutable(kind: io::ErrorKind) -> ureq::Error {
ureq::Error::Io(io::Error::from(kind))
}
#[test]
fn one_encoder_serves_symbols_and_place_names() {
let cases = [
("AAPL", "AAPL"),
("^GSPC", "%5EGSPC"),
("EURUSD=X", "EURUSD%3DX"),
("BRK-B", "BRK-B"),
("BRK.B", "BRK.B"),
("Boston", "Boston"),
("New York", "New%20York"),
("a,b", "a%2Cb"),
("Zürich", "Z%C3%BCrich"),
("a-b_c.d~e", "a-b_c.d~e"),
("a/b?c&d#e", "a%2Fb%3Fc%26d%23e"),
];
for (raw, encoded) in cases {
assert_eq!(percent_encode(raw), encoded, "{raw}");
}
let unreserved = |b: u8| b.is_ascii_alphanumeric() || b"-_.~".contains(&b);
let chars = (0..=0xFFFF).chain([0x1_F31E, 0x4_0000, 0x8_0000, 0xC_0000, 0x10_FFFF]);
for c in chars.filter_map(char::from_u32) {
let text = c.to_string();
let expected: String = text
.bytes()
.map(|b| {
if unreserved(b) {
char::from(b).to_string()
} else {
format!("%{b:02X}")
}
})
.collect();
assert_eq!(percent_encode(&text), expected, "U+{:04X}", u32::from(c));
}
}
#[test]
fn requests_with_the_same_settings_share_an_agent() {
const UA: Option<&str> = Some("mirador-test/agent-table");
let held = || {
AGENTS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.filter(|((ua, _), _)| *ua == UA)
.count()
};
for _ in 0..3 {
agent_for(UA, IpFamily::Any);
}
assert_eq!(
held(),
1,
"three asks with one setting built more than one agent"
);
agent_for(UA, IpFamily::Ipv4Only);
assert_eq!(
held(),
2,
"a family pinned for the fallback is its own agent"
);
}
#[test]
fn every_request_is_refused_while_testing() {
let err = get("http://127.0.0.1:9/", Duration::from_secs(1), None)
.expect_err("a request must not be possible from a test");
assert!(
err.to_string().contains(REFUSED),
"the refusal must come from the cfg(test) guard, not from a socket: {err}"
);
}
#[test]
fn an_unroutable_connect_is_retried_one_family_at_a_time() {
let mut tried = Vec::new();
let result = with_family_fallback(&mut |family| {
tried.push(family);
match family {
IpFamily::Ipv4Only => Ok("body".to_string()),
_ => Err(unroutable(io::ErrorKind::HostUnreachable)),
}
});
assert_eq!(result.unwrap(), "body");
assert_eq!(tried, vec![IpFamily::Any, IpFamily::Ipv4Only]);
}
#[test]
fn a_refused_connect_is_not_retried() {
let mut attempts = 0;
let result = with_family_fallback(&mut |_| {
attempts += 1;
Err(unroutable(io::ErrorKind::ConnectionRefused))
});
assert!(result.is_err());
assert_eq!(attempts, 1);
}
#[test]
fn a_status_error_from_a_retry_is_the_answer() {
let result = with_family_fallback(&mut |family| match family {
IpFamily::Any => Err(unroutable(io::ErrorKind::HostUnreachable)),
_ => Err(ureq::Error::StatusCode(429)),
});
assert!(matches!(result, Err(ureq::Error::StatusCode(429))));
}
#[test]
fn a_failed_fallback_reports_the_original_error() {
let result = with_family_fallback(&mut |family| match family {
IpFamily::Any => Err(unroutable(io::ErrorKind::HostUnreachable)),
IpFamily::Ipv4Only => Err(ureq::Error::HostNotFound),
IpFamily::Ipv6Only => Err(unroutable(io::ErrorKind::NetworkUnreachable)),
});
match result {
Err(ureq::Error::Io(e)) => assert_eq!(e.kind(), io::ErrorKind::HostUnreachable),
other => panic!("expected the original io error back, got {other:?}"),
}
}
#[test]
fn only_routing_level_failures_trigger_the_fallback() {
for kind in [
io::ErrorKind::HostUnreachable,
io::ErrorKind::NetworkUnreachable,
io::ErrorKind::AddrNotAvailable,
] {
assert!(is_unroutable(&unroutable(kind)), "{kind:?} should trigger");
}
for kind in [io::ErrorKind::ConnectionRefused, io::ErrorKind::TimedOut] {
assert!(!is_unroutable(&unroutable(kind)), "{kind:?} should not");
}
assert!(!is_unroutable(&ureq::Error::StatusCode(500)));
}
}