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::PolymarketUsStreamClient;
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 streaming(&self) -> PolymarketUsStreamClient {
203 PolymarketUsStreamClient::from_gateway_base_url(
204 self.gateway_base_url.clone(),
205 self.auth.clone(),
206 )
207 }
208
209 pub async fn health(&self) -> Result<types::HealthResponse, PolymarketUsError> {
210 self.internal_request::<(), (), types::HealthResponse>(
211 Method::GET,
212 "/v1/health",
213 None,
214 None,
215 false,
216 )
217 .await
218 }
219
220 pub(crate) async fn internal_request<Q: Serialize, B: Serialize, T: DeserializeOwned>(
227 &self,
228 method: Method,
229 path: &str,
230 query: Option<&Q>,
231 body: Option<&B>,
232 authenticated: bool,
233 ) -> Result<T, PolymarketUsError> {
234 let is_idempotent = matches!(method, Method::GET | Method::DELETE);
235 let max_attempts = if is_idempotent {
236 self.retry_config.max_retries + 1
237 } else {
238 1
239 };
240
241 let base = if authenticated {
242 &self.api_base_url
243 } else {
244 &self.gateway_base_url
245 };
246 let url = format!("{}{}", base, path);
247
248 let mut attempt = 0u32;
249 loop {
250 attempt += 1;
251
252 let correlation_id = format!("{}-{}", self.correlation_id_prefix, uuid::Uuid::new_v4());
254
255 let mut rb = self
256 .http
257 .request(method.clone(), &url)
258 .header("Content-Type", "application/json")
259 .header("X-Correlation-ID", &correlation_id);
260
261 if let Some(q) = query {
262 rb = rb.query(q);
263 }
264 if let Some(b) = body {
265 rb = rb.json(b);
266 }
267 if authenticated {
268 let auth = self
269 .auth
270 .as_ref()
271 .ok_or(PolymarketUsError::MissingAuth("authenticated endpoint"))?;
272 for (name, value) in auth.signed_headers(method.as_str(), path) {
273 rb = rb.header(name, value);
274 }
275 }
276
277 let response = match rb.send().await {
279 Ok(r) => r,
280 Err(e) if is_idempotent && attempt < max_attempts && is_transport_retryable(&e) => {
281 tokio::time::sleep(self.retry_config.backoff_for(attempt)).await;
282 continue;
283 }
284 Err(e) => return Err(PolymarketUsError::Transport(e)),
285 };
286
287 let status = response.status();
288
289 let retry_after = parse_retry_after(&response);
291
292 let text = response.text().await?;
293
294 if !status.is_success() {
295 let message = extract_error_message(&text).unwrap_or_else(|| text.clone());
296
297 let err = if status.as_u16() == 429 {
299 PolymarketUsError::RateLimited {
300 message,
301 retry_after,
302 }
303 } else {
304 PolymarketUsError::from_status(status, message)
305 };
306
307 if is_idempotent && attempt < max_attempts && is_retryable_status(status.as_u16()) {
309 let delay =
310 retry_after.unwrap_or_else(|| self.retry_config.backoff_for(attempt));
311 tokio::time::sleep(delay).await;
312 continue;
313 }
314
315 return Err(err);
316 }
317
318 return if text.trim().is_empty() {
322 serde_json::from_str("null").map_err(PolymarketUsError::from)
323 } else {
324 serde_json::from_str(&text).map_err(PolymarketUsError::from)
325 };
326 }
327 }
328}
329
330fn parse_retry_after(response: &reqwest::Response) -> Option<Duration> {
337 let raw = response.headers().get("retry-after")?.to_str().ok()?;
338 parse_retry_after_value(raw)
339}
340
341fn parse_retry_after_value(raw: &str) -> Option<Duration> {
342 let raw = raw.trim();
343
344 if let Ok(secs) = raw.parse::<u64>() {
346 return Some(Duration::from_secs(secs));
347 }
348
349 let target = httpdate_to_unix_secs(raw)?;
352 let now = crate::auth::unix_timestamp_millis() / 1000;
353 Some(Duration::from_secs(target.saturating_sub(now).max(0) as u64))
354}
355
356fn httpdate_to_unix_secs(raw: &str) -> Option<i64> {
362 const MONTHS: [&str; 12] = [
363 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
364 ];
365
366 let parts: Vec<&str> = raw.split_whitespace().collect();
368 if parts.len() != 6 || parts[5] != "GMT" {
369 return None;
370 }
371
372 let day: i64 = parts[1].parse().ok()?;
373 let month = MONTHS.iter().position(|m| *m == parts[2])? as i64 + 1;
374 let year: i64 = parts[3].parse().ok()?;
375
376 let hms: Vec<&str> = parts[4].split(':').collect();
377 if hms.len() != 3 {
378 return None;
379 }
380 let (hour, minute, second): (i64, i64, i64) = (
381 hms[0].parse().ok()?,
382 hms[1].parse().ok()?,
383 hms[2].parse().ok()?,
384 );
385
386 let y = if month <= 2 { year - 1 } else { year };
388 let era = if y >= 0 { y } else { y - 399 } / 400;
389 let yoe = y - era * 400;
390 let mp = (month + 9) % 12;
391 let doy = (153 * mp + 2) / 5 + day - 1;
392 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
393 let days = era * 146_097 + doe - 719_468;
394
395 Some(days * 86_400 + hour * 3_600 + minute * 60 + second)
396}
397
398fn is_transport_retryable(e: &reqwest::Error) -> bool {
400 e.is_connect() || e.is_timeout()
401}
402
403fn extract_error_message(text: &str) -> Option<String> {
404 let json: serde_json::Value = serde_json::from_str(text).ok()?;
405 json.get("message")
406 .and_then(|v| v.as_str())
407 .map(ToOwned::to_owned)
408 .or_else(|| {
409 json.get("error")
410 .and_then(|v| v.as_str())
411 .map(ToOwned::to_owned)
412 })
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn builder_defaults_match_public_endpoints() {
421 let client = PolymarketUsClient::builder().build().unwrap();
422 assert_eq!(client.api_base_url(), "https://api.polymarket.us");
423 }
424
425 #[test]
426 fn builder_retry_config_applied() {
427 let client = PolymarketUsClient::builder()
428 .retry(RetryConfig::none())
429 .build()
430 .unwrap();
431 assert_eq!(client.retry_config().max_retries, 0);
432 }
433
434 #[test]
435 fn builder_default_retry_is_three() {
436 let client = PolymarketUsClient::builder().build().unwrap();
437 assert_eq!(client.retry_config().max_retries, 3);
438 }
439
440 #[test]
441 fn builder_correlation_id_prefix_applied() {
442 let client = PolymarketUsClient::builder()
443 .correlation_id_prefix("myapp")
444 .build()
445 .unwrap();
446 assert_eq!(client.correlation_id_prefix(), "myapp");
447 }
448
449 #[test]
450 fn streaming_derives_websocket_url_from_gateway() {
451 let client = PolymarketUsClient::builder()
452 .gateway_base_url("https://gateway.example.com")
453 .build()
454 .unwrap();
455 assert_eq!(
456 client.streaming().base_url(),
457 "wss://gateway.example.com/ws"
458 );
459 }
460
461 #[test]
462 fn streaming_uses_default_gateway() {
463 let client = PolymarketUsClient::builder().build().unwrap();
464 assert_eq!(
465 client.streaming().base_url(),
466 "wss://gateway.polymarket.us/ws"
467 );
468 }
469
470 #[test]
471 fn default_correlation_id_prefix() {
472 let client = PolymarketUsClient::builder().build().unwrap();
473 assert_eq!(client.correlation_id_prefix(), "pmrs");
474 }
475
476 #[test]
477 fn retry_after_parses_delay_seconds() {
478 assert_eq!(
479 parse_retry_after_value("120"),
480 Some(Duration::from_secs(120))
481 );
482 assert_eq!(
483 parse_retry_after_value(" 30 "),
484 Some(Duration::from_secs(30))
485 );
486 }
487
488 #[test]
489 fn retry_after_parses_http_date() {
490 assert_eq!(
492 parse_retry_after_value("Wed, 21 Oct 2015 07:28:00 GMT"),
493 Some(Duration::from_secs(0))
494 );
495 let future = parse_retry_after_value("Fri, 01 Jan 2100 00:00:00 GMT").unwrap();
497 assert!(future > Duration::from_secs(0));
498 }
499
500 #[test]
501 fn retry_after_rejects_garbage() {
502 assert_eq!(parse_retry_after_value("not-a-date"), None);
503 assert_eq!(parse_retry_after_value(""), None);
504 }
505
506 #[test]
507 fn http_date_epoch_is_zero() {
508 assert_eq!(
509 httpdate_to_unix_secs("Thu, 01 Jan 1970 00:00:00 GMT"),
510 Some(0)
511 );
512 assert_eq!(
514 httpdate_to_unix_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
515 Some(1_445_412_480)
516 );
517 }
518
519 #[test]
520 fn empty_body_deserializes_to_unit() {
521 serde_json::from_str::<()>("null").expect("unit from null");
523 serde_json::from_str::<Option<String>>("null").expect("option from null");
524 }
525
526 #[test]
527 fn with_reqwest_uses_default_retry() {
528 let http = reqwest::Client::new();
529 let client = PolymarketUsClient::with_reqwest(http, None);
530 assert_eq!(client.retry_config().max_retries, 3);
531 }
532}