1use crate::auth::UsAuth;
2use crate::error::PolymarketUsError;
3use crate::resources::{
4 AccountClient, EventsClient, MarketsClient, OrdersClient, PortfolioClient, SearchClient,
5};
6use crate::retry::{is_retryable_status, RetryConfig};
7use crate::stream::{MarketStreamClient, PrivateStreamClient};
8use crate::types;
9use reqwest::Method;
10use serde::de::DeserializeOwned;
11use serde::Serialize;
12use std::time::Duration;
13
14const DEFAULT_GATEWAY_BASE_URL: &str = "https://gateway.polymarket.us";
15const DEFAULT_API_BASE_URL: &str = "https://api.polymarket.us";
16const DEFAULT_CORRELATION_ID_PREFIX: &str = "pmrs";
17
18#[derive(Clone)]
19pub struct PolymarketUsClient {
20 http: reqwest::Client,
21 gateway_base_url: String,
22 api_base_url: String,
23 auth: Option<UsAuth>,
24 retry_config: RetryConfig,
25 correlation_id_prefix: String,
26}
27
28pub struct PolymarketUsClientBuilder {
29 gateway_base_url: String,
30 api_base_url: String,
31 auth: Option<UsAuth>,
32 http: Option<reqwest::Client>,
33 timeout: Duration,
34 retry_config: RetryConfig,
35 correlation_id_prefix: String,
36}
37
38impl Default for PolymarketUsClientBuilder {
39 fn default() -> Self {
40 Self {
41 gateway_base_url: DEFAULT_GATEWAY_BASE_URL.to_string(),
42 api_base_url: DEFAULT_API_BASE_URL.to_string(),
43 auth: None,
44 http: None,
45 timeout: Duration::from_secs(30),
46 retry_config: RetryConfig::default(),
47 correlation_id_prefix: DEFAULT_CORRELATION_ID_PREFIX.to_string(),
48 }
49 }
50}
51
52impl PolymarketUsClientBuilder {
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn gateway_base_url(mut self, url: impl Into<String>) -> Self {
58 self.gateway_base_url = url.into();
59 self
60 }
61
62 pub fn api_base_url(mut self, url: impl Into<String>) -> Self {
63 self.api_base_url = url.into();
64 self
65 }
66
67 pub fn timeout(mut self, timeout: Duration) -> Self {
68 self.timeout = timeout;
69 self
70 }
71
72 pub fn auth(mut self, auth: UsAuth) -> Self {
73 self.auth = Some(auth);
74 self
75 }
76
77 pub fn http_client(mut self, http: reqwest::Client) -> Self {
78 self.http = Some(http);
79 self
80 }
81
82 pub fn retry(mut self, config: RetryConfig) -> Self {
86 self.retry_config = config;
87 self
88 }
89
90 pub fn correlation_id_prefix(mut self, prefix: impl Into<String>) -> Self {
95 self.correlation_id_prefix = prefix.into();
96 self
97 }
98
99 pub fn build(self) -> Result<PolymarketUsClient, PolymarketUsError> {
100 let http = match self.http {
101 Some(http) => http,
102 None => reqwest::Client::builder().timeout(self.timeout).build()?,
103 };
104 Ok(PolymarketUsClient {
105 http,
106 gateway_base_url: self.gateway_base_url,
107 api_base_url: self.api_base_url,
108 auth: self.auth,
109 retry_config: self.retry_config,
110 correlation_id_prefix: self.correlation_id_prefix,
111 })
112 }
113}
114
115impl PolymarketUsClient {
116 pub fn builder() -> PolymarketUsClientBuilder {
117 PolymarketUsClientBuilder::new()
118 }
119
120 pub fn with_reqwest(http: reqwest::Client, auth: Option<UsAuth>) -> Self {
121 Self {
122 http,
123 gateway_base_url: DEFAULT_GATEWAY_BASE_URL.to_string(),
124 api_base_url: DEFAULT_API_BASE_URL.to_string(),
125 auth,
126 retry_config: RetryConfig::default(),
127 correlation_id_prefix: DEFAULT_CORRELATION_ID_PREFIX.to_string(),
128 }
129 }
130
131 pub fn auth(&self) -> Option<&UsAuth> {
132 self.auth.as_ref()
133 }
134
135 pub fn api_base_url(&self) -> &str {
136 &self.api_base_url
137 }
138
139 pub fn retry_config(&self) -> &RetryConfig {
140 &self.retry_config
141 }
142
143 pub fn correlation_id_prefix(&self) -> &str {
145 &self.correlation_id_prefix
146 }
147
148 pub fn gateway_base_url(&self) -> &str {
149 &self.gateway_base_url
150 }
151
152 pub fn markets(&self) -> MarketsClient<'_> {
158 MarketsClient::new(self)
159 }
160
161 pub fn events(&self) -> EventsClient<'_> {
163 EventsClient::new(self)
164 }
165
166 pub fn orders(&self) -> OrdersClient<'_> {
168 OrdersClient::new(self)
169 }
170
171 pub fn account(&self) -> AccountClient<'_> {
173 AccountClient::new(self)
174 }
175
176 pub fn portfolio(&self) -> PortfolioClient<'_> {
178 PortfolioClient::new(self)
179 }
180
181 pub fn search(&self) -> SearchClient<'_> {
183 SearchClient::new(self)
184 }
185
186 pub fn market_stream(&self) -> MarketStreamClient {
206 MarketStreamClient::new(self.auth.clone())
207 }
208
209 pub fn private_stream(&self) -> Result<PrivateStreamClient, PolymarketUsError> {
228 let auth = self
229 .auth
230 .clone()
231 .ok_or(PolymarketUsError::MissingAuth("/v1/ws/private"))?;
232 Ok(PrivateStreamClient::new(auth))
233 }
234
235 pub async fn health(&self) -> Result<types::HealthResponse, PolymarketUsError> {
236 self.internal_request::<(), (), types::HealthResponse>(
237 Method::GET,
238 "/v1/health",
239 None,
240 None,
241 false,
242 )
243 .await
244 }
245
246 pub(crate) async fn internal_request<Q: Serialize, B: Serialize, T: DeserializeOwned>(
253 &self,
254 method: Method,
255 path: &str,
256 query: Option<&Q>,
257 body: Option<&B>,
258 authenticated: bool,
259 ) -> Result<T, PolymarketUsError> {
260 let is_idempotent = matches!(method, Method::GET | Method::DELETE);
261 let max_attempts = if is_idempotent {
262 self.retry_config.max_retries + 1
263 } else {
264 1
265 };
266
267 let base = if authenticated {
268 &self.api_base_url
269 } else {
270 &self.gateway_base_url
271 };
272 let url = format!("{}{}", base, path);
273
274 let mut attempt = 0u32;
275 loop {
276 attempt += 1;
277
278 let correlation_id = format!("{}-{}", self.correlation_id_prefix, uuid::Uuid::new_v4());
280
281 let mut rb = self
282 .http
283 .request(method.clone(), &url)
284 .header("Content-Type", "application/json")
285 .header("X-Correlation-ID", &correlation_id);
286
287 if let Some(q) = query {
288 rb = rb.query(q);
289 }
290 if let Some(b) = body {
291 rb = rb.json(b);
292 }
293 if authenticated {
294 let auth = self
295 .auth
296 .as_ref()
297 .ok_or(PolymarketUsError::MissingAuth("authenticated endpoint"))?;
298 for (name, value) in auth.signed_headers(method.as_str(), path) {
299 rb = rb.header(name, value);
300 }
301 }
302
303 let response = match rb.send().await {
305 Ok(r) => r,
306 Err(e) if is_idempotent && attempt < max_attempts && is_transport_retryable(&e) => {
307 tokio::time::sleep(self.retry_config.backoff_for(attempt)).await;
308 continue;
309 }
310 Err(e) => return Err(PolymarketUsError::Transport(e)),
311 };
312
313 let status = response.status();
314
315 let retry_after = parse_retry_after(&response);
317
318 let text = response.text().await?;
319
320 if !status.is_success() {
321 let message = extract_error_message(&text).unwrap_or_else(|| text.clone());
322
323 let err = if status.as_u16() == 429 {
325 PolymarketUsError::RateLimited {
326 message,
327 retry_after,
328 }
329 } else {
330 PolymarketUsError::from_status(status, message)
331 };
332
333 if is_idempotent && attempt < max_attempts && is_retryable_status(status.as_u16()) {
335 let delay =
336 retry_after.unwrap_or_else(|| self.retry_config.backoff_for(attempt));
337 tokio::time::sleep(delay).await;
338 continue;
339 }
340
341 return Err(err);
342 }
343
344 return if text.trim().is_empty() {
348 serde_json::from_str("null").map_err(PolymarketUsError::from)
349 } else {
350 serde_json::from_str(&text).map_err(PolymarketUsError::from)
351 };
352 }
353 }
354}
355
356fn parse_retry_after(response: &reqwest::Response) -> Option<Duration> {
363 let raw = response.headers().get("retry-after")?.to_str().ok()?;
364 parse_retry_after_value(raw)
365}
366
367fn parse_retry_after_value(raw: &str) -> Option<Duration> {
368 let raw = raw.trim();
369
370 if let Ok(secs) = raw.parse::<u64>() {
372 return Some(Duration::from_secs(secs));
373 }
374
375 let target = httpdate_to_unix_secs(raw)?;
378 let now = crate::auth::unix_timestamp_millis() / 1000;
379 Some(Duration::from_secs(target.saturating_sub(now).max(0) as u64))
380}
381
382fn httpdate_to_unix_secs(raw: &str) -> Option<i64> {
388 const MONTHS: [&str; 12] = [
389 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
390 ];
391
392 let parts: Vec<&str> = raw.split_whitespace().collect();
394 if parts.len() != 6 || parts[5] != "GMT" {
395 return None;
396 }
397
398 let day: i64 = parts[1].parse().ok()?;
399 let month = MONTHS.iter().position(|m| *m == parts[2])? as i64 + 1;
400 let year: i64 = parts[3].parse().ok()?;
401
402 let hms: Vec<&str> = parts[4].split(':').collect();
403 if hms.len() != 3 {
404 return None;
405 }
406 let (hour, minute, second): (i64, i64, i64) = (
407 hms[0].parse().ok()?,
408 hms[1].parse().ok()?,
409 hms[2].parse().ok()?,
410 );
411
412 let y = if month <= 2 { year - 1 } else { year };
414 let era = if y >= 0 { y } else { y - 399 } / 400;
415 let yoe = y - era * 400;
416 let mp = (month + 9) % 12;
417 let doy = (153 * mp + 2) / 5 + day - 1;
418 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
419 let days = era * 146_097 + doe - 719_468;
420
421 Some(days * 86_400 + hour * 3_600 + minute * 60 + second)
422}
423
424fn is_transport_retryable(e: &reqwest::Error) -> bool {
426 e.is_connect() || e.is_timeout()
427}
428
429fn extract_error_message(text: &str) -> Option<String> {
430 let json: serde_json::Value = serde_json::from_str(text).ok()?;
431 json.get("message")
432 .and_then(|v| v.as_str())
433 .map(ToOwned::to_owned)
434 .or_else(|| {
435 json.get("error")
436 .and_then(|v| v.as_str())
437 .map(ToOwned::to_owned)
438 })
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn builder_defaults_match_public_endpoints() {
447 let client = PolymarketUsClient::builder().build().unwrap();
448 assert_eq!(client.api_base_url(), "https://api.polymarket.us");
449 }
450
451 #[test]
452 fn builder_retry_config_applied() {
453 let client = PolymarketUsClient::builder()
454 .retry(RetryConfig::none())
455 .build()
456 .unwrap();
457 assert_eq!(client.retry_config().max_retries, 0);
458 }
459
460 #[test]
461 fn builder_default_retry_is_three() {
462 let client = PolymarketUsClient::builder().build().unwrap();
463 assert_eq!(client.retry_config().max_retries, 3);
464 }
465
466 #[test]
467 fn builder_correlation_id_prefix_applied() {
468 let client = PolymarketUsClient::builder()
469 .correlation_id_prefix("myapp")
470 .build()
471 .unwrap();
472 assert_eq!(client.correlation_id_prefix(), "myapp");
473 }
474
475 #[test]
476 fn market_stream_targets_the_api_host_not_the_gateway() {
477 let client = PolymarketUsClient::builder()
480 .gateway_base_url("https://gateway.example.com")
481 .build()
482 .unwrap();
483 assert_eq!(
484 client.market_stream().base_url(),
485 "wss://api.polymarket.us/v1/ws/markets"
486 );
487 }
488
489 #[test]
490 fn private_stream_requires_credentials() {
491 let client = PolymarketUsClient::builder().build().unwrap();
492 assert!(matches!(
493 client.private_stream(),
494 Err(PolymarketUsError::MissingAuth(_))
495 ));
496 }
497
498 #[test]
499 fn default_correlation_id_prefix() {
500 let client = PolymarketUsClient::builder().build().unwrap();
501 assert_eq!(client.correlation_id_prefix(), "pmrs");
502 }
503
504 #[test]
505 fn retry_after_parses_delay_seconds() {
506 assert_eq!(
507 parse_retry_after_value("120"),
508 Some(Duration::from_secs(120))
509 );
510 assert_eq!(
511 parse_retry_after_value(" 30 "),
512 Some(Duration::from_secs(30))
513 );
514 }
515
516 #[test]
517 fn retry_after_parses_http_date() {
518 assert_eq!(
520 parse_retry_after_value("Wed, 21 Oct 2015 07:28:00 GMT"),
521 Some(Duration::from_secs(0))
522 );
523 let future = parse_retry_after_value("Fri, 01 Jan 2100 00:00:00 GMT").unwrap();
525 assert!(future > Duration::from_secs(0));
526 }
527
528 #[test]
529 fn retry_after_rejects_garbage() {
530 assert_eq!(parse_retry_after_value("not-a-date"), None);
531 assert_eq!(parse_retry_after_value(""), None);
532 }
533
534 #[test]
535 fn http_date_epoch_is_zero() {
536 assert_eq!(
537 httpdate_to_unix_secs("Thu, 01 Jan 1970 00:00:00 GMT"),
538 Some(0)
539 );
540 assert_eq!(
542 httpdate_to_unix_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
543 Some(1_445_412_480)
544 );
545 }
546
547 #[test]
548 fn empty_body_deserializes_to_unit() {
549 serde_json::from_str::<()>("null").expect("unit from null");
551 serde_json::from_str::<Option<String>>("null").expect("option from null");
552 }
553
554 #[test]
555 fn with_reqwest_uses_default_retry() {
556 let http = reqwest::Client::new();
557 let client = PolymarketUsClient::with_reqwest(http, None);
558 assert_eq!(client.retry_config().max_retries, 3);
559 }
560}