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::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    /// 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 WebSocket stream client that inherits this client's gateway URL
187    /// and credentials.
188    ///
189    /// The returned client is independent of `self` and can outlive it.
190    ///
191    /// ```no_run
192    /// # use polymarket_us::{PolymarketUsClient, StreamSubscription};
193    /// # async fn run() -> Result<(), polymarket_us::PolymarketUsError> {
194    /// let client = PolymarketUsClient::builder().build()?;
195    /// let mut stream = client
196    ///     .streaming()
197    ///     .connect(vec![StreamSubscription::market_data_lite("BTC-USD")])
198    ///     .await?;
199    /// # Ok(())
200    /// # }
201    /// ```
202    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    // ========================================================================
221    // Internal Request Method
222    // ========================================================================
223
224    /// Execute an HTTP request with correlation ID injection, automatic retry
225    /// (GET/DELETE only), and `Retry-After`-aware rate-limit handling.
226    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            // Fresh correlation ID per attempt so each retry is independently traceable.
253            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            // --- Send request, retry on transport errors for idempotent calls ---
278            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            // Parse Retry-After before consuming the response body.
290            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                // Surface rate-limit errors with the server's retry_after hint.
298                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                // Retry on retryable status codes (idempotent calls only).
308                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            // An empty 2xx body (e.g. 204 No Content) is deserialized as JSON
319            // `null`, which satisfies both `()` and `Option<T>`. Using `{}` here
320            // would fail for any `T` that is not a struct or map.
321            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
330// ---------------------------------------------------------------------------
331// Helpers
332// ---------------------------------------------------------------------------
333
334/// Parse a `Retry-After` header in either form permitted by RFC 9110:
335/// a delay in seconds, or an absolute HTTP-date.
336fn 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    // Form 1: delay-seconds.
345    if let Ok(secs) = raw.parse::<u64>() {
346        return Some(Duration::from_secs(secs));
347    }
348
349    // Form 2: HTTP-date. Convert to a delay relative to now, clamping the past
350    // to zero so a skewed clock cannot produce a negative or huge wait.
351    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
356/// Parse the IMF-fixdate form of HTTP-date, e.g.
357/// `Wed, 21 Oct 2015 07:28:00 GMT`, into seconds since the Unix epoch.
358///
359/// This is the only form servers are required to emit by RFC 9110, and
360/// implementing it here avoids taking on a date-parsing dependency.
361fn 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    // "Wed, 21 Oct 2015 07:28:00 GMT" -> ["Wed,", "21", "Oct", "2015", "07:28:00", "GMT"]
367    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    // Days from civil epoch (Howard Hinnant's algorithm).
387    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
398/// Returns `true` for transport errors worth retrying (connect/timeout).
399fn 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        // A date far in the past clamps to zero rather than going negative.
491        assert_eq!(
492            parse_retry_after_value("Wed, 21 Oct 2015 07:28:00 GMT"),
493            Some(Duration::from_secs(0))
494        );
495        // A date far in the future yields a positive delay.
496        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        // Known reference value.
513        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        // Guards the 204 No Content path in internal_request.
522        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}