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::types;
8use reqwest::Method;
9use serde::de::DeserializeOwned;
10use serde::Serialize;
11use std::time::Duration;
12
13const DEFAULT_GATEWAY_BASE_URL: &str = "https://gateway.polymarket.us";
14const DEFAULT_API_BASE_URL: &str = "https://api.polymarket.us";
15const DEFAULT_CORRELATION_ID_PREFIX: &str = "pmrs";
16
17#[derive(Clone)]
18pub struct PolymarketUsClient {
19    http: reqwest::Client,
20    gateway_base_url: String,
21    api_base_url: String,
22    auth: Option<UsAuth>,
23    retry_config: RetryConfig,
24    correlation_id_prefix: String,
25}
26
27pub struct PolymarketUsClientBuilder {
28    gateway_base_url: String,
29    api_base_url: String,
30    auth: Option<UsAuth>,
31    http: Option<reqwest::Client>,
32    timeout: Duration,
33    retry_config: RetryConfig,
34    correlation_id_prefix: String,
35}
36
37impl Default for PolymarketUsClientBuilder {
38    fn default() -> Self {
39        Self {
40            gateway_base_url: DEFAULT_GATEWAY_BASE_URL.to_string(),
41            api_base_url: DEFAULT_API_BASE_URL.to_string(),
42            auth: None,
43            http: None,
44            timeout: Duration::from_secs(30),
45            retry_config: RetryConfig::default(),
46            correlation_id_prefix: DEFAULT_CORRELATION_ID_PREFIX.to_string(),
47        }
48    }
49}
50
51impl PolymarketUsClientBuilder {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn gateway_base_url(mut self, url: impl Into<String>) -> Self {
57        self.gateway_base_url = url.into();
58        self
59    }
60
61    pub fn api_base_url(mut self, url: impl Into<String>) -> Self {
62        self.api_base_url = url.into();
63        self
64    }
65
66    pub fn timeout(mut self, timeout: Duration) -> Self {
67        self.timeout = timeout;
68        self
69    }
70
71    pub fn auth(mut self, auth: UsAuth) -> Self {
72        self.auth = Some(auth);
73        self
74    }
75
76    pub fn http_client(mut self, http: reqwest::Client) -> Self {
77        self.http = Some(http);
78        self
79    }
80
81    /// Set the retry policy. Applies only to idempotent methods (GET, DELETE).
82    ///
83    /// Use [`RetryConfig::none()`] to disable retries entirely.
84    pub fn retry(mut self, config: RetryConfig) -> Self {
85        self.retry_config = config;
86        self
87    }
88
89    /// Set a prefix for the `X-Correlation-ID` header sent with every request.
90    ///
91    /// The full header value is `{prefix}-{uuid_v4}`. Defaults to `"pmrs"`.
92    /// Useful for filtering SDK requests in Polymarket support logs.
93    pub fn correlation_id_prefix(mut self, prefix: impl Into<String>) -> Self {
94        self.correlation_id_prefix = prefix.into();
95        self
96    }
97
98    pub fn build(self) -> Result<PolymarketUsClient, PolymarketUsError> {
99        let http = match self.http {
100            Some(http) => http,
101            None => reqwest::Client::builder().timeout(self.timeout).build()?,
102        };
103        Ok(PolymarketUsClient {
104            http,
105            gateway_base_url: self.gateway_base_url,
106            api_base_url: self.api_base_url,
107            auth: self.auth,
108            retry_config: self.retry_config,
109            correlation_id_prefix: self.correlation_id_prefix,
110        })
111    }
112}
113
114impl PolymarketUsClient {
115    pub fn builder() -> PolymarketUsClientBuilder {
116        PolymarketUsClientBuilder::new()
117    }
118
119    pub fn with_reqwest(http: reqwest::Client, auth: Option<UsAuth>) -> Self {
120        Self {
121            http,
122            gateway_base_url: DEFAULT_GATEWAY_BASE_URL.to_string(),
123            api_base_url: DEFAULT_API_BASE_URL.to_string(),
124            auth,
125            retry_config: RetryConfig::default(),
126            correlation_id_prefix: DEFAULT_CORRELATION_ID_PREFIX.to_string(),
127        }
128    }
129
130    pub fn auth(&self) -> Option<&UsAuth> {
131        self.auth.as_ref()
132    }
133
134    pub fn api_base_url(&self) -> &str {
135        &self.api_base_url
136    }
137
138    pub fn retry_config(&self) -> &RetryConfig {
139        &self.retry_config
140    }
141
142    // ========================================================================
143    // Resource Access
144    // ========================================================================
145
146    /// Access markets resource (discovery, order book, pricing)
147    pub fn markets(&self) -> MarketsClient<'_> {
148        MarketsClient::new(self)
149    }
150
151    /// Access events resource
152    pub fn events(&self) -> EventsClient<'_> {
153        EventsClient::new(self)
154    }
155
156    /// Access orders resource (lifecycle management)
157    pub fn orders(&self) -> OrdersClient<'_> {
158        OrdersClient::new(self)
159    }
160
161    /// Access account resource (balances, buying power)
162    pub fn account(&self) -> AccountClient<'_> {
163        AccountClient::new(self)
164    }
165
166    /// Access portfolio resource (positions, activity)
167    pub fn portfolio(&self) -> PortfolioClient<'_> {
168        PortfolioClient::new(self)
169    }
170
171    /// Access search resource (full-text search)
172    pub fn search(&self) -> SearchClient<'_> {
173        SearchClient::new(self)
174    }
175
176    pub async fn health(&self) -> Result<types::HealthResponse, PolymarketUsError> {
177        self.internal_request::<(), (), types::HealthResponse>(
178            Method::GET,
179            "/v1/health",
180            None,
181            None,
182            false,
183        )
184        .await
185    }
186
187    // ========================================================================
188    // Deprecated: Use resource clients instead (e.g., client.markets().list())
189    // ========================================================================
190
191    #[deprecated(since = "0.3.0", note = "use client.markets().list() instead")]
192    pub async fn markets_list(&self) -> Result<types::MarketsResponse, PolymarketUsError> {
193        self.markets().list().await
194    }
195
196    #[deprecated(
197        since = "0.3.0",
198        note = "use client.markets().list_with_query() instead"
199    )]
200    pub async fn markets_list_with_query<Q: Serialize>(
201        &self,
202        query: Option<&Q>,
203    ) -> Result<types::MarketsResponse, PolymarketUsError> {
204        self.markets().list_with_query(query).await
205    }
206
207    #[deprecated(
208        since = "0.3.0",
209        note = "use client.markets().list_authenticated() instead"
210    )]
211    pub async fn markets_list_authenticated(
212        &self,
213    ) -> Result<types::MarketsResponse, PolymarketUsError> {
214        self.markets().list_authenticated().await
215    }
216
217    #[deprecated(
218        since = "0.3.0",
219        note = "use client.markets().list_authenticated_with_query() instead"
220    )]
221    pub async fn markets_list_authenticated_with_query<Q: Serialize>(
222        &self,
223        query: Option<&Q>,
224    ) -> Result<types::MarketsResponse, PolymarketUsError> {
225        self.markets().list_authenticated_with_query(query).await
226    }
227
228    #[deprecated(since = "0.3.0", note = "use client.account().balances() instead")]
229    pub async fn account_balances(
230        &self,
231    ) -> Result<types::AccountBalancesResponse, PolymarketUsError> {
232        self.account().balances().await
233    }
234
235    #[deprecated(since = "0.3.0", note = "use client.portfolio().positions() instead")]
236    pub async fn portfolio_positions(
237        &self,
238    ) -> Result<types::PortfolioPositionsResponse, PolymarketUsError> {
239        self.portfolio().positions().await
240    }
241
242    #[deprecated(since = "0.3.0", note = "use client.portfolio().activities() instead")]
243    pub async fn portfolio_activities<Q: Serialize>(
244        &self,
245        query: Option<&Q>,
246    ) -> Result<types::PortfolioActivitiesResponse, PolymarketUsError> {
247        self.portfolio().activities(query).await
248    }
249
250    #[deprecated(since = "0.3.0", note = "use client.orders().place() instead")]
251    pub async fn place_order(
252        &self,
253        body: &types::PlaceOrderRequest,
254    ) -> Result<types::PlaceOrderResponse, PolymarketUsError> {
255        self.orders().place(body).await
256    }
257
258    #[deprecated(since = "0.3.0", note = "use client.orders().place_batch() instead")]
259    pub async fn place_batched_orders(
260        &self,
261        body: &types::BatchedOrderRequest,
262    ) -> Result<types::BatchedOrderResponse, PolymarketUsError> {
263        self.orders().place_batch(body).await
264    }
265
266    #[deprecated(since = "0.3.0", note = "use client.orders().cancel_trading() instead")]
267    pub async fn cancel_trading_order(
268        &self,
269        order_id: &str,
270    ) -> Result<types::CancelOrderResponse, PolymarketUsError> {
271        self.orders().cancel_trading(order_id).await
272    }
273
274    #[deprecated(since = "0.3.0", note = "use client.orders().create() instead")]
275    pub async fn orders_create(
276        &self,
277        body: &types::PlaceOrderRequest,
278    ) -> Result<types::PlaceOrderResponse, PolymarketUsError> {
279        self.orders().create(body).await
280    }
281
282    #[deprecated(since = "0.3.0", note = "use client.orders().open() instead")]
283    pub async fn orders_open<Q: Serialize>(
284        &self,
285        query: Option<&Q>,
286    ) -> Result<types::GetOpenOrdersResponse, PolymarketUsError> {
287        self.orders().open(query).await
288    }
289
290    #[deprecated(since = "0.3.0", note = "use client.orders().retrieve() instead")]
291    pub async fn order_retrieve(
292        &self,
293        order_id: &str,
294    ) -> Result<types::PlaceOrderResponse, PolymarketUsError> {
295        self.orders().retrieve(order_id).await
296    }
297
298    #[deprecated(since = "0.3.0", note = "use client.orders().cancel() instead")]
299    pub async fn order_cancel(
300        &self,
301        order_id: &str,
302        body: &types::CancelOrderParams,
303    ) -> Result<(), PolymarketUsError> {
304        self.orders().cancel(order_id, body).await
305    }
306
307    #[deprecated(since = "0.3.0", note = "use client.orders().modify() instead")]
308    pub async fn order_modify(
309        &self,
310        order_id: &str,
311        body: &types::ModifyOrderRequest,
312    ) -> Result<(), PolymarketUsError> {
313        self.orders().modify(order_id, body).await
314    }
315
316    #[deprecated(since = "0.3.0", note = "use client.orders().cancel_all() instead")]
317    pub async fn orders_cancel_all(
318        &self,
319        body: &types::CancelAllOrdersParams,
320    ) -> Result<types::CancelAllOrdersResponse, PolymarketUsError> {
321        self.orders().cancel_all(body).await
322    }
323
324    #[deprecated(since = "0.3.0", note = "use client.orders().preview() instead")]
325    pub async fn order_preview(
326        &self,
327        body: &types::PreviewOrderRequest,
328    ) -> Result<types::PreviewOrderResponse, PolymarketUsError> {
329        self.orders().preview(body).await
330    }
331
332    #[deprecated(since = "0.3.0", note = "use client.orders().close_position() instead")]
333    pub async fn order_close_position(
334        &self,
335        body: &types::ClosePositionRequest,
336    ) -> Result<types::ClosePositionResponse, PolymarketUsError> {
337        self.orders().close_position(body).await
338    }
339
340    // ========================================================================
341    // Internal Request Method
342    // ========================================================================
343
344    /// Execute an HTTP request with correlation ID injection, automatic retry
345    /// (GET/DELETE only), and `Retry-After`-aware rate-limit handling.
346    pub(crate) async fn internal_request<Q: Serialize, B: Serialize, T: DeserializeOwned>(
347        &self,
348        method: Method,
349        path: &str,
350        query: Option<&Q>,
351        body: Option<&B>,
352        authenticated: bool,
353    ) -> Result<T, PolymarketUsError> {
354        let is_idempotent = matches!(method, Method::GET | Method::DELETE);
355        let max_attempts = if is_idempotent {
356            self.retry_config.max_retries + 1
357        } else {
358            1
359        };
360
361        let base = if authenticated {
362            &self.api_base_url
363        } else {
364            &self.gateway_base_url
365        };
366        let url = format!("{}{}", base, path);
367
368        let mut attempt = 0u32;
369        loop {
370            attempt += 1;
371
372            // Fresh correlation ID per attempt so each retry is independently traceable.
373            let correlation_id = format!("{}-{}", self.correlation_id_prefix, uuid::Uuid::new_v4());
374
375            let mut rb = self
376                .http
377                .request(method.clone(), &url)
378                .header("Content-Type", "application/json")
379                .header("X-Correlation-ID", &correlation_id);
380
381            if let Some(q) = query {
382                rb = rb.query(q);
383            }
384            if let Some(b) = body {
385                rb = rb.json(b);
386            }
387            if authenticated {
388                let auth = self
389                    .auth
390                    .as_ref()
391                    .ok_or(PolymarketUsError::MissingAuth("authenticated endpoint"))?;
392                for (name, value) in auth.signed_headers(method.as_str(), path) {
393                    rb = rb.header(name, value);
394                }
395            }
396
397            // --- Send request, retry on transport errors for idempotent calls ---
398            let response = match rb.send().await {
399                Ok(r) => r,
400                Err(e) if is_idempotent && attempt < max_attempts && is_transport_retryable(&e) => {
401                    tokio::time::sleep(self.retry_config.backoff_for(attempt)).await;
402                    continue;
403                }
404                Err(e) => return Err(PolymarketUsError::Transport(e)),
405            };
406
407            let status = response.status();
408
409            // Parse Retry-After before consuming the response body.
410            let retry_after = parse_retry_after(&response);
411
412            let text = response.text().await?;
413
414            if !status.is_success() {
415                let message = extract_error_message(&text).unwrap_or_else(|| text.clone());
416
417                // Surface rate-limit errors with the server's retry_after hint.
418                let err = if status.as_u16() == 429 {
419                    PolymarketUsError::RateLimited {
420                        message,
421                        retry_after,
422                    }
423                } else {
424                    PolymarketUsError::from_status(status, message)
425                };
426
427                // Retry on retryable status codes (idempotent calls only).
428                if is_idempotent && attempt < max_attempts && is_retryable_status(status.as_u16()) {
429                    let delay =
430                        retry_after.unwrap_or_else(|| self.retry_config.backoff_for(attempt));
431                    tokio::time::sleep(delay).await;
432                    continue;
433                }
434
435                return Err(err);
436            }
437
438            return if text.trim().is_empty() {
439                serde_json::from_str("{}").map_err(PolymarketUsError::from)
440            } else {
441                serde_json::from_str(&text).map_err(PolymarketUsError::from)
442            };
443        }
444    }
445}
446
447// ---------------------------------------------------------------------------
448// Helpers
449// ---------------------------------------------------------------------------
450
451/// Parse a numeric `Retry-After: <seconds>` header value.
452fn parse_retry_after(response: &reqwest::Response) -> Option<Duration> {
453    response
454        .headers()
455        .get("retry-after")
456        .and_then(|v| v.to_str().ok())
457        .and_then(|s| s.parse::<u64>().ok())
458        .map(Duration::from_secs)
459}
460
461/// Returns `true` for transport errors worth retrying (connect/timeout).
462fn is_transport_retryable(e: &reqwest::Error) -> bool {
463    e.is_connect() || e.is_timeout()
464}
465
466fn extract_error_message(text: &str) -> Option<String> {
467    let json: serde_json::Value = serde_json::from_str(text).ok()?;
468    json.get("message")
469        .and_then(|v| v.as_str())
470        .map(ToOwned::to_owned)
471        .or_else(|| {
472            json.get("error")
473                .and_then(|v| v.as_str())
474                .map(ToOwned::to_owned)
475        })
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn builder_defaults_match_public_endpoints() {
484        let client = PolymarketUsClient::builder().build().unwrap();
485        assert_eq!(client.api_base_url(), "https://api.polymarket.us");
486    }
487
488    #[test]
489    fn builder_retry_config_applied() {
490        let client = PolymarketUsClient::builder()
491            .retry(RetryConfig::none())
492            .build()
493            .unwrap();
494        assert_eq!(client.retry_config().max_retries, 0);
495    }
496
497    #[test]
498    fn builder_default_retry_is_three() {
499        let client = PolymarketUsClient::builder().build().unwrap();
500        assert_eq!(client.retry_config().max_retries, 3);
501    }
502
503    #[test]
504    fn builder_correlation_id_prefix_applied() {
505        let client = PolymarketUsClient::builder()
506            .correlation_id_prefix("myapp")
507            .build()
508            .unwrap();
509        // We can't directly read the prefix back without a getter, but we verify
510        // the client builds without error when a custom prefix is set.
511        assert_eq!(client.api_base_url(), "https://api.polymarket.us");
512    }
513
514    #[test]
515    fn with_reqwest_uses_default_retry() {
516        let http = reqwest::Client::new();
517        let client = PolymarketUsClient::with_reqwest(http, None);
518        assert_eq!(client.retry_config().max_retries, 3);
519    }
520}