use anyhow::{Context, Result};
use std::io::Read;
use std::time::Duration;
pub fn base_url() -> String {
std::env::var("SWAPDEX_UPSTREAM")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "https://api.anthropic.com".to_string())
}
pub struct Upstream {
pub status: u16,
pub headers: Vec<(String, String)>,
pub reader: Box<dyn Read + Send>,
}
pub fn explain_failure(up: &mut Upstream) -> Option<String> {
const CAP: u64 = 64 * 1024;
if up.status < 400 {
return None;
}
let mut buf = Vec::new();
let mut limited = (&mut up.reader).take(CAP);
let _ = limited.read_to_end(&mut buf);
let why = why_refused(&buf);
up.reader = Box::new(std::io::Cursor::new(buf));
Some(why)
}
pub fn why_refused(body: &[u8]) -> String {
const LIMIT: usize = 300;
let text = String::from_utf8_lossy(body);
let pick = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| {
for path in [&["error", "message"][..], &["detail"][..], &["message"][..]] {
let mut cur = &v;
for key in path {
match cur.get(key) {
Some(next) => cur = next,
None => {
cur = &serde_json::Value::Null;
break;
}
}
}
if let Some(s) = cur.as_str().map(str::trim).filter(|s| !s.is_empty()) {
return Some(s.to_string());
}
}
None
});
let flat = match pick {
Some(s) => s,
None => text.split_whitespace().collect::<Vec<_>>().join(" "),
};
if flat.is_empty() {
return "(empty response body)".to_string();
}
if flat.chars().count() > LIMIT {
return flat.chars().take(LIMIT - 1).collect::<String>() + "…";
}
flat
}
fn waits() -> (Duration, Duration) {
if std::env::var_os("SWAPDEX_ROOT").is_some() {
if let Some(ms) = std::env::var("SWAPDEX_UPSTREAM_WAIT_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
{
return (Duration::from_millis(ms), Duration::from_millis(ms));
}
}
(Duration::from_secs(10), Duration::from_secs(300))
}
pub fn agent() -> ureq::Agent {
let (short, headers) = waits();
agent_with(short, headers)
}
fn agent_with(short: Duration, headers: Duration) -> ureq::Agent {
ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_resolve(Some(short))
.timeout_connect(Some(short))
.timeout_recv_response(Some(headers))
.build()
.into()
}
fn collect_headers<T>(resp: &ureq::http::Response<T>) -> Vec<(String, String)> {
resp.headers()
.iter()
.filter_map(|(n, v)| {
v.to_str()
.ok()
.map(|s| (n.as_str().to_string(), s.to_string()))
})
.collect()
}
pub fn worth_retrying(err: &str) -> bool {
let e = err.to_ascii_lowercase();
if e.contains("certificate") || e.contains("http status") {
return false;
}
if e.contains("receive response") || e.contains("recv response") {
return false;
}
e.contains("lookup address")
|| e.contains("broken pipe")
|| e.contains("no route to host")
|| e.contains("unexpected end of file")
|| e.contains("connection reset")
|| e.contains("connection refused")
|| e.contains("timeout")
}
pub fn forward(
agent: &ureq::Agent,
method: &str,
url: &str,
headers: &[(String, String)],
body: &[u8],
) -> Result<Upstream> {
const TRIES: u32 = 4;
let mut attempt = 0u32;
loop {
match forward_once(agent, method, url, headers, body) {
Ok(u) => return Ok(u),
Err(e) => {
let text = format!("{e:#}");
if attempt + 1 >= TRIES || !worth_retrying(&text) {
return Err(e);
}
std::thread::sleep(std::time::Duration::from_millis(250u64 << attempt));
attempt += 1;
}
}
}
}
fn forward_once(
agent: &ureq::Agent,
method: &str,
url: &str,
headers: &[(String, String)],
body: &[u8],
) -> Result<Upstream> {
let bodyless = matches!(
method.to_ascii_uppercase().as_str(),
"GET" | "HEAD" | "DELETE" | "OPTIONS"
);
if bodyless {
let mut rb = match method.to_ascii_uppercase().as_str() {
"HEAD" => agent.head(url),
"DELETE" => agent.delete(url),
"OPTIONS" => agent.options(url),
_ => agent.get(url),
};
for (k, v) in headers {
rb = rb.header(k.as_str(), v.as_str());
}
let resp = rb.call().context("upstream request failed")?;
let status = resp.status().as_u16();
let headers = collect_headers(&resp);
return Ok(Upstream {
status,
headers,
reader: Box::new(resp.into_body().into_reader()),
});
}
let mut rb = match method.to_ascii_uppercase().as_str() {
"PUT" => agent.put(url),
"PATCH" => agent.patch(url),
_ => agent.post(url),
};
for (k, v) in headers {
rb = rb.header(k.as_str(), v.as_str());
}
let resp = rb.send(body).context("upstream request failed")?;
let status = resp.status().as_u16();
let headers = collect_headers(&resp);
Ok(Upstream {
status,
headers,
reader: Box::new(resp.into_body().into_reader()),
})
}
#[cfg(test)]
mod failure_tests {
use super::*;
#[test]
fn an_error_body_is_reduced_to_the_sentence_that_explains_it() {
assert_eq!(
why_refused(br#"{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: must be <= 8192"}}"#),
"max_tokens: must be <= 8192"
);
assert_eq!(
why_refused(br#"{"detail":"Store must be set to false"}"#),
"Store must be set to false"
);
}
#[test]
fn a_body_that_is_not_json_is_still_reported() {
assert_eq!(why_refused(b" Bad Gateway\n\n"), "Bad Gateway");
assert_eq!(why_refused(b""), "(empty response body)");
}
#[test]
fn a_long_body_is_cut_rather_than_flooding_the_log() {
let long = format!("{{\"detail\":\"{}\"}}", "x".repeat(900));
let got = why_refused(long.as_bytes());
assert!(got.chars().count() <= 300, "{}", got.chars().count());
assert!(got.ends_with('…'), "{got}");
}
}
#[cfg(test)]
mod explain_tests {
use super::*;
fn resp(status: u16, body: &str) -> Upstream {
Upstream {
status,
headers: Vec::new(),
reader: Box::new(std::io::Cursor::new(body.as_bytes().to_vec())),
}
}
#[test]
fn a_failure_is_explained_and_its_body_still_reaches_the_client() {
let mut up = resp(
400,
r#"{"error":{"message":"max_tokens: must be <= 8192"}}"#,
);
assert_eq!(
explain_failure(&mut up).as_deref(),
Some("max_tokens: must be <= 8192")
);
let mut back = String::new();
up.reader.read_to_string(&mut back).unwrap();
assert_eq!(
back,
r#"{"error":{"message":"max_tokens: must be <= 8192"}}"#
);
}
#[test]
fn a_success_is_not_read_at_all() {
let mut up = resp(200, "event: message_start\n\n");
assert_eq!(explain_failure(&mut up), None);
let mut back = String::new();
up.reader.read_to_string(&mut back).unwrap();
assert_eq!(back, "event: message_start\n\n");
}
}
#[cfg(test)]
mod transient_retry_tests {
use super::*;
#[test]
fn transient_transport_errors_are_worth_another_try() {
assert!(worth_retrying("io: failed to lookup address information"));
assert!(worth_retrying("io: Broken pipe (os error 32)"));
assert!(worth_retrying("io: No route to host"));
assert!(worth_retrying("io: unexpected end of file"));
assert!(worth_retrying("timeout: global"));
assert!(!worth_retrying("http status 401"));
assert!(!worth_retrying("certificate verification failed"));
}
}
#[cfg(test)]
mod wait_tests {
use super::*;
#[test]
fn the_short_waits_are_bounded_and_the_body_is_not() {
let (short, headers) = waits();
assert!(short.as_secs() > 0 && short.as_secs() <= 30, "{short:?}");
assert!(
headers > short,
"the response headers get more room than a connect: {headers:?} vs {short:?}"
);
assert!(
headers.as_secs() >= 120,
"too tight for a slow first token: {headers:?}"
);
}
#[test]
fn an_upstream_that_never_answers_becomes_an_error() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let mut held = Vec::new();
for c in listener.incoming() {
held.push(c);
}
});
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let agent = agent_with(Duration::from_millis(300), Duration::from_millis(300));
let out = forward(
&agent,
"POST",
&format!("http://127.0.0.1:{port}/v1/messages"),
&[],
b"{}",
);
let _ = tx.send(out.is_err());
});
match rx.recv_timeout(Duration::from_secs(20)) {
Ok(is_err) => assert!(is_err, "silence must not read as success"),
Err(_) => panic!("the relay never returned - the upstream wait is unbounded"),
}
}
#[test]
fn a_response_timeout_is_terminal_but_a_connect_one_is_not() {
assert!(
!worth_retrying("timeout: receive response"),
"four tries inside four is sixteen response budgets"
);
assert!(!worth_retrying("timeout: recv response"));
assert!(worth_retrying("timeout: connect"));
assert!(worth_retrying("connection reset by peer"));
assert!(!worth_retrying("invalid certificate"));
}
}