use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
use reqwest::{Method, StatusCode, Url};
use serde::de::DeserializeOwned;
use crate::api::distributor::Distributor;
use crate::error::{message_from_body, truncate, Error, Result};
use crate::rpc::DEFAULT_RPC_TIMEOUT;
use crate::trade::{TradeApi, TradeState};
pub const DEFAULT_BASE_URL: &str = "https://api.fyuz.fun";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const MAX_RESPONSE_BYTES: usize = 1 << 20;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_MAX_RETRIES: u32 = 3;
const DEFAULT_RETRY_BASE_DELAY: Duration = Duration::from_millis(250);
const DEFAULT_MAX_RETRY_DELAY: Duration = Duration::from_secs(8);
pub(crate) type QueryPairs = Vec<(&'static str, String)>;
#[derive(Debug, Clone)]
pub struct FyuzClient {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
http: reqwest::Client,
base_url: Url,
max_retries: u32,
retry_base_delay: Duration,
max_retry_delay: Duration,
rng: AtomicU64,
rpc_url: Option<String>,
rpc_block: Option<String>,
rpc_http: reqwest::Client,
trade_state: TradeState,
}
impl FyuzClient {
pub fn new() -> Result<Self> {
Self::builder().build()
}
pub fn builder() -> FyuzClientBuilder {
FyuzClientBuilder::new()
}
pub fn base_url(&self) -> &Url {
&self.inner.base_url
}
pub fn distributor(&self) -> Distributor<'_> {
Distributor::new(self)
}
pub fn trade(&self) -> TradeApi<'_> {
TradeApi::new(self)
}
pub(crate) fn rpc_url(&self) -> Option<&str> {
self.inner.rpc_url.as_deref()
}
pub(crate) fn rpc_block(&self) -> Option<&str> {
self.inner.rpc_block.as_deref()
}
pub(crate) fn rpc_http(&self) -> &reqwest::Client {
&self.inner.rpc_http
}
pub(crate) fn trade_state(&self) -> &TradeState {
&self.inner.trade_state
}
pub(crate) async fn get_json<T: DeserializeOwned>(
&self,
path: &str,
query: QueryPairs,
) -> Result<T> {
self.request(Method::GET, path, query, None).await
}
pub(crate) async fn post_json<T: DeserializeOwned>(
&self,
path: &str,
query: QueryPairs,
body: serde_json::Value,
) -> Result<T> {
self.request(Method::POST, path, query, Some(body)).await
}
async fn request<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
query: QueryPairs,
body: Option<serde_json::Value>,
) -> Result<T> {
let bytes = self.send_with_retry(method, path, query, body).await?;
serde_json::from_slice(&bytes).map_err(|e| Error::Decode {
message: e.to_string(),
body: truncate(String::from_utf8_lossy(&bytes).trim(), 256),
})
}
async fn send_with_retry(
&self,
method: Method,
path: &str,
query: QueryPairs,
body: Option<serde_json::Value>,
) -> Result<Vec<u8>> {
let url = build_url(&self.inner.base_url, path);
let mut attempt: u32 = 0;
loop {
match self.attempt(&method, &url, &query, body.as_ref()).await {
Ok(bytes) => return Ok(bytes),
Err(Failure::Fatal(err)) => return Err(err),
Err(Failure::Retryable { error, retry_after }) => {
if attempt >= self.inner.max_retries {
return Err(error);
}
let delay = match retry_after {
Some(after) => after
.max(self.inner.retry_base_delay)
.min(self.inner.max_retry_delay),
None => self.backoff(attempt),
};
tokio::time::sleep(delay).await;
attempt += 1;
}
}
}
}
async fn attempt(
&self,
method: &Method,
url: &Url,
query: &QueryPairs,
body: Option<&serde_json::Value>,
) -> std::result::Result<Vec<u8>, Failure> {
let mut request = self.inner.http.request(method.clone(), url.clone());
if !query.is_empty() {
request = request.query(query);
}
if let Some(body) = body {
request = request.json(body);
}
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
let retryable = err.is_timeout() || err.is_connect() || err.is_request();
return Err(if retryable {
Failure::Retryable {
error: Error::Transport(err),
retry_after: None,
}
} else {
Failure::Fatal(Error::Transport(err))
});
}
};
let status = response.status();
let retry_after = parse_retry_after(response.headers());
let bytes = match read_capped(response).await {
Ok(bytes) => bytes,
Err(BodyError::Transport(err)) => {
return Err(Failure::Retryable {
error: Error::Transport(err),
retry_after,
})
}
Err(BodyError::TooLarge(seen)) => {
return Err(Failure::Fatal(Error::Api {
status: status.as_u16(),
message: format!(
"response body is at least {seen} bytes, past the \
{MAX_RESPONSE_BYTES}-byte ceiling"
),
}))
}
};
if status.is_success() {
return Ok(bytes);
}
let message = message_from_body(&bytes, status);
if status == StatusCode::TOO_MANY_REQUESTS {
return Err(Failure::Retryable {
error: Error::RateLimited {
message,
retry_after,
},
retry_after,
});
}
let error = Error::Api {
status: status.as_u16(),
message,
};
Err(if status.is_server_error() {
Failure::Retryable { error, retry_after }
} else {
Failure::Fatal(error)
})
}
fn backoff(&self, attempt: u32) -> Duration {
let factor = 1u32 << attempt.min(16);
let cap = self
.inner
.retry_base_delay
.saturating_mul(factor)
.min(self.inner.max_retry_delay);
let cap_ms = cap.as_millis() as u64;
if cap_ms == 0 {
return Duration::ZERO;
}
Duration::from_millis(self.next_random() % (cap_ms + 1))
}
fn next_random(&self) -> u64 {
let previous = self
.inner
.rng
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |state| {
Some(xorshift(state))
})
.unwrap_or(1);
xorshift(previous)
}
}
fn xorshift(mut state: u64) -> u64 {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
}
enum BodyError {
TooLarge(usize),
Transport(reqwest::Error),
}
async fn read_capped(mut response: reqwest::Response) -> std::result::Result<Vec<u8>, BodyError> {
if let Some(declared) = response.content_length() {
if declared > MAX_RESPONSE_BYTES as u64 {
return Err(BodyError::TooLarge(declared as usize));
}
}
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(BodyError::Transport)? {
if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
return Err(BodyError::TooLarge(body.len() + chunk.len()));
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
enum Failure {
Fatal(Error),
Retryable {
error: Error,
retry_after: Option<Duration>,
},
}
pub(crate) fn seg(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char)
}
_ => {
encoded.push('%');
encoded.push(HEX[usize::from(byte >> 4)] as char);
encoded.push(HEX[usize::from(byte & 0x0f)] as char);
}
}
}
encoded
}
fn build_url(base: &Url, path: &str) -> Url {
let mut url = base.clone();
let mut joined = url.path().trim_end_matches('/').to_string();
for segment in path.split('/').filter(|s| !s.is_empty()) {
joined.push('/');
joined.push_str(segment);
}
url.set_path(&joined);
url
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
let raw = headers.get(RETRY_AFTER)?.to_str().ok()?;
let seconds: u64 = raw.trim().parse().ok()?;
Some(Duration::from_secs(seconds))
}
#[derive(Debug, Clone)]
pub struct FyuzClientBuilder {
base_url: Url,
user_agent: String,
timeout: Duration,
max_retries: u32,
retry_base_delay: Duration,
max_retry_delay: Duration,
rpc_url: Option<String>,
rpc_timeout: Option<Duration>,
rpc_block: Option<String>,
rpc_headers: HeaderMap,
}
impl FyuzClientBuilder {
pub fn new() -> Self {
Self {
base_url: Url::parse(DEFAULT_BASE_URL).expect("DEFAULT_BASE_URL is a valid URL"),
user_agent: format!("fyuz-sdk-rust/{VERSION}"),
timeout: DEFAULT_TIMEOUT,
max_retries: DEFAULT_MAX_RETRIES,
retry_base_delay: DEFAULT_RETRY_BASE_DELAY,
max_retry_delay: DEFAULT_MAX_RETRY_DELAY,
rpc_url: None,
rpc_timeout: None,
rpc_block: None,
rpc_headers: HeaderMap::new(),
}
}
pub fn base_url(mut self, base_url: &str) -> Result<Self> {
let mut parsed = Url::parse(base_url).map_err(|e| Error::InvalidBaseUrl {
url: base_url.to_string(),
message: e.to_string(),
})?;
if parsed.cannot_be_a_base() {
return Err(Error::InvalidBaseUrl {
url: base_url.to_string(),
message: "URL cannot be used as a base".to_string(),
});
}
if !matches!(parsed.scheme(), "http" | "https") {
return Err(Error::InvalidBaseUrl {
url: base_url.to_string(),
message: format!(
"unsupported scheme {:?}, expected http or https",
parsed.scheme()
),
});
}
if !parsed.path().ends_with('/') {
let path = format!("{}/", parsed.path());
parsed.set_path(&path);
}
self.base_url = parsed;
Ok(self)
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
pub fn retry_base_delay(mut self, delay: Duration) -> Self {
self.retry_base_delay = delay;
self
}
pub fn max_retry_delay(mut self, delay: Duration) -> Self {
self.max_retry_delay = delay;
self
}
pub fn rpc_url(mut self, url: impl Into<String>) -> Self {
self.rpc_url = Some(url.into());
self
}
pub fn rpc_timeout(mut self, timeout: Duration) -> Self {
self.rpc_timeout = Some(timeout);
self
}
pub fn rpc_header(mut self, name: &str, value: &str) -> Result<Self> {
let name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| Error::InvalidArgument {
message: format!("{name:?} is not a valid header name: {e}"),
})?;
let value = HeaderValue::from_str(value).map_err(|e| Error::InvalidArgument {
message: format!("the value for header {name:?} is not a valid header value: {e}"),
})?;
self.rpc_headers.insert(name, value);
Ok(self)
}
pub fn rpc_block(mut self, block: impl Into<String>) -> Self {
self.rpc_block = Some(block.into());
self
}
pub fn build(self) -> Result<FyuzClient> {
let http = reqwest::Client::builder()
.user_agent(&self.user_agent)
.timeout(self.timeout)
.build()?;
let rpc_http = reqwest::Client::builder()
.user_agent(&self.user_agent)
.timeout(self.rpc_timeout.unwrap_or(DEFAULT_RPC_TIMEOUT))
.default_headers(self.rpc_headers)
.build()?;
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
Ok(FyuzClient {
inner: Arc::new(Inner {
http,
base_url: self.base_url,
max_retries: self.max_retries,
retry_base_delay: self.retry_base_delay,
max_retry_delay: self.max_retry_delay,
rng: AtomicU64::new(seed),
rpc_url: self.rpc_url,
rpc_block: self.rpc_block,
rpc_http,
trade_state: TradeState::default(),
}),
})
}
}
impl Default for FyuzClientBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base(url: &str) -> Url {
FyuzClientBuilder::new().base_url(url).unwrap().base_url
}
#[test]
fn joins_paths_onto_a_bare_host() {
assert_eq!(
build_url(&base("http://127.0.0.1:8080"), "/tokens/king").as_str(),
"http://127.0.0.1:8080/tokens/king"
);
}
#[test]
fn joins_paths_onto_a_prefixed_base() {
assert_eq!(
build_url(&base("http://gateway/fyuz"), "/distributor/rounds/7").as_str(),
"http://gateway/fyuz/distributor/rounds/7"
);
}
#[test]
fn percent_encodes_path_segments() {
let url = build_url(&base("https://api.fyuz.fun"), "/wallet/0x00 ff");
assert_eq!(url.as_str(), "https://api.fyuz.fun/wallet/0x00%20ff");
}
#[test]
fn seg_escapes_everything_outside_the_unreserved_set() {
assert_eq!(
seg("0x1111111111111111111111111111111111111111"),
"0x1111111111111111111111111111111111111111"
);
assert_eq!(seg("-._~"), "-._~", "unreserved punctuation stays literal");
assert_eq!(seg("a/b"), "a%2Fb", "a slash must not become structure");
assert_eq!(seg("a?b=c#d"), "a%3Fb%3Dc%23d");
assert_eq!(seg("0x00 ff"), "0x00%20ff");
assert_eq!(seg("%2F"), "%252F", "a pre-escaped slash is data too");
assert_eq!(seg("é"), "%C3%A9", "non-ASCII is escaped per UTF-8 byte");
}
#[test]
fn a_slash_in_an_argument_cannot_retarget_the_request() {
let base = base("https://api.fyuz.fun");
let url = build_url(&base, &format!("/wallet/{}", seg("../distributor/stats")));
assert_eq!(
url.path(),
"/wallet/..%2Fdistributor%2Fstats",
"the argument must stay one segment"
);
let url = build_url(&base, &format!("/tier/{}", seg("0xabc/../../health")));
assert_eq!(url.path(), "/tier/0xabc%2F..%2F..%2Fhealth");
}
#[test]
fn a_bare_dot_segment_collapses_rather_than_reaching_a_sibling_endpoint() {
let url = build_url(
&base("https://api.fyuz.fun"),
&format!("/wallet/{}", seg("..")),
);
assert_eq!(url.path(), "/");
assert_eq!(seg(".."), "..");
}
#[test]
fn build_url_does_not_escape_an_already_escaped_segment_twice() {
let url = build_url(
&base("https://api.fyuz.fun"),
&format!("/wallet/{}", seg("a b")),
);
assert_eq!(url.as_str(), "https://api.fyuz.fun/wallet/a%20b");
}
#[test]
fn rejects_non_http_base_urls() {
let err = FyuzClientBuilder::new()
.base_url("ftp://example.com")
.unwrap_err();
assert!(matches!(err, Error::InvalidBaseUrl { .. }));
let err = FyuzClientBuilder::new().base_url("not a url").unwrap_err();
assert!(matches!(err, Error::InvalidBaseUrl { .. }));
}
#[test]
fn parses_retry_after_seconds() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(RETRY_AFTER, "3".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(3)));
headers.insert(
RETRY_AFTER,
"Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
);
assert_eq!(parse_retry_after(&headers), None);
}
#[test]
fn backoff_stays_within_the_cap() {
let client = FyuzClientBuilder::new()
.retry_base_delay(Duration::from_millis(100))
.max_retry_delay(Duration::from_millis(400))
.build()
.unwrap();
for attempt in 0..6 {
let delay = client.backoff(attempt);
assert!(delay <= Duration::from_millis(400), "{delay:?}");
}
assert!(client.backoff(0) <= Duration::from_millis(100));
}
}