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 pub fn retry(mut self, config: RetryConfig) -> Self {
85 self.retry_config = config;
86 self
87 }
88
89 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 pub fn markets(&self) -> MarketsClient<'_> {
148 MarketsClient::new(self)
149 }
150
151 pub fn events(&self) -> EventsClient<'_> {
153 EventsClient::new(self)
154 }
155
156 pub fn orders(&self) -> OrdersClient<'_> {
158 OrdersClient::new(self)
159 }
160
161 pub fn account(&self) -> AccountClient<'_> {
163 AccountClient::new(self)
164 }
165
166 pub fn portfolio(&self) -> PortfolioClient<'_> {
168 PortfolioClient::new(self)
169 }
170
171 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 #[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 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 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 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 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 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 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
447fn 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
461fn 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 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}