use std::any::TypeId;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use chrono::Utc;
use reqwest::Method;
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
use serde::Serialize;
use serde::de::DeserializeOwned;
use url::Url;
use crate::error::{self, HonchoError, Result};
use crate::http::decode;
fn is_idempotent(method: &Method) -> bool {
matches!(
*method,
Method::GET | Method::HEAD | Method::DELETE | Method::PUT | Method::OPTIONS
)
}
pub(crate) const DEFAULT_MAX_RETRIES: u32 = 2;
pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500);
const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
const RETRYABLE_STATUS: &[u16] = &[408, 429, 500, 502, 503, 504];
fn is_retryable_status(status: u16) -> bool {
RETRYABLE_STATUS.contains(&status)
}
fn map_send_error(e: reqwest::Error) -> HonchoError {
if e.is_timeout() {
HonchoError::Timeout {
message: e.to_string(),
}
} else if e.is_connect() {
HonchoError::Connection {
message: e.to_string(),
}
} else {
HonchoError::Transport(e)
}
}
fn is_send_error_retryable(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect()
}
fn body_read_error(status: reqwest::StatusCode) -> HonchoError {
let msg = format!(
"request failed with status {} (could not read response body)",
status.as_u16()
);
if status.is_server_error() {
HonchoError::Server {
status: status.as_u16(),
message: msg,
}
} else {
HonchoError::Client {
status: status.as_u16(),
message: msg,
}
}
}
pub(crate) fn normalize_base_url(base_url: &str) -> Result<Url> {
let mut base_url = Url::parse(base_url)
.map_err(|e| HonchoError::Configuration(format!("invalid base_url: {e}")))?;
match base_url.scheme() {
"http" | "https" => {}
scheme => {
return Err(HonchoError::Configuration(format!(
"invalid base_url scheme: {scheme}"
)));
}
}
if base_url.host().is_none() {
return Err(HonchoError::Configuration(
"base_url must include a host".into(),
));
}
let path = base_url.path().to_owned();
if path.ends_with('/') && path.len() > 1 {
let trimmed = path.trim_end_matches('/');
base_url.set_path(trimmed);
}
Ok(base_url)
}
struct Inner {
client: reqwest::Client,
base_url: Url,
max_retries: u32,
default_query: Vec<(String, String)>,
timeout: Duration,
default_headers: HeaderMap,
}
#[derive(Clone)]
pub struct HttpClient {
inner: Arc<Inner>,
}
#[derive(bon::Builder)]
#[builder(on(String, into))]
#[builder(finish_fn = build)]
pub struct HttpClientParams {
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) base_url: String,
pub(crate) api_key: Option<String>,
#[builder(default = DEFAULT_MAX_RETRIES)]
pub(crate) max_retries: u32,
#[builder(default)]
pub(crate) default_headers: HeaderMap,
#[builder(default)]
pub(crate) default_query: Vec<(String, String)>,
#[builder(default = DEFAULT_TIMEOUT)]
pub(crate) timeout: Duration,
pub(crate) http_client: Option<reqwest::Client>,
}
impl HttpClient {
pub fn builder() -> HttpClientParamsBuilder {
HttpClientParams::builder()
}
fn build_url(&self, path: &str) -> Result<Url> {
let base = self.inner.base_url.as_str().trim_end_matches('/');
let path = path.strip_prefix('/').unwrap_or(path);
Url::parse(&format!("{base}/{path}"))
.map_err(|e| HonchoError::Configuration(format!("failed to build URL: {e}")))
}
#[cfg(test)]
pub fn from_params(params: HttpClientParams) -> Result<Self> {
let base_url = normalize_base_url(¶ms.base_url)?;
Self::from_params_with_base_url(params, base_url)
}
pub(crate) fn from_params_with_base_url(
params: HttpClientParams,
base_url: Url,
) -> Result<Self> {
let version = env!("CARGO_PKG_VERSION");
let mut client_headers = HeaderMap::new();
client_headers.insert(
USER_AGENT,
HeaderValue::from_str(&format!("honcho-ai/{version}"))
.unwrap_or_else(|_| HeaderValue::from_static("honcho-ai/unknown")),
);
client_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
client_headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
if let Some(ref key) = params.api_key {
let mut val = HeaderValue::from_str(&format!("Bearer {key}"))
.map_err(|e| HonchoError::Configuration(format!("invalid api_key: {e}")))?;
val.set_sensitive(true);
client_headers.insert(AUTHORIZATION, val);
}
let mut last_name: Option<reqwest::header::HeaderName> = None;
for (name, value) in params.default_headers {
match name {
Some(n) => {
let _ = client_headers.insert(n.clone(), value);
last_name = Some(n);
}
None => {
if let Some(ref n) = last_name {
client_headers.append(n.clone(), value);
}
}
}
}
let (client, extra_headers) = match params.http_client {
Some(c) => (c, client_headers),
None => (
reqwest::ClientBuilder::new()
.timeout(params.timeout)
.build()
.map_err(|e| {
HonchoError::Configuration(format!("failed to build HTTP client: {e}"))
})?,
client_headers,
),
};
Ok(Self {
inner: Arc::new(Inner {
client,
base_url,
max_retries: params.max_retries,
default_query: params.default_query,
timeout: params.timeout,
default_headers: extra_headers,
}),
})
}
pub(crate) async fn request<TBody, TResp>(
&self,
method: Method,
path: &str,
body: Option<&TBody>,
query: &[(&str, &str)],
) -> Result<TResp>
where
TBody: Serialize + ?Sized,
TResp: DeserializeOwned + 'static,
{
let url = self.build_url(path)?;
let merged_query: Vec<(&str, &str)> = self
.inner
.default_query
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.chain(query.iter().copied())
.collect();
let mut attempt = 0u32;
loop {
let mut req_builder = self
.inner
.client
.request(method.clone(), url.clone())
.headers(self.inner.default_headers.clone())
.query(&merged_query)
.timeout(self.inner.timeout);
if let Some(b) = body {
req_builder = req_builder.json(b);
}
let response = match req_builder.send().await {
Ok(r) => r,
Err(e) => {
let should_retry = is_idempotent(&method)
&& is_send_error_retryable(&e)
&& attempt < self.inner.max_retries;
if should_retry {
tokio::time::sleep(jittered_delay(attempt)).await;
attempt += 1;
continue;
}
return Err(map_send_error(e));
}
};
let status = response.status();
if status.is_success() {
return self.handle_success_response(response).await;
}
let headers = response.headers().clone();
let Ok(body_bytes) = response.bytes().await else {
return Err(body_read_error(status));
};
let api_error = error::from_response(status, &headers, &body_bytes, Utc::now());
let is_retryable = is_idempotent(&method) && is_retryable_status(status.as_u16());
if is_retryable && attempt < self.inner.max_retries {
let retry_after = headers
.get("retry-after")
.and_then(|v| error::parse_retry_after(v, Utc::now()));
let delay = match retry_after {
Some(d) if d > MAX_RETRY_AFTER => return Err(api_error),
Some(d) => d,
None => jittered_delay(attempt),
};
tokio::time::sleep(delay).await;
attempt += 1;
continue;
}
return Err(api_error);
}
}
async fn handle_success_response<TResp: DeserializeOwned + 'static>(
&self,
response: reqwest::Response,
) -> Result<TResp> {
let bytes = response.bytes().await.map_err(|e| {
if e.is_timeout() {
HonchoError::Timeout {
message: e.to_string(),
}
} else {
HonchoError::Transport(e)
}
})?;
if bytes.is_empty() {
return serde_json::from_value::<TResp>(serde_json::Value::Null).map_err(|e| {
HonchoError::Decode {
path: ".".to_string(),
source: e,
}
});
}
match decode::deserialize_with_path::<TResp>(&bytes) {
Ok(val) => Ok(val),
Err(decode_err) => {
if TypeId::of::<TResp>() == TypeId::of::<()>() {
serde_json::from_value::<TResp>(serde_json::Value::Null).map_err(|_| decode_err)
} else {
Err(decode_err)
}
}
}
}
pub(crate) async fn get<TResp: DeserializeOwned + 'static>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<TResp> {
self.request::<(), TResp>(Method::GET, path, None, query)
.await
}
pub(crate) async fn post<TBody: Serialize + ?Sized, TResp: DeserializeOwned + 'static>(
&self,
path: &str,
body: Option<&TBody>,
query: &[(&str, &str)],
) -> Result<TResp> {
self.request(Method::POST, path, body, query).await
}
pub(crate) async fn put<TBody: Serialize + ?Sized, TResp: DeserializeOwned + 'static>(
&self,
path: &str,
body: Option<&TBody>,
query: &[(&str, &str)],
) -> Result<TResp> {
self.request(Method::PUT, path, body, query).await
}
pub(crate) async fn delete<TResp: DeserializeOwned + 'static>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<TResp> {
self.request::<(), TResp>(Method::DELETE, path, None, query)
.await
}
pub(crate) async fn request_multipart<TResp, F, Fut>(
&self,
method: Method,
path: &str,
form_factory: F,
query: &[(String, String)],
) -> Result<TResp>
where
TResp: DeserializeOwned + 'static,
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::multipart::Form>>,
{
let url = self.build_url(path)?;
let merged_query: Vec<(&str, &str)> = self
.inner
.default_query
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.chain(query.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.collect();
let mut multipart_headers = self.inner.default_headers.clone();
multipart_headers.remove(CONTENT_TYPE);
let mut attempt = 0u32;
loop {
let form = form_factory().await?;
let response = match self
.inner
.client
.request(method.clone(), url.clone())
.headers(multipart_headers.clone())
.query(&merged_query)
.timeout(self.inner.timeout)
.multipart(form)
.send()
.await
{
Ok(r) => r,
Err(e) => {
let should_retry = is_idempotent(&method)
&& is_send_error_retryable(&e)
&& attempt < self.inner.max_retries;
if should_retry {
tokio::time::sleep(jittered_delay(attempt)).await;
attempt += 1;
continue;
}
return Err(map_send_error(e));
}
};
let status = response.status();
if status.is_success() {
return self.handle_success_response(response).await;
}
let headers = response.headers().clone();
let Ok(body_bytes) = response.bytes().await else {
return Err(body_read_error(status));
};
let api_error = error::from_response(status, &headers, &body_bytes, Utc::now());
let is_retryable = is_idempotent(&method) && is_retryable_status(status.as_u16());
if is_retryable && attempt < self.inner.max_retries {
let retry_after = headers
.get("retry-after")
.and_then(|v| error::parse_retry_after(v, Utc::now()));
let delay = match retry_after {
Some(d) if d > MAX_RETRY_AFTER => return Err(api_error),
Some(d) => d,
None => jittered_delay(attempt),
};
tokio::time::sleep(delay).await;
attempt += 1;
continue;
}
return Err(api_error);
}
}
pub(crate) async fn request_streaming(
&self,
method: Method,
path: &str,
body: Option<&serde_json::Value>,
query: &[(&str, &str)],
) -> Result<reqwest::Response> {
let url = self.build_url(path)?;
let merged_query: Vec<(&str, &str)> = self
.inner
.default_query
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.chain(query.iter().copied())
.collect();
let mut headers = self.inner.default_headers.clone();
headers.remove(ACCEPT);
let mut req_builder = self
.inner
.client
.request(method, url)
.headers(headers)
.header(ACCEPT, HeaderValue::from_static("text/event-stream"))
.query(&merged_query)
.timeout(self.inner.timeout);
if let Some(b) = body {
req_builder = req_builder.json(b);
}
let response = match req_builder.send().await {
Ok(r) => r,
Err(e) => return Err(map_send_error(e)),
};
let status = response.status();
if status.is_success() {
return Ok(response);
}
let headers = response.headers().clone();
let Ok(body_bytes) = response.bytes().await else {
return Err(body_read_error(status));
};
Err(error::from_response(
status,
&headers,
&body_bytes,
Utc::now(),
))
}
pub(crate) async fn post_multipart<TResp, F, Fut>(
&self,
path: &str,
form_factory: F,
query: &[(String, String)],
) -> Result<TResp>
where
TResp: DeserializeOwned + 'static,
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::multipart::Form>>,
{
self.request_multipart(Method::POST, path, form_factory, query)
.await
}
}
#[doc(hidden)]
#[must_use]
pub fn delay_for_attempt(attempt: u32) -> Duration {
let shift = attempt.min(31);
INITIAL_RETRY_DELAY
.saturating_mul(1u32 << shift)
.min(Duration::from_secs(60))
}
fn jittered_delay(attempt: u32) -> Duration {
let base = delay_for_attempt(attempt);
let entropy = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|e| e.duration())
.subsec_nanos();
let scrambled = entropy.wrapping_mul(1_103_515_245).wrapping_add(12_345);
let jitter_factor = 500 + (scrambled % 500);
base * jitter_factor / 1000
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unnecessary_wraps,
clippy::needless_pass_by_value,
clippy::unused_async
)]
use super::*;
use crate::error::HonchoError;
use crate::http::routes;
use crate::types::peer::Peer;
use crate::types::workspace::Workspace;
use std::time::Duration;
use wiremock::matchers::{
body_json, header, header_exists, header_regex, method, path, query_param,
};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_client(server: &MockServer) -> HttpClient {
HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build()).unwrap()
}
fn make_client_with_key(server: &MockServer, api_key: &str) -> HttpClient {
HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.api_key(api_key.to_string())
.build(),
)
.unwrap()
}
fn peer_json() -> serde_json::Value {
serde_json::json!({
"id": "p1",
"workspace_id": "ws1",
"created_at": "2025-01-15T10:30:00Z",
"metadata": {},
"configuration": {}
})
}
fn workspace_json() -> serde_json::Value {
serde_json::json!({
"id": "ws_abc123",
"metadata": {},
"configuration": {},
"created_at": "2025-01-15T10:30:00Z"
})
}
#[tokio::test]
async fn builder_creates_client_with_valid_url() {
let server = MockServer::start().await;
let result = HttpClient::from_params(HttpClient::builder().base_url(server.uri()).build());
assert!(result.is_ok());
}
#[tokio::test]
async fn builder_accepts_pre_normalized_base_url() {
let server = MockServer::start().await;
let base_url = normalize_base_url(&format!("{}/", server.uri())).unwrap();
let client = HttpClient::from_params_with_base_url(
HttpClient::builder().base_url("unused value").build(),
base_url,
)
.unwrap();
Mock::given(method("GET"))
.and(path("/v3/test"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[tokio::test]
async fn builder_rejects_invalid_url() {
let result = HttpClient::from_params(
HttpClient::builder()
.base_url("not a url".to_string())
.build(),
);
let Err(err) = result else {
panic!("expected Configuration error")
};
assert!(matches!(err, HonchoError::Configuration(_)));
}
#[tokio::test]
async fn builder_strips_trailing_slash() {
let server = MockServer::start().await;
let base = format!("{}/", server.uri());
let client = HttpClient::from_params(HttpClient::builder().base_url(base).build()).unwrap();
Mock::given(method("GET"))
.and(path("/v3/test"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[tokio::test]
async fn base_url_with_path_prefix_preserved() {
let server = MockServer::start().await;
let base = format!("{}/api", server.uri());
let client = HttpClient::from_params(HttpClient::builder().base_url(base).build()).unwrap();
Mock::given(method("GET"))
.and(path("/api/v3/test"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[tokio::test]
async fn builder_default_max_retries_is_2() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.expect(3)
.mount(&server)
.await;
assert!(client.get::<Peer>("/v3/test", &[]).await.is_err());
}
#[tokio::test]
async fn builder_custom_http_client_preserved() {
let server = MockServer::start().await;
let custom = reqwest::Client::new();
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.http_client(custom)
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[tokio::test]
async fn get_sends_get_request() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[tokio::test]
async fn post_sends_post_with_body() {
let server = MockServer::start().await;
let client = make_client(&server);
let body = serde_json::json!({"name": "test"});
Mock::given(method("POST"))
.and(body_json(&body))
.respond_with(ResponseTemplate::new(200).set_body_json(workspace_json()))
.mount(&server)
.await;
let result: Workspace = client.post("/v3/test", Some(&body), &[]).await.unwrap();
assert_eq!(result.id, "ws_abc123");
}
#[tokio::test]
async fn put_sends_put_with_body() {
let server = MockServer::start().await;
let client = make_client(&server);
let body = serde_json::json!({"name": "updated"});
Mock::given(method("PUT"))
.and(body_json(&body))
.respond_with(ResponseTemplate::new(200).set_body_json(workspace_json()))
.mount(&server)
.await;
let result: Workspace = client.put("/v3/test", Some(&body), &[]).await.unwrap();
assert_eq!(result.id, "ws_abc123");
}
#[tokio::test]
async fn delete_returns_unit() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let _: () = client.delete("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn sends_bearer_auth_when_api_key_set() {
let server = MockServer::start().await;
let client = make_client_with_key(&server, "test-key");
Mock::given(method("GET"))
.and(header("authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let _: Peer = client.get("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn sends_user_agent() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.and(header_exists("user-agent"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let _: Peer = client.get("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn sends_content_type() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.and(header("content-type", "application/json"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let _: Peer = client.get("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn sends_accept() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.and(header("accept", "application/json"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let _: Peer = client.get("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn default_query_params_sent() {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.default_query(vec![("foo".to_string(), "bar".to_string())])
.build(),
)
.unwrap();
Mock::given(method("GET"))
.and(query_param("foo", "bar"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let _: Peer = client.get("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn unit_type_for_204_no_content() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let _: () = client.delete("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn unit_type_for_200_empty_body() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let _: () = client.get("/v3/test", &[]).await.unwrap();
}
#[tokio::test]
async fn request_times_out() {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.timeout(Duration::from_millis(100))
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
.mount(&server)
.await;
let err = client.get::<Peer>("/v3/test", &[]).await.unwrap_err();
assert!(
matches!(err, HonchoError::Timeout { .. }),
"expected Timeout, got {err:?}"
);
}
#[rstest::rstest]
#[case(400, "bad_request")]
#[case(401, "authentication_error")]
#[case(403, "permission_denied")]
#[case(404, "not_found")]
#[case(409, "conflict")]
#[case(422, "unprocessable_entity")]
#[tokio::test]
async fn non_retryable_4xx_returns_error(
#[case] status: u16,
#[case] expected_code: &'static str,
) {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(5)
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(status))
.expect(1)
.mount(&server)
.await;
let err = client.get::<Peer>("/v3/test", &[]).await.unwrap_err();
assert_eq!(err.code(), expected_code);
}
#[tokio::test]
async fn server_error_without_retries() {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
let err = client.get::<Peer>("/v3/test", &[]).await.unwrap_err();
assert!(
matches!(err, HonchoError::Server { status: 503, .. }),
"expected Server(503), got {err:?}"
);
}
#[tokio::test]
async fn malformed_json_returns_decode_error() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"bad": true})),
)
.mount(&server)
.await;
let err = client.get::<Peer>("/v3/test", &[]).await.unwrap_err();
assert!(
matches!(err, HonchoError::Decode { .. }),
"expected Decode, got {err:?}"
);
}
#[rstest::rstest]
#[case(429)]
#[case(500)]
#[case(502)]
#[case(503)]
#[case(504)]
#[tokio::test]
async fn retries_on_retryable_status(#[case] status: u16) {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(status))
.up_to_n_times(1)
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[rstest::rstest]
#[case(400)]
#[case(401)]
#[case(403)]
#[case(404)]
#[case(409)]
#[case(422)]
#[tokio::test]
async fn no_retry_non_retryable_4xx(#[case] status: u16) {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(5)
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(status))
.expect(1)
.mount(&server)
.await;
assert!(client.get::<Peer>("/v3/test", &[]).await.is_err());
}
#[tokio::test]
async fn max_retries_zero_no_retry() {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
assert!(client.get::<Peer>("/v3/test", &[]).await.is_err());
}
#[tokio::test]
async fn max_retries_3_means_4_total_attempts() {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(3)
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.expect(4)
.mount(&server)
.await;
assert!(client.get::<Peer>("/v3/test", &[]).await.is_err());
}
#[rstest::rstest]
#[case(429)]
#[case(500)]
#[case(502)]
#[case(503)]
#[case(504)]
#[tokio::test]
async fn no_retry_post_on_retryable_status(#[case] status: u16) {
let server = MockServer::start().await;
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(5)
.build(),
)
.unwrap();
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(status))
.expect(1)
.mount(&server)
.await;
let body = serde_json::json!({"key": "value"});
let err = client
.post::<_, Peer>("/v3/test", Some(&body), &[])
.await
.unwrap_err();
if status == 429 {
assert!(
matches!(err, HonchoError::RateLimit { .. }),
"expected RateLimit, got {err:?}"
);
} else {
assert!(
matches!(err, HonchoError::Server { status: s, .. } if s == status),
"expected Server({status}), got {err:?}"
);
}
}
#[test]
fn delay_for_attempt_sequence() {
assert_eq!(delay_for_attempt(0), Duration::from_millis(500));
assert_eq!(delay_for_attempt(1), Duration::from_secs(1));
assert_eq!(delay_for_attempt(2), Duration::from_secs(2));
assert_eq!(delay_for_attempt(3), Duration::from_secs(4));
}
#[test]
fn delay_for_attempt_capped_at_60s() {
assert_eq!(delay_for_attempt(10), Duration::from_secs(60));
}
#[test]
fn jittered_delay_within_expected_range() {
for attempt in 0..6 {
let base = delay_for_attempt(attempt);
for _ in 0..1000 {
let jittered = jittered_delay(attempt);
assert!(
jittered >= base / 2 && jittered < base,
"jittered={jittered:?} not in [{:?}, {base:?}) for attempt={attempt}",
base / 2,
);
}
}
}
#[tokio::test]
async fn retry_after_zero_retries_immediately() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[tokio::test]
async fn retry_after_seconds_used() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "1"))
.up_to_n_times(1)
.mount(&server)
.await;
let result: Peer = client.get("/v3/test", &[]).await.unwrap();
assert_eq!(result.id, "p1");
}
#[tokio::test]
async fn e2e_get_peer_typed() {
let server = MockServer::start().await;
let client = make_client(&server);
let fixture = serde_json::json!({
"id": "p1",
"workspace_id": "ws1",
"created_at": "2025-01-15T10:30:00Z",
"metadata": {"role": "admin", "version": 2},
"configuration": {"language": "en", "features": {"beta": true}}
});
Mock::given(method("GET"))
.and(path("/v3/workspaces/ws1/peers/alice"))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture))
.mount(&server)
.await;
let result: Peer = client
.get(&routes::peer("ws1", "alice").unwrap(), &[])
.await
.unwrap();
assert_eq!(result.id, "p1");
assert_eq!(result.workspace_id, "ws1");
}
#[tokio::test]
async fn e2e_post_workspace_typed() {
let server = MockServer::start().await;
let client = make_client(&server);
let fixture = serde_json::json!({
"id": "ws_abc123",
"metadata": {"env": "production", "team": "core"},
"configuration": {"reasoning": {"enabled": true}},
"created_at": "2025-01-15T10:30:00Z"
});
let create_body = serde_json::json!({"id": "ws_abc123"});
Mock::given(method("POST"))
.and(path("/v3/workspaces"))
.and(body_json(&create_body))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture))
.mount(&server)
.await;
let result: Workspace = client
.post(&routes::workspaces(), Some(&create_body), &[])
.await
.unwrap();
assert_eq!(result.id, "ws_abc123");
}
#[tokio::test]
async fn post_multipart_sends_form_data() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("POST"))
.and(path("/v3/upload"))
.and(wiremock::matchers::body_string_contains("field_value"))
.and(wiremock::matchers::body_string_contains("test upload"))
.respond_with(ResponseTemplate::new(200).set_body_json(workspace_json()))
.mount(&server)
.await;
let result: Workspace = client
.post_multipart(
"/v3/upload",
|| async {
Ok(reqwest::multipart::Form::new()
.text("field_name", "field_value")
.text("description", "test upload"))
},
&[],
)
.await
.unwrap();
assert_eq!(result.id, "ws_abc123");
}
#[tokio::test]
async fn post_multipart_with_query_params() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("POST"))
.and(path("/v3/upload"))
.and(query_param("workspace_id", "ws1"))
.respond_with(ResponseTemplate::new(200).set_body_json(workspace_json()))
.mount(&server)
.await;
let result: Workspace = client
.post_multipart(
"/v3/upload",
|| async { Ok(reqwest::multipart::Form::new().text("key", "value")) },
&[("workspace_id".to_string(), "ws1".to_string())],
)
.await
.unwrap();
assert_eq!(result.id, "ws_abc123");
}
#[tokio::test]
async fn post_multipart_content_type_is_multipart_not_json() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("POST"))
.and(path("/v3/upload"))
.and(header_regex("content-type", "multipart/form-data"))
.respond_with(ResponseTemplate::new(200).set_body_json(workspace_json()))
.mount(&server)
.await;
let result: Workspace = client
.post_multipart(
"/v3/upload",
|| async { Ok(reqwest::multipart::Form::new().text("key", "value")) },
&[],
)
.await
.unwrap();
assert_eq!(result.id, "ws_abc123");
}
#[tokio::test]
async fn post_multipart_server_error_returns_error() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(503))
.expect(1)
.mount(&server)
.await;
let err = client
.post_multipart::<Workspace, _, _>(
"/v3/upload",
|| async { Ok(reqwest::multipart::Form::new().text("key", "value")) },
&[],
)
.await
.unwrap_err();
assert!(
matches!(err, HonchoError::Server { status: 503, .. }),
"expected Server(503), got {err:?}"
);
}
#[rstest::rstest]
#[case(429)]
#[case(500)]
#[case(502)]
#[case(503)]
#[case(504)]
#[tokio::test]
async fn no_retry_multipart_on_retryable_status_for_post(#[case] status: u16) {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(status))
.expect(1)
.mount(&server)
.await;
let err = client
.post_multipart::<Workspace, _, _>(
"/v3/upload",
|| async { Ok(reqwest::multipart::Form::new().text("key", "value")) },
&[],
)
.await
.unwrap_err();
if status == 429 {
assert!(
matches!(err, HonchoError::RateLimit { .. }),
"expected RateLimit, got {err:?}"
);
} else {
assert!(
matches!(err, HonchoError::Server { status: s, .. } if s == status),
"expected Server({status}), got {err:?}"
);
}
}
#[tokio::test]
async fn request_streaming_returns_response_on_200() {
let server = MockServer::start().await;
let client = make_client(&server);
let sse_body = "data: hello\n\ndata: world\n\n";
Mock::given(method("POST"))
.and(header("accept", "text/event-stream"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(sse_body)
.insert_header("content-type", "text/event-stream"),
)
.mount(&server)
.await;
let response = client
.request_streaming(
Method::POST,
"/v3/workspaces/ws1/peers/alice/chat",
None,
&[],
)
.await
.unwrap();
assert!(response.status().is_success());
let bytes = response.bytes().await.unwrap();
assert_eq!(bytes, sse_body.as_bytes());
}
#[tokio::test]
async fn request_streaming_error_status_maps_to_honcho_error() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(422))
.mount(&server)
.await;
let err = client
.request_streaming(
Method::POST,
"/v3/workspaces/ws1/peers/alice/chat",
None,
&[],
)
.await
.unwrap_err();
assert!(
matches!(err, HonchoError::UnprocessableEntity { .. }),
"expected UnprocessableEntity, got {err:?}"
);
}
#[tokio::test]
async fn request_streaming_sends_accept_event_stream_header() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("POST"))
.and(header("accept", "text/event-stream"))
.respond_with(ResponseTemplate::new(200).set_body_string("data: ok\n\n"))
.expect(1)
.mount(&server)
.await;
let _response = client
.request_streaming(
Method::POST,
"/v3/workspaces/ws1/peers/alice/chat",
None,
&[],
)
.await
.unwrap();
}
#[tokio::test]
async fn multi_value_default_headers_preserved() {
let server = MockServer::start().await;
let mut headers = HeaderMap::new();
headers.append(
reqwest::header::HeaderName::from_static("x-custom"),
HeaderValue::from_static("val1"),
);
headers.append(
reqwest::header::HeaderName::from_static("x-custom"),
HeaderValue::from_static("val2"),
);
let client = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.default_headers(headers)
.build(),
)
.unwrap();
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(peer_json()))
.mount(&server)
.await;
let _: Peer = client.get("/v3/test", &[]).await.unwrap();
let requests = server.received_requests().await.unwrap();
let custom_values: Vec<&str> = requests[0]
.headers
.get_all("x-custom")
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert_eq!(
custom_values.len(),
2,
"both header values should reach the wire"
);
assert!(custom_values.contains(&"val1"));
assert!(custom_values.contains(&"val2"));
}
#[tokio::test]
async fn large_retry_after_returns_error_without_sleeping() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "86400"))
.expect(1)
.mount(&server)
.await;
let outcome =
tokio::time::timeout(Duration::from_secs(5), client.get::<Peer>("/v3/test", &[])).await;
assert!(
outcome.is_ok(),
"request should complete without sleeping 24h"
);
let err = outcome.unwrap().unwrap_err();
assert!(
matches!(err, HonchoError::RateLimit { .. }),
"expected RateLimit, got {err:?}"
);
}
#[tokio::test]
async fn decode_failure_for_option_type_not_masked_as_none() {
let server = MockServer::start().await;
let client = make_client(&server);
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"unexpected": 1})),
)
.mount(&server)
.await;
let err = client
.get::<Option<Peer>>("/v3/test", &[])
.await
.unwrap_err();
assert!(
matches!(err, HonchoError::Decode { .. }),
"expected Decode error for incompatible JSON, got {err:?}"
);
}
#[tokio::test]
async fn non_network_send_error_is_not_retryable() {
let raw = reqwest::Client::builder()
.https_only(true)
.build()
.unwrap()
.get("http://example.com")
.send()
.await
.unwrap_err();
assert!(
!raw.is_connect() && !raw.is_timeout(),
"precondition: error must be neither connect nor timeout"
);
assert!(
!is_send_error_retryable(&raw),
"non-network send error must not be retried"
);
assert!(
matches!(map_send_error(raw), HonchoError::Transport(_)),
"non-network send error must map to Transport"
);
}
#[test]
fn bearer_auth_header_marked_sensitive() {
let client = HttpClient::from_params(
HttpClient::builder()
.base_url("http://localhost:0")
.api_key("secret-key")
.build(),
)
.unwrap();
let auth = client
.inner
.default_headers
.get("authorization")
.expect("authorization header should be set");
assert!(
auth.is_sensitive(),
"authorization header must be marked sensitive"
);
}
}