Skip to main content

polymarket_us/
client.rs

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    /// Set the retry policy. Applies only to idempotent methods (GET, DELETE).
83    ///
84    /// Use [`RetryConfig::none()`] to disable retries entirely.
85    pub fn retry(mut self, config: RetryConfig) -> Self {
86        self.retry_config = config;
87        self
88    }
89
90    /// Set a prefix for the `X-Correlation-ID` header sent with every request.
91    ///
92    /// The full header value is `{prefix}-{uuid_v4}`. Defaults to `"pmrs"`.
93    /// Useful for filtering SDK requests in Polymarket support logs.
94    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    /// The prefix prepended to the `X-Correlation-ID` header of every request.
144    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    // ========================================================================
153    // Resource Access
154    // ========================================================================
155
156    /// Access markets resource (discovery, order book, pricing)
157    pub fn markets(&self) -> MarketsClient<'_> {
158        MarketsClient::new(self)
159    }
160
161    /// Access events resource
162    pub fn events(&self) -> EventsClient<'_> {
163        EventsClient::new(self)
164    }
165
166    /// Access orders resource (lifecycle management)
167    pub fn orders(&self) -> OrdersClient<'_> {
168        OrdersClient::new(self)
169    }
170
171    /// Access account resource (balances, buying power)
172    pub fn account(&self) -> AccountClient<'_> {
173        AccountClient::new(self)
174    }
175
176    /// Access portfolio resource (positions, activity)
177    pub fn portfolio(&self) -> PortfolioClient<'_> {
178        PortfolioClient::new(self)
179    }
180
181    /// Access search resource (full-text search)
182    pub fn search(&self) -> SearchClient<'_> {
183        SearchClient::new(self)
184    }
185
186    /// Build a client for the market-data socket, inheriting this client's
187    /// credentials.
188    ///
189    /// The WebSocket endpoints live on the API host, not the gateway host used
190    /// for public REST traffic, so this does not derive its URL from
191    /// `gateway_base_url`. The returned client is independent of `self` and can
192    /// outlive it.
193    ///
194    /// ```no_run
195    /// # use polymarket_us::{PolymarketUsClient, MarketSubscription};
196    /// # async fn run() -> Result<(), polymarket_us::PolymarketUsError> {
197    /// let client = PolymarketUsClient::builder().build()?;
198    /// let mut stream = client
199    ///     .market_stream()
200    ///     .connect(vec![MarketSubscription::market_data_lite(["btc-100k-2025"])])
201    ///     .await?;
202    /// # Ok(())
203    /// # }
204    /// ```
205    pub fn market_stream(&self) -> MarketStreamClient {
206        MarketStreamClient::new(self.auth.clone())
207    }
208
209    /// Build a client for the private account socket (orders, positions,
210    /// balances).
211    ///
212    /// Fails with [`PolymarketUsError::MissingAuth`] if this client has no
213    /// credentials — the private endpoint rejects an unauthenticated upgrade,
214    /// so there is nothing useful to hand back.
215    ///
216    /// ```no_run
217    /// # use polymarket_us::{PolymarketUsClient, PrivateSubscription, UsAuth};
218    /// # async fn run() -> Result<(), polymarket_us::PolymarketUsError> {
219    /// let client = PolymarketUsClient::builder().auth(UsAuth::from_env()?).build()?;
220    /// let mut stream = client
221    ///     .private_stream()?
222    ///     .connect(vec![PrivateSubscription::orders()])
223    ///     .await?;
224    /// # Ok(())
225    /// # }
226    /// ```
227    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    // ========================================================================
247    // Internal Request Method
248    // ========================================================================
249
250    /// Execute an HTTP request with correlation ID injection, automatic retry
251    /// (GET/DELETE only), and `Retry-After`-aware rate-limit handling.
252    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            // Fresh correlation ID per attempt so each retry is independently traceable.
279            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            // --- Send request, retry on transport errors for idempotent calls ---
304            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            // Parse Retry-After before consuming the response body.
316            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                // Surface rate-limit errors with the server's retry_after hint.
324                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                // Retry on retryable status codes (idempotent calls only).
334                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            // An empty 2xx body (e.g. 204 No Content) is deserialized as JSON
345            // `null`, which satisfies both `()` and `Option<T>`. Using `{}` here
346            // would fail for any `T` that is not a struct or map.
347            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
356// ---------------------------------------------------------------------------
357// Helpers
358// ---------------------------------------------------------------------------
359
360/// Parse a `Retry-After` header in either form permitted by RFC 9110:
361/// a delay in seconds, or an absolute HTTP-date.
362fn 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    // Form 1: delay-seconds.
371    if let Ok(secs) = raw.parse::<u64>() {
372        return Some(Duration::from_secs(secs));
373    }
374
375    // Form 2: HTTP-date. Convert to a delay relative to now, clamping the past
376    // to zero so a skewed clock cannot produce a negative or huge wait.
377    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
382/// Parse the IMF-fixdate form of HTTP-date, e.g.
383/// `Wed, 21 Oct 2015 07:28:00 GMT`, into seconds since the Unix epoch.
384///
385/// This is the only form servers are required to emit by RFC 9110, and
386/// implementing it here avoids taking on a date-parsing dependency.
387fn 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    // "Wed, 21 Oct 2015 07:28:00 GMT" -> ["Wed,", "21", "Oct", "2015", "07:28:00", "GMT"]
393    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    // Days from civil epoch (Howard Hinnant's algorithm).
413    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
424/// Returns `true` for transport errors worth retrying (connect/timeout).
425fn 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        // The gateway URL is deliberately ignored: the sockets are served from
478        // the API host, and deriving from the gateway pointed at a dead path.
479        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        // A date far in the past clamps to zero rather than going negative.
519        assert_eq!(
520            parse_retry_after_value("Wed, 21 Oct 2015 07:28:00 GMT"),
521            Some(Duration::from_secs(0))
522        );
523        // A date far in the future yields a positive delay.
524        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        // Known reference value.
541        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        // Guards the 204 No Content path in internal_request.
550        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}