Skip to main content

polyoxide_gamma/
client.rs

1use polyoxide_core::{
2    HttpClient, HttpClientBuilder, RateLimiter, RetryConfig, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
3};
4
5use crate::{
6    api::{
7        comments::Comments, events::Events, health::Health, markets::Markets, search::Search,
8        series::Series, sports::Sports, tags::Tags, user::User,
9    },
10    error::GammaError,
11};
12
13const DEFAULT_BASE_URL: &str = "https://gamma-api.polymarket.com";
14
15/// Main Gamma API client
16#[derive(Clone)]
17pub struct Gamma {
18    pub(crate) http_client: HttpClient,
19}
20
21impl Gamma {
22    /// Create a new Gamma client with default configuration
23    pub fn new() -> Result<Self, GammaError> {
24        Self::builder().build()
25    }
26
27    /// Create a builder for configuring the client
28    pub fn builder() -> GammaBuilder {
29        GammaBuilder::new()
30    }
31
32    /// Get markets namespace
33    pub fn markets(&self) -> Markets {
34        Markets {
35            http_client: self.http_client.clone(),
36        }
37    }
38
39    /// Get events namespace
40    pub fn events(&self) -> Events {
41        Events {
42            http_client: self.http_client.clone(),
43        }
44    }
45
46    /// Get series namespace
47    pub fn series(&self) -> Series {
48        Series {
49            http_client: self.http_client.clone(),
50        }
51    }
52
53    /// Get tags namespace
54    pub fn tags(&self) -> Tags {
55        Tags {
56            http_client: self.http_client.clone(),
57        }
58    }
59
60    /// Get sports namespace
61    pub fn sports(&self) -> Sports {
62        Sports {
63            http_client: self.http_client.clone(),
64        }
65    }
66
67    /// Get comments namespace
68    pub fn comments(&self) -> Comments {
69        Comments {
70            http_client: self.http_client.clone(),
71        }
72    }
73
74    /// Get search namespace
75    pub fn search(&self) -> Search {
76        Search {
77            http_client: self.http_client.clone(),
78        }
79    }
80
81    /// Get user namespace
82    pub fn user(&self) -> User {
83        User {
84            http_client: self.http_client.clone(),
85        }
86    }
87
88    /// Get health namespace
89    pub fn health(&self) -> Health {
90        Health {
91            http_client: self.http_client.clone(),
92        }
93    }
94}
95
96/// Builder for configuring Gamma client
97pub struct GammaBuilder {
98    base_url: String,
99    timeout_ms: u64,
100    pool_size: usize,
101    retry_config: Option<RetryConfig>,
102    max_concurrent: Option<usize>,
103}
104
105impl GammaBuilder {
106    fn new() -> Self {
107        Self {
108            base_url: DEFAULT_BASE_URL.to_string(),
109            timeout_ms: DEFAULT_TIMEOUT_MS,
110            pool_size: DEFAULT_POOL_SIZE,
111            retry_config: None,
112            max_concurrent: None,
113        }
114    }
115
116    /// Set base URL for the API
117    pub fn base_url(mut self, url: impl Into<String>) -> Self {
118        self.base_url = url.into();
119        self
120    }
121
122    /// Set request timeout in milliseconds
123    pub fn timeout_ms(mut self, timeout: u64) -> Self {
124        self.timeout_ms = timeout;
125        self
126    }
127
128    /// Set connection pool size
129    pub fn pool_size(mut self, size: usize) -> Self {
130        self.pool_size = size;
131        self
132    }
133
134    /// Set retry configuration for 429 responses
135    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
136        self.retry_config = Some(config);
137        self
138    }
139
140    /// Set the maximum number of concurrent in-flight requests.
141    ///
142    /// Default: 4. Prevents Cloudflare 1015 errors from request bursts.
143    pub fn max_concurrent(mut self, max: usize) -> Self {
144        self.max_concurrent = Some(max);
145        self
146    }
147
148    /// Build the Gamma client
149    pub fn build(self) -> Result<Gamma, GammaError> {
150        let mut builder = HttpClientBuilder::new(&self.base_url)
151            .timeout_ms(self.timeout_ms)
152            .pool_size(self.pool_size)
153            .with_rate_limiter(RateLimiter::gamma_default())
154            .with_max_concurrent(self.max_concurrent.unwrap_or(4));
155        if let Some(config) = self.retry_config {
156            builder = builder.with_retry_config(config);
157        }
158        let http_client = builder.build()?;
159
160        Ok(Gamma { http_client })
161    }
162}
163
164impl Default for GammaBuilder {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_builder_default() {
176        let builder = GammaBuilder::default();
177        assert_eq!(builder.base_url, DEFAULT_BASE_URL);
178        assert_eq!(builder.timeout_ms, DEFAULT_TIMEOUT_MS);
179        assert_eq!(builder.pool_size, DEFAULT_POOL_SIZE);
180    }
181
182    #[test]
183    fn test_builder_custom_url() {
184        let builder = GammaBuilder::new().base_url("https://custom.api.com");
185        assert_eq!(builder.base_url, "https://custom.api.com");
186    }
187
188    #[test]
189    fn test_builder_custom_timeout() {
190        let builder = GammaBuilder::new().timeout_ms(60_000);
191        assert_eq!(builder.timeout_ms, 60_000);
192    }
193
194    #[test]
195    fn test_builder_custom_pool_size() {
196        let builder = GammaBuilder::new().pool_size(20);
197        assert_eq!(builder.pool_size, 20);
198    }
199
200    #[test]
201    fn test_builder_custom_retry_config() {
202        let config = RetryConfig {
203            max_retries: 5,
204            initial_backoff_ms: 1000,
205            max_backoff_ms: 30_000,
206        };
207        let builder = GammaBuilder::new().with_retry_config(config);
208        let config = builder.retry_config.unwrap();
209        assert_eq!(config.max_retries, 5);
210        assert_eq!(config.initial_backoff_ms, 1000);
211    }
212
213    #[test]
214    fn test_builder_build_success() {
215        let gamma = Gamma::builder().build();
216        assert!(gamma.is_ok());
217    }
218
219    #[test]
220    fn test_builder_invalid_url() {
221        let result = Gamma::builder().base_url("://bad").build();
222        assert!(result.is_err());
223    }
224
225    #[test]
226    fn test_builder_custom_max_concurrent() {
227        let builder = GammaBuilder::new().max_concurrent(10);
228        assert_eq!(builder.max_concurrent, Some(10));
229    }
230
231    #[tokio::test]
232    async fn test_default_concurrency_limit_is_4() {
233        let gamma = Gamma::new().unwrap();
234        // Default concurrency limit is 4
235        let mut permits = Vec::new();
236        for _ in 0..4 {
237            permits.push(gamma.http_client.acquire_concurrency().await);
238        }
239        assert!(permits.iter().all(|p| p.is_some()));
240
241        // 5th should block
242        let result = tokio::time::timeout(
243            std::time::Duration::from_millis(50),
244            gamma.http_client.acquire_concurrency(),
245        )
246        .await;
247        assert!(
248            result.is_err(),
249            "5th permit should block with default limit of 4"
250        );
251    }
252
253    #[test]
254    fn test_new_creates_client() {
255        let gamma = Gamma::new();
256        assert!(gamma.is_ok());
257    }
258
259    #[test]
260    fn test_client_namespaces_accessible() {
261        let gamma = Gamma::new().unwrap();
262        let _markets = gamma.markets();
263        let _events = gamma.events();
264        let _series = gamma.series();
265        let _tags = gamma.tags();
266        let _sports = gamma.sports();
267        let _comments = gamma.comments();
268        let _search = gamma.search();
269        let _user = gamma.user();
270        let _health = gamma.health();
271    }
272}