use crate::auth::UsAuth;
use crate::error::PolymarketUsError;
use crate::resources::{
AccountClient, EventsClient, MarketsClient, OrdersClient, PortfolioClient, SearchClient,
};
use crate::retry::{is_retryable_status, RetryConfig};
use crate::stream::PolymarketUsStreamClient;
use crate::types;
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::time::Duration;
const DEFAULT_GATEWAY_BASE_URL: &str = "https://gateway.polymarket.us";
const DEFAULT_API_BASE_URL: &str = "https://api.polymarket.us";
const DEFAULT_CORRELATION_ID_PREFIX: &str = "pmrs";
#[derive(Clone)]
pub struct PolymarketUsClient {
http: reqwest::Client,
gateway_base_url: String,
api_base_url: String,
auth: Option<UsAuth>,
retry_config: RetryConfig,
correlation_id_prefix: String,
}
pub struct PolymarketUsClientBuilder {
gateway_base_url: String,
api_base_url: String,
auth: Option<UsAuth>,
http: Option<reqwest::Client>,
timeout: Duration,
retry_config: RetryConfig,
correlation_id_prefix: String,
}
impl Default for PolymarketUsClientBuilder {
fn default() -> Self {
Self {
gateway_base_url: DEFAULT_GATEWAY_BASE_URL.to_string(),
api_base_url: DEFAULT_API_BASE_URL.to_string(),
auth: None,
http: None,
timeout: Duration::from_secs(30),
retry_config: RetryConfig::default(),
correlation_id_prefix: DEFAULT_CORRELATION_ID_PREFIX.to_string(),
}
}
}
impl PolymarketUsClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn gateway_base_url(mut self, url: impl Into<String>) -> Self {
self.gateway_base_url = url.into();
self
}
pub fn api_base_url(mut self, url: impl Into<String>) -> Self {
self.api_base_url = url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn auth(mut self, auth: UsAuth) -> Self {
self.auth = Some(auth);
self
}
pub fn http_client(mut self, http: reqwest::Client) -> Self {
self.http = Some(http);
self
}
pub fn retry(mut self, config: RetryConfig) -> Self {
self.retry_config = config;
self
}
pub fn correlation_id_prefix(mut self, prefix: impl Into<String>) -> Self {
self.correlation_id_prefix = prefix.into();
self
}
pub fn build(self) -> Result<PolymarketUsClient, PolymarketUsError> {
let http = match self.http {
Some(http) => http,
None => reqwest::Client::builder().timeout(self.timeout).build()?,
};
Ok(PolymarketUsClient {
http,
gateway_base_url: self.gateway_base_url,
api_base_url: self.api_base_url,
auth: self.auth,
retry_config: self.retry_config,
correlation_id_prefix: self.correlation_id_prefix,
})
}
}
impl PolymarketUsClient {
pub fn builder() -> PolymarketUsClientBuilder {
PolymarketUsClientBuilder::new()
}
pub fn with_reqwest(http: reqwest::Client, auth: Option<UsAuth>) -> Self {
Self {
http,
gateway_base_url: DEFAULT_GATEWAY_BASE_URL.to_string(),
api_base_url: DEFAULT_API_BASE_URL.to_string(),
auth,
retry_config: RetryConfig::default(),
correlation_id_prefix: DEFAULT_CORRELATION_ID_PREFIX.to_string(),
}
}
pub fn auth(&self) -> Option<&UsAuth> {
self.auth.as_ref()
}
pub fn api_base_url(&self) -> &str {
&self.api_base_url
}
pub fn retry_config(&self) -> &RetryConfig {
&self.retry_config
}
pub fn correlation_id_prefix(&self) -> &str {
&self.correlation_id_prefix
}
pub fn gateway_base_url(&self) -> &str {
&self.gateway_base_url
}
pub fn markets(&self) -> MarketsClient<'_> {
MarketsClient::new(self)
}
pub fn events(&self) -> EventsClient<'_> {
EventsClient::new(self)
}
pub fn orders(&self) -> OrdersClient<'_> {
OrdersClient::new(self)
}
pub fn account(&self) -> AccountClient<'_> {
AccountClient::new(self)
}
pub fn portfolio(&self) -> PortfolioClient<'_> {
PortfolioClient::new(self)
}
pub fn search(&self) -> SearchClient<'_> {
SearchClient::new(self)
}
pub fn streaming(&self) -> PolymarketUsStreamClient {
PolymarketUsStreamClient::from_gateway_base_url(
self.gateway_base_url.clone(),
self.auth.clone(),
)
}
pub async fn health(&self) -> Result<types::HealthResponse, PolymarketUsError> {
self.internal_request::<(), (), types::HealthResponse>(
Method::GET,
"/v1/health",
None,
None,
false,
)
.await
}
pub(crate) async fn internal_request<Q: Serialize, B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
query: Option<&Q>,
body: Option<&B>,
authenticated: bool,
) -> Result<T, PolymarketUsError> {
let is_idempotent = matches!(method, Method::GET | Method::DELETE);
let max_attempts = if is_idempotent {
self.retry_config.max_retries + 1
} else {
1
};
let base = if authenticated {
&self.api_base_url
} else {
&self.gateway_base_url
};
let url = format!("{}{}", base, path);
let mut attempt = 0u32;
loop {
attempt += 1;
let correlation_id = format!("{}-{}", self.correlation_id_prefix, uuid::Uuid::new_v4());
let mut rb = self
.http
.request(method.clone(), &url)
.header("Content-Type", "application/json")
.header("X-Correlation-ID", &correlation_id);
if let Some(q) = query {
rb = rb.query(q);
}
if let Some(b) = body {
rb = rb.json(b);
}
if authenticated {
let auth = self
.auth
.as_ref()
.ok_or(PolymarketUsError::MissingAuth("authenticated endpoint"))?;
for (name, value) in auth.signed_headers(method.as_str(), path) {
rb = rb.header(name, value);
}
}
let response = match rb.send().await {
Ok(r) => r,
Err(e) if is_idempotent && attempt < max_attempts && is_transport_retryable(&e) => {
tokio::time::sleep(self.retry_config.backoff_for(attempt)).await;
continue;
}
Err(e) => return Err(PolymarketUsError::Transport(e)),
};
let status = response.status();
let retry_after = parse_retry_after(&response);
let text = response.text().await?;
if !status.is_success() {
let message = extract_error_message(&text).unwrap_or_else(|| text.clone());
let err = if status.as_u16() == 429 {
PolymarketUsError::RateLimited {
message,
retry_after,
}
} else {
PolymarketUsError::from_status(status, message)
};
if is_idempotent && attempt < max_attempts && is_retryable_status(status.as_u16()) {
let delay =
retry_after.unwrap_or_else(|| self.retry_config.backoff_for(attempt));
tokio::time::sleep(delay).await;
continue;
}
return Err(err);
}
return if text.trim().is_empty() {
serde_json::from_str("null").map_err(PolymarketUsError::from)
} else {
serde_json::from_str(&text).map_err(PolymarketUsError::from)
};
}
}
}
fn parse_retry_after(response: &reqwest::Response) -> Option<Duration> {
let raw = response.headers().get("retry-after")?.to_str().ok()?;
parse_retry_after_value(raw)
}
fn parse_retry_after_value(raw: &str) -> Option<Duration> {
let raw = raw.trim();
if let Ok(secs) = raw.parse::<u64>() {
return Some(Duration::from_secs(secs));
}
let target = httpdate_to_unix_secs(raw)?;
let now = crate::auth::unix_timestamp_millis() / 1000;
Some(Duration::from_secs(target.saturating_sub(now).max(0) as u64))
}
fn httpdate_to_unix_secs(raw: &str) -> Option<i64> {
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let parts: Vec<&str> = raw.split_whitespace().collect();
if parts.len() != 6 || parts[5] != "GMT" {
return None;
}
let day: i64 = parts[1].parse().ok()?;
let month = MONTHS.iter().position(|m| *m == parts[2])? as i64 + 1;
let year: i64 = parts[3].parse().ok()?;
let hms: Vec<&str> = parts[4].split(':').collect();
if hms.len() != 3 {
return None;
}
let (hour, minute, second): (i64, i64, i64) = (
hms[0].parse().ok()?,
hms[1].parse().ok()?,
hms[2].parse().ok()?,
);
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + hour * 3_600 + minute * 60 + second)
}
fn is_transport_retryable(e: &reqwest::Error) -> bool {
e.is_connect() || e.is_timeout()
}
fn extract_error_message(text: &str) -> Option<String> {
let json: serde_json::Value = serde_json::from_str(text).ok()?;
json.get("message")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
.or_else(|| {
json.get("error")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_defaults_match_public_endpoints() {
let client = PolymarketUsClient::builder().build().unwrap();
assert_eq!(client.api_base_url(), "https://api.polymarket.us");
}
#[test]
fn builder_retry_config_applied() {
let client = PolymarketUsClient::builder()
.retry(RetryConfig::none())
.build()
.unwrap();
assert_eq!(client.retry_config().max_retries, 0);
}
#[test]
fn builder_default_retry_is_three() {
let client = PolymarketUsClient::builder().build().unwrap();
assert_eq!(client.retry_config().max_retries, 3);
}
#[test]
fn builder_correlation_id_prefix_applied() {
let client = PolymarketUsClient::builder()
.correlation_id_prefix("myapp")
.build()
.unwrap();
assert_eq!(client.correlation_id_prefix(), "myapp");
}
#[test]
fn streaming_derives_websocket_url_from_gateway() {
let client = PolymarketUsClient::builder()
.gateway_base_url("https://gateway.example.com")
.build()
.unwrap();
assert_eq!(
client.streaming().base_url(),
"wss://gateway.example.com/ws"
);
}
#[test]
fn streaming_uses_default_gateway() {
let client = PolymarketUsClient::builder().build().unwrap();
assert_eq!(
client.streaming().base_url(),
"wss://gateway.polymarket.us/ws"
);
}
#[test]
fn default_correlation_id_prefix() {
let client = PolymarketUsClient::builder().build().unwrap();
assert_eq!(client.correlation_id_prefix(), "pmrs");
}
#[test]
fn retry_after_parses_delay_seconds() {
assert_eq!(
parse_retry_after_value("120"),
Some(Duration::from_secs(120))
);
assert_eq!(
parse_retry_after_value(" 30 "),
Some(Duration::from_secs(30))
);
}
#[test]
fn retry_after_parses_http_date() {
assert_eq!(
parse_retry_after_value("Wed, 21 Oct 2015 07:28:00 GMT"),
Some(Duration::from_secs(0))
);
let future = parse_retry_after_value("Fri, 01 Jan 2100 00:00:00 GMT").unwrap();
assert!(future > Duration::from_secs(0));
}
#[test]
fn retry_after_rejects_garbage() {
assert_eq!(parse_retry_after_value("not-a-date"), None);
assert_eq!(parse_retry_after_value(""), None);
}
#[test]
fn http_date_epoch_is_zero() {
assert_eq!(
httpdate_to_unix_secs("Thu, 01 Jan 1970 00:00:00 GMT"),
Some(0)
);
assert_eq!(
httpdate_to_unix_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
Some(1_445_412_480)
);
}
#[test]
fn empty_body_deserializes_to_unit() {
serde_json::from_str::<()>("null").expect("unit from null");
serde_json::from_str::<Option<String>>("null").expect("option from null");
}
#[test]
fn with_reqwest_uses_default_retry() {
let http = reqwest::Client::new();
let client = PolymarketUsClient::with_reqwest(http, None);
assert_eq!(client.retry_config().max_retries, 3);
}
}