use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::error::{AreevError, Result};
use crate::types::*;
const RETRY_DELAYS_MS: [u64; 3] = [100, 500, 2000];
pub struct HttpClient {
client: reqwest::Client,
base_url: String,
memory_id: String,
api_key: Option<String>,
max_retries: usize,
}
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub struct HttpClientBuilder {
base_url: Option<String>,
memory_id: Option<String>,
api_key: Option<String>,
timeout: Duration,
connect_timeout: Duration,
max_retries: usize,
root_certs: Vec<reqwest::Certificate>,
danger_invalid_certs: bool,
pool_max_idle_per_host: Option<usize>,
}
impl Default for HttpClientBuilder {
fn default() -> Self {
Self {
base_url: None,
memory_id: None,
api_key: None,
timeout: DEFAULT_REQUEST_TIMEOUT,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
max_retries: 3,
root_certs: Vec::new(),
danger_invalid_certs: false,
pool_max_idle_per_host: None,
}
}
}
impl HttpClientBuilder {
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn memory_id(mut self, id: impl Into<String>) -> Self {
self.memory_id = Some(id.into());
self
}
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn timeout(mut self, t: Duration) -> Self {
self.timeout = t;
self
}
pub fn connect_timeout(mut self, t: Duration) -> Self {
self.connect_timeout = t;
self
}
pub fn max_retries(mut self, n: usize) -> Self {
self.max_retries = n;
self
}
pub fn root_cert(mut self, cert: reqwest::Certificate) -> Self {
self.root_certs.push(cert);
self
}
pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
self.danger_invalid_certs = accept;
self
}
pub fn pool_max_idle_per_host(mut self, n: usize) -> Self {
self.pool_max_idle_per_host = Some(n);
self
}
pub fn build(self) -> Result<HttpClient> {
let base_url = self
.base_url
.ok_or_else(|| AreevError::Other {
http_status: 0,
code: Some("CFG-E001".into()),
message: "HttpClientBuilder: base_url is required".into(),
body: serde_json::Value::Null,
request_id: None,
})?
.trim_end_matches('/')
.to_string();
let memory_id = self.memory_id.ok_or_else(|| AreevError::Other {
http_status: 0,
code: Some("CFG-E002".into()),
message: "HttpClientBuilder: memory_id is required".into(),
body: serde_json::Value::Null,
request_id: None,
})?;
let mut cb = reqwest::Client::builder()
.timeout(self.timeout)
.connect_timeout(self.connect_timeout);
for cert in self.root_certs {
cb = cb.add_root_certificate(cert);
}
if self.danger_invalid_certs {
cb = cb.danger_accept_invalid_certs(true);
}
if let Some(n) = self.pool_max_idle_per_host {
cb = cb.pool_max_idle_per_host(n);
}
let client = cb.build().map_err(|e| AreevError::Other {
http_status: 0,
code: Some("CFG-E003".into()),
message: format!("reqwest client build failed: {e}"),
body: serde_json::Value::Null,
request_id: None,
})?;
Ok(HttpClient {
client,
base_url,
memory_id,
api_key: self.api_key,
max_retries: self.max_retries,
})
}
}
impl HttpClient {
pub fn builder() -> HttpClientBuilder {
HttpClientBuilder::default()
}
pub fn new(base_url: &str, memory_id: &str, api_key: Option<&str>) -> Self {
Self::builder()
.base_url(base_url)
.memory_id(memory_id)
.api_key(api_key.unwrap_or("").to_string())
.build()
.unwrap_or_else(|_| Self {
client: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
memory_id: memory_id.to_string(),
api_key: api_key.map(|s| s.to_string()),
max_retries: 3,
})
}
pub fn with_max_retries(mut self, max_retries: usize) -> Self {
self.max_retries = max_retries;
self
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn memory_id(&self) -> &str {
&self.memory_id
}
fn url(&self, path: &str) -> String {
format!("{}/api/memories/{}/{}", self.base_url, self.memory_id, path)
}
fn full_url(&self, path: &str) -> String {
if let Some(stripped) = path.strip_prefix('/') {
format!("{}/api/{}", self.base_url, stripped)
} else {
format!("{}/api/{}", self.base_url, path)
}
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let mut req = self.client.request(method, self.url(path));
if let Some(ref key) = self.api_key {
req = req.bearer_auth(key);
}
req
}
fn raw_request(&self, method: reqwest::Method, full_url: &str) -> reqwest::RequestBuilder {
let mut req = self.client.request(method, full_url);
if let Some(ref key) = self.api_key {
req = req.bearer_auth(key);
}
req
}
fn log_request(method: &str, path: &str, sensitive: bool) {
if sensitive {
tracing::debug!(target: "areev::http", method, path, "areev request (body redacted)");
} else {
tracing::debug!(target: "areev::http", method, path, "areev request");
}
}
pub async fn _get(
&self,
path: &str,
query: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
Self::log_request("GET", path, false);
let url = self.full_url(path);
let url_ref = &url;
let query_ref = query;
self.send_with_retry("GET", path, move || {
let mut req = self.raw_request(reqwest::Method::GET, url_ref);
if let Some(q) = query_ref {
req = req.query(q);
}
req
})
.await
}
pub async fn _post(
&self,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
Self::log_request("POST", path, false);
let url = self.full_url(path);
let url_ref = &url;
let body_ref = body;
self.send_with_retry("POST", path, move || {
let req = self.raw_request(reqwest::Method::POST, url_ref);
match body_ref {
Some(b) => req.json(b),
None => req,
}
})
.await
}
pub async fn _get_bytes(
&self,
path: &str,
query: Option<&serde_json::Value>,
) -> Result<Vec<u8>> {
Self::log_request("GET", path, false);
let url = self.full_url(path);
let idem_key = new_idempotency_key();
let mut last_err: Option<AreevError> = None;
for attempt in 0..=self.max_retries {
let mut req = self.raw_request(reqwest::Method::GET, &url);
if let Some(q) = query {
req = req.query(q);
}
req = req.header("Idempotency-Key", &idem_key);
let mut retry_after: Option<Duration> = None;
match req.send().await {
Ok(resp) => {
if resp.status().as_u16() == 429 {
retry_after = parse_retry_after(
resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
);
}
match Self::interpret_bytes_response(resp).await {
Ok(bytes) => return Ok(bytes),
Err(e) => {
let retryable = e.is_retryable();
last_err = Some(e);
if !retryable {
return Err(last_err.unwrap());
}
}
}
}
Err(e) => {
last_err = Some(AreevError::from(e));
}
}
if attempt >= self.max_retries {
break;
}
let sleep_dur = retry_after.unwrap_or_else(|| {
let base = RETRY_DELAYS_MS[attempt.min(RETRY_DELAYS_MS.len() - 1)];
Duration::from_millis(jittered_delay_ms(base, attempt))
});
tokio::time::sleep(sleep_dur).await;
}
Err(last_err.unwrap_or_else(|| AreevError::Other {
http_status: 0,
code: None,
message: "retry loop exited without an error".into(),
body: serde_json::Value::Null,
request_id: None,
}))
}
pub async fn _post_raw(
&self,
path: &str,
body: String,
content_type: &str,
) -> Result<serde_json::Value> {
Self::log_request("POST", path, false);
let url = self.full_url(path);
let url_ref = &url;
let body_ref = &body;
self.send_with_retry("POST", path, move || {
self.raw_request(reqwest::Method::POST, url_ref)
.header("Content-Type", content_type)
.body(body_ref.clone())
})
.await
}
pub async fn _post_no_retry(
&self,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
Self::log_request("POST", path, false);
let url = self.full_url(path);
let mut req = self.raw_request(reqwest::Method::POST, &url);
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().await?;
Self::interpret_response(resp).await
}
pub async fn _put(
&self,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
Self::log_request("PUT", path, false);
let url = self.full_url(path);
let url_ref = &url;
let body_ref = body;
self.send_with_retry("PUT", path, move || {
let req = self.raw_request(reqwest::Method::PUT, url_ref);
match body_ref {
Some(b) => req.json(b),
None => req,
}
})
.await
}
pub async fn _patch(
&self,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
Self::log_request("PATCH", path, false);
let url = self.full_url(path);
let url_ref = &url;
let body_ref = body;
self.send_with_retry("PATCH", path, move || {
let req = self.raw_request(reqwest::Method::PATCH, url_ref);
match body_ref {
Some(b) => req.json(b),
None => req,
}
})
.await
}
pub async fn _delete(&self, path: &str) -> Result<serde_json::Value> {
Self::log_request("DELETE", path, false);
let url = self.full_url(path);
let url_ref = &url;
self.send_with_retry("DELETE", path, move || {
self.raw_request(reqwest::Method::DELETE, url_ref)
})
.await
}
pub async fn _delete_no_retry(&self, path: &str) -> Result<serde_json::Value> {
Self::log_request("DELETE", path, false);
let url = self.full_url(path);
let req = self.raw_request(reqwest::Method::DELETE, &url);
let resp = req.send().await?;
Self::interpret_response(resp).await
}
pub async fn _get_sensitive(
&self,
path: &str,
query: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
Self::log_request("GET", path, true);
let url = self.full_url(path);
let url_ref = &url;
let query_ref = query;
self.send_with_retry("GET", path, move || {
let mut req = self.raw_request(reqwest::Method::GET, url_ref);
if let Some(q) = query_ref {
req = req.query(q);
}
req
})
.await
}
pub async fn _post_sensitive(
&self,
path: &str,
body: Option<&serde_json::Value>,
retry: bool,
) -> Result<serde_json::Value> {
Self::log_request("POST", path, true);
if !retry {
let url = self.full_url(path);
let mut req = self.raw_request(reqwest::Method::POST, &url);
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().await?;
return Self::interpret_response(resp).await;
}
let url = self.full_url(path);
let url_ref = &url;
let body_ref = body;
self.send_with_retry("POST", path, move || {
let req = self.raw_request(reqwest::Method::POST, url_ref);
match body_ref {
Some(b) => req.json(b),
None => req,
}
})
.await
}
pub async fn _post_if_match_no_retry(
&self,
path: &str,
body: Option<&serde_json::Value>,
if_match: Option<&str>,
) -> Result<serde_json::Value> {
Self::log_request("POST", path, true);
let url = self.full_url(path);
let mut req = self.raw_request(reqwest::Method::POST, &url);
if let Some(etag) = if_match {
req = req.header("If-Match", etag);
}
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().await?;
Self::interpret_response(resp).await
}
pub async fn _post_multipart(
&self,
path: &str,
form: reqwest::multipart::Form,
) -> Result<serde_json::Value> {
Self::log_request("POST", path, false);
let url = self.full_url(path);
let req = self
.raw_request(reqwest::Method::POST, &url)
.multipart(form);
let resp = req.send().await?;
Self::interpret_response(resp).await
}
pub async fn _stream_sse(
&self,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<SseStream> {
Self::log_request("POST", path, false);
let url = self.full_url(path);
let mut req = self
.raw_request(reqwest::Method::POST, &url)
.header("Accept", "text/event-stream");
if let Some(b) = body {
req = req.json(b);
}
let resp = req.send().await?;
let status = resp.status().as_u16();
if status >= 400 {
let request_id = resp
.headers()
.get("x-request-id")
.or_else(|| resp.headers().get("x-areev-request-id"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let text = resp.text().await.unwrap_or_default();
let (message, code, body) = parse_error_body(&text);
return Err(AreevError::from_response_with_body(
status,
code.as_deref(),
message,
body,
request_id,
));
}
Ok(SseStream::new(resp))
}
async fn send_with_retry<F>(
&self,
_method: &str,
_path: &str,
build_request: F,
) -> Result<serde_json::Value>
where
F: Fn() -> reqwest::RequestBuilder,
{
let idem_key = new_idempotency_key();
let mut last_err: Option<AreevError> = None;
for attempt in 0..=self.max_retries {
let req = build_request().header("Idempotency-Key", &idem_key);
let send_result = req.send().await;
let mut retry_after: Option<Duration> = None;
match send_result {
Ok(resp) => {
if resp.status().as_u16() == 429 {
retry_after = parse_retry_after(
resp.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok()),
);
}
match Self::interpret_response(resp).await {
Ok(value) => return Ok(value),
Err(e) => {
let retryable = e.is_retryable();
last_err = Some(e);
if !retryable {
return Err(last_err.unwrap());
}
}
}
}
Err(e) => {
last_err = Some(AreevError::from(e));
}
}
if attempt >= self.max_retries {
break;
}
let sleep_dur = retry_after.unwrap_or_else(|| {
let base = RETRY_DELAYS_MS[attempt.min(RETRY_DELAYS_MS.len() - 1)];
Duration::from_millis(jittered_delay_ms(base, attempt))
});
tokio::time::sleep(sleep_dur).await;
}
Err(last_err.unwrap_or_else(|| AreevError::Other {
http_status: 0,
code: None,
message: "retry loop exited without an error".into(),
body: serde_json::Value::Null,
request_id: None,
}))
}
async fn interpret_response(resp: reqwest::Response) -> Result<serde_json::Value> {
let status = resp.status().as_u16();
let request_id = resp
.headers()
.get("x-request-id")
.or_else(|| resp.headers().get("x-areev-request-id"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if status < 400 {
if status == 204 {
return Ok(serde_json::Value::Null);
}
let bytes = resp.bytes().await?;
if bytes.is_empty() {
return Ok(serde_json::Value::Null);
}
let v: serde_json::Value = serde_json::from_slice(&bytes)?;
return Ok(v);
}
let text = resp.text().await.unwrap_or_default();
let (message, code, body) = parse_error_body(&text);
Err(AreevError::from_response_with_body(
status,
code.as_deref(),
message,
body,
request_id,
))
}
async fn interpret_bytes_response(resp: reqwest::Response) -> Result<Vec<u8>> {
let status = resp.status().as_u16();
let request_id = resp
.headers()
.get("x-request-id")
.or_else(|| resp.headers().get("x-areev-request-id"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if status < 400 {
if status == 204 {
return Ok(Vec::new());
}
return Ok(resp.bytes().await?.to_vec());
}
let text = resp.text().await.unwrap_or_default();
let (message, code, body) = parse_error_body(&text);
Err(AreevError::from_response_with_body(
status,
code.as_deref(),
message,
body,
request_id,
))
}
pub async fn recall(&self, req: &RecallRequest) -> Result<RecallResponse> {
let resp = self
.request(reqwest::Method::POST, "recall")
.json(req)
.send()
.await?;
self.handle_typed(resp).await
}
pub async fn remember(&self, req: &RememberRequest) -> Result<RememberResponse> {
let resp = self
.request(reqwest::Method::POST, "remember")
.json(req)
.send()
.await?;
self.handle_typed(resp).await
}
pub async fn harness_chat(
&self,
slug: &str,
req: &HarnessChatRequest,
) -> Result<HarnessChatResponse> {
let resp = self
.request(reqwest::Method::POST, &format!("harnesses/{slug}/chat"))
.json(req)
.send()
.await?;
self.handle_typed(resp).await
}
pub async fn harness_chat_resume(
&self,
slug: &str,
req: &ChatResumeRequest,
) -> Result<HarnessChatResponse> {
let resp = self
.request(
reqwest::Method::POST,
&format!("harnesses/{slug}/chat/resume"),
)
.json(req)
.send()
.await?;
self.handle_typed(resp).await
}
async fn handle_typed<T: serde::de::DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T> {
let status = resp.status().as_u16();
let request_id = resp
.headers()
.get("x-request-id")
.or_else(|| resp.headers().get("x-areev-request-id"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if resp.status().is_success() {
if status == 204 {
return serde_json::from_value(serde_json::Value::Null).map_err(AreevError::from);
}
let bytes = resp.bytes().await?;
if bytes.is_empty() {
return serde_json::from_value(serde_json::Value::Null).map_err(AreevError::from);
}
return serde_json::from_slice(&bytes).map_err(AreevError::from);
}
let text = resp.text().await.unwrap_or_default();
let (message, code, body) = parse_error_body(&text);
Err(AreevError::from_response_with_body(
status,
code.as_deref(),
message,
body,
request_id,
))
}
}
fn parse_error_body(text: &str) -> (String, Option<String>, serde_json::Value) {
if text.is_empty() {
return ("(empty body)".to_string(), None, serde_json::Value::Null);
}
match serde_json::from_str::<serde_json::Value>(text) {
Ok(value @ serde_json::Value::Object(_)) => {
let map = value.as_object().expect("matched Object");
let code = map
.get("code")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message = map
.get("error")
.and_then(|v| v.as_str())
.or_else(|| map.get("message").and_then(|v| v.as_str()))
.map(|s| s.to_string())
.unwrap_or_else(|| text.to_string());
(message, code, value)
}
_ => (text.to_string(), None, serde_json::Value::Null),
}
}
fn jittered_delay_ms(base_ms: u64, attempt: usize) -> u64 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let tid = thread_id_u64();
let mixed = nanos
.wrapping_add((attempt as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
.wrapping_add(tid.wrapping_mul(0xBF58_476D_1CE4_E5B9));
let half = base_ms.max(1);
let perturb = mixed % (half.saturating_mul(2).max(1));
let centered = perturb as i64 - half as i64;
let result = base_ms as i64 + centered;
result.max(0) as u64
}
fn thread_id_u64() -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
std::thread::current().id().hash(&mut h);
h.finish()
}
fn new_idempotency_key() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
const RETRY_AFTER_MAX_SECS: u64 = 300;
fn parse_retry_after(header: Option<&str>) -> Option<Duration> {
let raw = header?.trim();
if let Ok(secs) = raw.parse::<u64>() {
return Some(Duration::from_secs(secs.min(RETRY_AFTER_MAX_SECS)));
}
let target = httpdate::parse_http_date(raw).ok()?;
let delay = target
.duration_since(SystemTime::now())
.unwrap_or(Duration::ZERO);
Some(Duration::from_secs(
delay.as_secs().min(RETRY_AFTER_MAX_SECS),
))
}
pub struct SseStream {
resp: reqwest::Response,
buf: String,
pending: std::collections::VecDeque<String>,
done: bool,
}
impl SseStream {
fn new(resp: reqwest::Response) -> Self {
Self {
resp,
buf: String::new(),
pending: std::collections::VecDeque::new(),
done: false,
}
}
pub async fn next(&mut self) -> Result<Option<String>> {
loop {
if let Some(item) = self.pending.pop_front() {
return Ok(Some(item));
}
if self.done {
return Ok(None);
}
match self.resp.chunk().await? {
Some(bytes) => {
self.buf.push_str(&String::from_utf8_lossy(&bytes));
self.drain_frames();
}
None => {
self.done = true;
if !self.buf.trim().is_empty() {
let leftover = std::mem::take(&mut self.buf);
self.parse_frame(&leftover);
}
}
}
}
}
fn drain_frames(&mut self) {
while let Some(idx) = self.buf.find("\n\n").or_else(|| self.buf.find("\r\n\r\n")) {
let sep_len = if self.buf[idx..].starts_with("\r\n\r\n") {
4
} else {
2
};
let frame = self.buf[..idx].to_string();
self.buf.drain(..idx + sep_len);
self.parse_frame(&frame);
}
}
fn parse_frame(&mut self, frame: &str) {
for line in frame.lines() {
let line = line.strip_prefix("data:").map(|s| s.trim_start());
if let Some(payload) = line {
if payload == "[DONE]" {
self.done = true;
return;
}
self.pending.push_back(payload.to_string());
}
}
}
}
#[cfg(test)]
mod tests {
use super::{new_idempotency_key, parse_retry_after, RETRY_AFTER_MAX_SECS};
use std::collections::HashSet;
use std::time::Duration;
#[test]
fn idempotency_keys_are_unique_across_calls() {
let n = 10_000;
let mut seen = HashSet::with_capacity(n);
for _ in 0..n {
let k = new_idempotency_key();
assert_eq!(k.len(), 32, "expected 32 hex chars, got {k:?}");
assert!(k.chars().all(|c| c.is_ascii_hexdigit()));
assert!(seen.insert(k), "duplicate idempotency key minted");
}
}
#[test]
fn parse_retry_after_integer_seconds() {
assert_eq!(parse_retry_after(Some("3")), Some(Duration::from_secs(3)));
assert_eq!(
parse_retry_after(Some(" 10 ")),
Some(Duration::from_secs(10))
);
assert_eq!(
parse_retry_after(Some("100000")),
Some(Duration::from_secs(RETRY_AFTER_MAX_SECS))
);
}
#[test]
fn parse_retry_after_http_date_form() {
assert_eq!(
parse_retry_after(Some("Wed, 21 Oct 2015 07:28:00 GMT")),
Some(Duration::ZERO)
);
assert_eq!(
parse_retry_after(Some("Fri, 31 Dec 9999 23:59:59 GMT")),
Some(Duration::from_secs(RETRY_AFTER_MAX_SECS))
);
}
#[test]
fn parse_retry_after_rejects_garbage_and_empty() {
assert_eq!(parse_retry_after(None), None);
assert_eq!(parse_retry_after(Some("not-a-date")), None);
assert_eq!(parse_retry_after(Some("")), None);
}
}