use std::{
fmt::Debug,
path::PathBuf,
sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use arc_swap::ArcSwap;
pub use bytes::Bytes;
use dashmap::DashMap;
use eyre::{Report, eyre};
use jiff::Timestamp;
use reqwest::Url;
pub use reqwest::{
Method, Request, RequestBuilder, StatusCode,
header::{self, HeaderMap},
};
use serde::Serialize;
use tracing::{Span, debug, error, field::Empty, info, instrument, warn};
pub use ustr::Ustr;
use crate::{
ConstructAuthError, RetryConfig, UrlError,
ratelimiter::{RateLimiter, clock::MonotonicClock},
retry::ExponentialBackoff,
};
pub static USER_AGENT: &str = concat!("v_exchanges_api_generics/", env!("CARGO_PKG_VERSION"));
#[derive(Clone)]
pub struct Client {
client: Arc<ArcSwap<reqwest::Client>>,
net_dirty: Arc<AtomicBool>,
_net_watch: Arc<std::sync::Mutex<netwatcher::WatchHandle>>,
pub config: RequestConfig,
pub rate_limiter: Option<Arc<RateLimiter<Ustr, MonotonicClock>>>,
banned_until: Arc<DashMap<Ustr, Timestamp>>,
}
impl Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("client", &self.client)
.field("net_dirty", &self.net_dirty)
.field("config", &self.config)
.field("rate_limiter", &self.rate_limiter)
.finish_non_exhaustive()
}
}
fn build_reqwest_client() -> reqwest::Client {
reqwest::Client::new()
}
impl Default for Client {
fn default() -> Self {
let net_dirty = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&net_dirty);
let watch = netwatcher::watch_interfaces_with_callback(move |u: netwatcher::Update| {
if !u.is_initial {
flag.store(true, Ordering::Relaxed);
}
})
.expect("netwatcher: failed to subscribe to host network change events");
Self {
client: Arc::new(ArcSwap::from_pointee(build_reqwest_client())),
net_dirty,
_net_watch: Arc::new(std::sync::Mutex::new(watch)),
config: RequestConfig::default(),
rate_limiter: None,
banned_until: Arc::new(DashMap::new()),
}
}
}
impl Client {
#[instrument(skip_all, fields(?url, ?query, request_builder = Empty))] pub async fn request<Q, B, H>(&self, method: Method, url: &str, query: Option<&Q>, body: Option<B>, handler: &H) -> Result<H::Successful, RequestError>
where
Q: Serialize + ?Sized + std::fmt::Debug,
H: RequestHandler<B>, {
if self.net_dirty.swap(false, Ordering::Relaxed) {
self.client.store(Arc::new(build_reqwest_client()));
info!("host network change observed; rebuilt HTTP connection pool");
}
let reqwest_client = self.client.load();
let config = &self.config;
let base_url = handler.base_url(config.use_testnet)?;
let url = base_url.join(url).map_err(|_| RequestError::Other(eyre!("Failed to parse provided URL")))?;
debug!(?config);
let mock_path = config.mock_cache_dir.as_ref().map(|dir| mock_cache_path(dir, &url));
if let Some(ref path) = mock_path
&& let Ok(file) = std::fs::read_to_string(path)
&& path
.metadata()
.expect("already read the file, guaranteed to exist")
.modified()
.expect("switch OSes, you're on something stupid")
.elapsed()
.unwrap() < MOCK_CACHE_DURATION
{
debug!("Mock cache hit: {}", path.display());
let body = Bytes::from(file);
let (status, headers) = (StatusCode::OK, header::HeaderMap::new());
return handler.handle_response(status, headers, body).map_err(RequestError::HandleResponse);
}
let bucket: Ustr = {
let exchange = url.host_str().and_then(|h| {
let parts: Vec<&str> = h.split('.').collect();
parts.len().checked_sub(2).map(|i| parts[i])
});
let key_name = handler.rate_limit_key_name();
let s = match (exchange, key_name.as_deref()) {
(Some(ex), Some(kn)) => format!("ip.{ex}.{kn}"),
(Some(ex), None) => format!("ip.{ex}"),
(None, _) => "ip".to_owned(),
};
Ustr::from(&s)
};
let banned = self.banned_until.get(&bucket).map(|r| *r);
if let Some(until) = banned {
if Timestamp::now() < until {
warn!(%bucket, ?until, "IP banned; short-circuiting request until unban time");
return Err(RequestError::HandleResponse(HandleError::Api(ApiError::Ip(IpError::Timeout { until: Some(until) }))));
}
self.banned_until.remove(&bucket);
}
if let Some(rl) = &self.rate_limiter {
rl.until_key_ready_n(&bucket, 1).await;
}
let mut backoff = ExponentialBackoff::try_from(&config.retry).map_err(|e| RequestError::Other(eyre!("Invalid retry configuration: {e}")))?;
let mut attempt: u32 = 0;
loop {
let attempt_num = attempt + 1;
let mut request_builder = reqwest_client.request(method.clone(), url.clone()).timeout(config.timeout);
if let Some(query) = query {
request_builder = request_builder.query(query);
}
Span::current().record("request_builder", format!("{request_builder:?}"));
if config.use_testnet
&& let Some(cache_duration) = config.cache_testnet_calls
{
let path = test_calls_path(&url, &query);
if let Ok(file) = std::fs::read_to_string(&path)
&& path
.metadata()
.expect("already read the file, guaranteed to exist")
.modified()
.expect("switch OSes, you're on something stupid")
.elapsed()
.unwrap() < cache_duration
{
let body = Bytes::from(file);
let (status, headers) = (StatusCode::OK, header::HeaderMap::new()); return handler.handle_response(status, headers, body).map_err(RequestError::HandleResponse);
}
}
let request = handler.build_request(request_builder, &body, attempt_num as u8).map_err(RequestError::BuildRequest)?;
match reqwest_client.execute(request).await {
Ok(mut response) => {
let status = response.status();
let headers = std::mem::take(response.headers_mut());
debug!(?status, ?headers, "Received response headers");
let body: Bytes = match response.bytes().await {
Ok(b) => b,
Err(e) => {
error!(?status, ?headers, ?e, "Failed to read response body");
return Err(RequestError::ReceiveResponse(e));
}
};
{
let truncated_body = v_utils::utils::truncate_msg(std::str::from_utf8(&body)?.trim());
debug!(truncated_body);
}
if status.is_success()
&& let Some(ref path) = mock_path
{
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, &body).ok();
debug!("Mock cache write: {}", path.display());
}
match config.use_testnet {
true => {
let handled = handler.handle_response(status, headers.clone(), body.clone())?;
std::fs::write(test_calls_path(&url, &query), &body).ok();
return Ok(handled);
}
false => {
let handled = handler.handle_response(status, headers.clone(), body.clone());
if let Err(HandleError::Api(ApiError::Ip(IpError::Timeout { until }))) = &handled {
let until = until.unwrap_or_else(|| Timestamp::now() + config.ban_cooldown);
warn!(%bucket, ?until, "exchange reported IP ban; gating bucket until unban time");
self.banned_until.insert(bucket, until);
}
return handled.map_err(|e| {
error!(?status, ?headers, body = ?v_utils::utils::truncate_msg(std::str::from_utf8(&body).unwrap_or("<invalid utf8>")), "Failed to handle response");
RequestError::HandleResponse(e)
});
}
}
}
Err(e) =>
if attempt < config.retry.max_retries && is_retryable_request_error(&e) {
let delay = backoff.next_duration();
info!(attempt = attempt_num, delay_ms = delay.as_millis(), "Retrying after network error");
if delay.is_zero() {
tokio::task::yield_now().await;
} else {
tokio::time::sleep(delay).await;
}
attempt += 1;
} else {
warn!(?e);
return Err(RequestError::SendRequest(e));
},
}
}
}
pub async fn get<Q, H>(&self, url: &str, query: &Q, handler: &H) -> Result<H::Successful, RequestError>
where
Q: Serialize + ?Sized + Debug,
H: RequestHandler<()>, {
self.request::<Q, (), H>(Method::GET, url, Some(query), None, handler).await
}
pub async fn get_no_query<H>(&self, url: &str, handler: &H) -> Result<H::Successful, RequestError>
where
H: RequestHandler<()>, {
self.request::<&[(&str, &str)], (), H>(Method::GET, url, None, None, handler).await
}
pub async fn post<B, H>(&self, url: &str, body: B, handler: &H) -> Result<H::Successful, RequestError>
where
H: RequestHandler<B>, {
self.request::<(), B, H>(Method::POST, url, None, Some(body), handler).await
}
pub async fn post_no_body<H>(&self, url: &str, handler: &H) -> Result<H::Successful, RequestError>
where
H: RequestHandler<()>, {
self.request::<(), (), H>(Method::POST, url, None, None, handler).await
}
pub async fn put<B, H>(&self, url: &str, body: B, handler: &H) -> Result<H::Successful, RequestError>
where
H: RequestHandler<B>, {
self.request::<(), B, H>(Method::PUT, url, None, Some(body), handler).await
}
pub async fn put_no_body<H>(&self, url: &str, handler: &H) -> Result<H::Successful, RequestError>
where
H: RequestHandler<()>, {
self.request::<(), (), H>(Method::PUT, url, None, None, handler).await
}
pub async fn delete<Q, H>(&self, url: &str, query: &Q, handler: &H) -> Result<H::Successful, RequestError>
where
Q: Serialize + ?Sized + Debug,
H: RequestHandler<()>, {
self.request::<Q, (), H>(Method::DELETE, url, Some(query), None, handler).await
}
pub async fn delete_no_query<H>(&self, url: &str, handler: &H) -> Result<H::Successful, RequestError>
where
H: RequestHandler<()>, {
self.request::<&[(&str, &str)], (), H>(Method::DELETE, url, None, None, handler).await
}
}
pub trait RequestHandler<B> {
type Successful;
#[allow(unused_variables)]
fn base_url(&self, is_test: bool) -> Result<url::Url, UrlError> {
Url::parse("").map_err(UrlError::Parse)
}
fn build_request(&self, builder: RequestBuilder, request_body: &Option<B>, attempt_count: u8) -> Result<Request, BuildError>;
fn handle_response(&self, status: StatusCode, headers: HeaderMap, response_body: Bytes) -> Result<Self::Successful, HandleError>;
fn rate_limit_key_name(&self) -> Option<String> {
None
}
}
#[derive(Clone, Debug, Default)]
pub struct RequestConfig {
pub retry: RetryConfig,
pub timeout: Duration = Duration::from_secs(3),
pub use_testnet: bool,
pub cache_testnet_calls: Option<Duration> = Some(Duration::from_days(30)),
pub mock_cache_dir: Option<PathBuf>,
pub ban_cooldown: Duration = Duration::from_secs(300),
}
#[derive(Debug, miette::Diagnostic, derive_more::Display, thiserror::Error, derive_more::From)]
pub enum HandleError {
#[diagnostic(transparent)]
Api(ApiError),
#[diagnostic(code(v_exchanges::http::handle::parse), help("The response body could not be parsed. Check if the API response format has changed."))]
Parse(Report),
}
#[non_exhaustive]
#[derive(Debug, miette::Diagnostic, derive_more::Display, thiserror::Error, derive_more::From)]
pub enum ApiError {
#[diagnostic(transparent)]
Ip(IpError),
#[diagnostic(transparent)]
Auth(AuthError),
#[error(transparent)]
Other(Report),
}
#[non_exhaustive]
#[allow(unused_assignments)] #[derive(Debug, miette::Diagnostic, thiserror::Error)]
pub enum IpError {
#[error("IP timed out or banned until {until:?}")]
#[diagnostic(code(v_exchanges::ip::timeout), help("Your IP has been rate-limited. Wait until the specified time or reduce request frequency."))]
Timeout {
until: Option<Timestamp>,
},
#[error("blocked by WAF: {msg}")]
#[diagnostic(
code(v_exchanges::ip::waf),
help("Your request was blocked by the exchange's CDN/WAF. This could be geo-blocking, rate-limiting, or a malformed request.")
)]
Waf { msg: String },
#[error("geo-blocked: {msg}")]
#[diagnostic(
code(v_exchanges::ip::geo_blocked),
help("Your IP is in a region restricted by the exchange. Use a VPN or contact the exchange for more information.")
)]
GeoBlocked { msg: String },
}
#[non_exhaustive]
#[allow(unused_assignments)] #[derive(Debug, miette::Diagnostic, thiserror::Error)]
pub enum AuthError {
#[error("API key has expired: {msg}")]
#[diagnostic(code(v_exchanges::http::api::auth::key_expired), help("Generate a new API key from the exchange dashboard."))]
KeyExpired { msg: String },
#[error("Unauthorized: {msg}")]
#[diagnostic(
code(v_exchanges::http::api::auth::unauthorized),
help("Check that your API key and secret are correct and have the required permissions.")
)]
Unauthorized { msg: String },
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
pub enum RequestError {
#[error("failed to send HTTP request: {0}")]
#[diagnostic(code(v_exchanges::http::request::send), help("Check your network connection and firewall settings."))]
SendRequest(#[source] reqwest::Error),
#[error("failed to parse response body as UTF-8: {0}")]
#[diagnostic(code(v_exchanges::http::request::utf8))]
Utf8Error(#[from] std::str::Utf8Error),
#[error("failed to receive HTTP response: {0}")]
#[diagnostic(code(v_exchanges::http::request::receive), help("The server may have closed the connection. Try again."))]
ReceiveResponse(#[source] reqwest::Error),
#[error("handler failed to build a request: {0}")]
#[diagnostic(transparent)]
BuildRequest(#[from] BuildError),
#[error("handler failed to process the response: {0}")]
#[diagnostic(transparent)]
HandleResponse(#[from] HandleError),
#[error("{0}")]
#[diagnostic(transparent)]
Url(#[from] UrlError),
#[allow(missing_docs)]
#[error(transparent)]
Other(#[from] Report),
}
#[derive(Debug, miette::Diagnostic, derive_more::Display, thiserror::Error, derive_more::From)]
pub enum BuildError {
#[diagnostic(transparent)]
Auth(ConstructAuthError),
#[diagnostic(code(v_exchanges::http::build::url_serialization), help("Check that all request parameters can be URL-encoded."))]
UrlSerialization(serde_urlencoded::ser::Error),
#[diagnostic(code(v_exchanges::http::build::json_serialization), help("Check that all request body fields can be serialized to JSON."))]
JsonSerialization(serde_json::Error),
#[allow(missing_docs)]
#[error(transparent)]
Other(Report),
}
fn is_retryable_request_error(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || (e.is_request() && e.status().is_none())
}
static TEST_CALLS_PATH: OnceLock<PathBuf> = OnceLock::new();
fn test_calls_path<Q: Serialize>(url: &Url, query: &Option<Q>) -> PathBuf {
let base = TEST_CALLS_PATH.get_or_init(|| v_utils::xdg_cache_dir!("test_calls"));
let mut filename = url.to_string();
if query.is_some() {
filename.push('?');
filename.push_str(&serde_urlencoded::to_string(query).unwrap_or_default());
}
base.join(filename)
}
const MOCK_CACHE_DURATION: Duration = Duration::from_days(30);
fn mock_cache_path(cache_dir: &PathBuf, url: &Url) -> PathBuf {
let host = url.host_str().unwrap_or("unknown");
let path = url.path().trim_start_matches('/');
cache_dir.join(host).join(path)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use jiff::SignedDuration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::*;
struct BanHandler {
base: Url,
network_ran: AtomicBool,
}
impl RequestHandler<()> for BanHandler {
type Successful = ();
fn base_url(&self, _is_test: bool) -> Result<Url, UrlError> {
Ok(self.base.clone())
}
fn build_request(&self, builder: RequestBuilder, _body: &Option<()>, _attempt: u8) -> Result<Request, BuildError> {
self.network_ran.store(true, Ordering::SeqCst);
builder.build().map_err(|e| BuildError::Other(eyre!(e)))
}
fn handle_response(&self, _status: StatusCode, _headers: HeaderMap, _body: Bytes) -> Result<(), HandleError> {
Err(HandleError::Api(ApiError::Ip(IpError::Timeout { until: None })))
}
}
#[tokio::test]
async fn banned_bucket_short_circuits_then_recovers() {
let mut client = Client::default();
client.config.retry.max_retries = 0; let handler = BanHandler {
base: Url::parse("https://api.testex.com/").unwrap(),
network_ran: AtomicBool::new(false),
};
let bucket = Ustr::from("ip.testex");
client.banned_until.insert(bucket, Timestamp::now() + SignedDuration::from_secs(60));
let err = client.get_no_query("", &handler).await.unwrap_err();
assert!(matches!(err, RequestError::HandleResponse(HandleError::Api(ApiError::Ip(IpError::Timeout { .. })))));
assert!(!handler.network_ran.load(Ordering::SeqCst), "banned bucket hit the network");
client.banned_until.insert(bucket, Timestamp::now() - SignedDuration::from_secs(60));
let _ = client.get_no_query("", &handler).await;
assert!(handler.network_ran.load(Ordering::SeqCst), "expired ban did not proceed");
assert!(!client.banned_until.contains_key(&bucket), "expired ban was not evicted");
}
#[tokio::test]
async fn ban_recorded_with_cooldown_fallback() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = async {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
sock.write_all(b"HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\n\r\n").await.unwrap();
};
let client = Client::default();
let handler = BanHandler {
base: Url::parse(&format!("http://{addr}/")).unwrap(),
network_ran: AtomicBool::new(false),
};
let before = Timestamp::now();
let (_, res) = tokio::join!(server, client.get_no_query("", &handler));
assert!(matches!(res, Err(RequestError::HandleResponse(HandleError::Api(ApiError::Ip(IpError::Timeout { until: None }))))));
let bucket = Ustr::from("ip.0"); let until = *client.banned_until.get(&bucket).expect("ban recorded");
let expected = before + client.config.ban_cooldown;
assert!(
until.duration_since(expected).abs() < SignedDuration::from_secs(2),
"recorded unban {until} far from expected {expected}"
);
}
}