Skip to main content

cg_common/
client.rs

1//! HTTP client with rate limiting for CoinGecko API
2
3use crate::error::{CgCommonError, Result};
4use crate::rate_limit::{wait_for_permit, CgRateLimiter, RateLimitConfig};
5use crate::retry::{calculate_backoff, is_retryable, BackoffStrategy, RetryConfig};
6use reqwest::{Client, Response, StatusCode};
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::time::sleep;
10use tracing::{debug, warn};
11
12pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
13pub const DEFAULT_USER_AGENT: &str = "cg-common/0.1.0";
14pub const COINGECKO_API_BASE: &str = "https://api.coingecko.com/api/v3";
15
16/// HTTP client for CoinGecko API
17pub struct CoinGeckoClient {
18    client: Client,
19    base_url: String,
20    api_key: Option<String>,
21    rate_limiter: Arc<CgRateLimiter>,
22    retry_config: RetryConfig,
23}
24
25impl CoinGeckoClient {
26    pub fn builder() -> CoinGeckoClientBuilder {
27        CoinGeckoClientBuilder::new()
28    }
29
30    /// Make a GET request to the CoinGecko API
31    pub async fn get(&self, endpoint: &str) -> Result<String> {
32        let url = if endpoint.starts_with("http") {
33            endpoint.to_string()
34        } else {
35            format!("{}{}", self.base_url, endpoint)
36        };
37        self.get_url(&url).await
38    }
39
40    /// Make a GET request to a full URL with automatic retry on rate limits
41    pub async fn get_url(&self, url: &str) -> Result<String> {
42        let mut last_error = None;
43
44        for attempt in 0..=self.retry_config.max_retries {
45            // Wait for rate limit permit
46            wait_for_permit(&self.rate_limiter).await;
47            debug!("GET {} (attempt {})", url, attempt + 1);
48
49            let mut request = self.client.get(url);
50
51            // Add API key header if provided (for pro tier)
52            if let Some(ref key) = self.api_key {
53                request = request.header("x-cg-pro-api-key", key);
54            }
55
56            match request.send().await {
57                Ok(response) => {
58                    match self.handle_response(response).await {
59                        Ok(body) => return Ok(body),
60                        Err(e) => {
61                            if attempt == self.retry_config.max_retries
62                                || !is_retryable(&e, &self.retry_config)
63                            {
64                                return Err(e);
65                            }
66                            // Use longer delay for rate limits
67                            let delay = if matches!(e, CgCommonError::RateLimitExceeded(_)) {
68                                // For 429, wait longer: 30s, 60s, 120s
69                                Duration::from_secs(30 * 2u64.pow(attempt))
70                            } else {
71                                calculate_backoff(
72                                    attempt,
73                                    &self.retry_config,
74                                    BackoffStrategy::Exponential,
75                                )
76                            };
77                            warn!(
78                                "Request failed (attempt {}): {}. Retrying in {:?}",
79                                attempt + 1,
80                                e,
81                                delay
82                            );
83                            sleep(delay).await;
84                            last_error = Some(e);
85                        }
86                    }
87                }
88                Err(e) => {
89                    let err = CgCommonError::RequestError(e);
90                    if attempt == self.retry_config.max_retries
91                        || !is_retryable(&err, &self.retry_config)
92                    {
93                        return Err(err);
94                    }
95                    let delay = calculate_backoff(
96                        attempt,
97                        &self.retry_config,
98                        BackoffStrategy::Exponential,
99                    );
100                    warn!(
101                        "Request error (attempt {}): {}. Retrying in {:?}",
102                        attempt + 1,
103                        err,
104                        delay
105                    );
106                    sleep(delay).await;
107                    last_error = Some(err);
108                }
109            }
110        }
111
112        Err(last_error.unwrap_or(CgCommonError::MaxRetriesExceeded(
113            self.retry_config.max_retries,
114        )))
115    }
116
117    async fn handle_response(&self, response: Response) -> Result<String> {
118        let status = response.status();
119        let url = response.url().to_string();
120
121        match status {
122            s if s.is_success() => Ok(response.text().await?),
123            StatusCode::TOO_MANY_REQUESTS => Err(CgCommonError::RateLimitExceeded(url)),
124            s if s.is_server_error() => {
125                let body = response.text().await.unwrap_or_default();
126                Err(CgCommonError::ServerError(s.as_u16(), body))
127            }
128            s if s.is_client_error() => {
129                let body = response.text().await.unwrap_or_default();
130                Err(CgCommonError::ClientError(s.as_u16(), body))
131            }
132            _ => {
133                let body = response.text().await.unwrap_or_default();
134                Err(CgCommonError::DataError(format!(
135                    "Unexpected status {}: {}",
136                    status, body
137                )))
138            }
139        }
140    }
141
142    /// Test API connectivity
143    pub async fn ping(&self) -> Result<bool> {
144        let response = self.get("/ping").await?;
145        Ok(response.contains("gecko_says"))
146    }
147
148    pub fn inner(&self) -> &Client {
149        &self.client
150    }
151
152    pub fn retry_config(&self) -> &RetryConfig {
153        &self.retry_config
154    }
155
156    pub fn base_url(&self) -> &str {
157        &self.base_url
158    }
159}
160
161/// Builder for CoinGeckoClient
162#[derive(Default)]
163pub struct CoinGeckoClientBuilder {
164    timeout: Option<Duration>,
165    user_agent: Option<String>,
166    api_key: Option<String>,
167    base_url: Option<String>,
168    rate_limit_config: Option<RateLimitConfig>,
169    retry_config: Option<RetryConfig>,
170}
171
172impl CoinGeckoClientBuilder {
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    pub fn with_timeout(mut self, timeout: Duration) -> Self {
178        self.timeout = Some(timeout);
179        self
180    }
181
182    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
183        self.user_agent = Some(user_agent.into());
184        self
185    }
186
187    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
188        self.api_key = Some(key.into());
189        self
190    }
191
192    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
193        self.base_url = Some(url.into());
194        self
195    }
196
197    pub fn with_rate_limit(mut self, requests_per_minute: u32) -> Self {
198        self.rate_limit_config = Some(RateLimitConfig::new(requests_per_minute));
199        self
200    }
201
202    pub fn with_retry(mut self, config: RetryConfig) -> Self {
203        self.retry_config = Some(config);
204        self
205    }
206
207    pub fn build(self) -> Result<CoinGeckoClient> {
208        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
209        let user_agent = self
210            .user_agent
211            .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());
212        let base_url = self
213            .base_url
214            .unwrap_or_else(|| COINGECKO_API_BASE.to_string());
215        let rate_limit_config = self.rate_limit_config.unwrap_or_default();
216        let retry_config = self.retry_config.unwrap_or_default();
217
218        let client = Client::builder()
219            .timeout(timeout)
220            .user_agent(&user_agent)
221            .build()
222            .map_err(CgCommonError::RequestError)?;
223
224        Ok(CoinGeckoClient {
225            client,
226            base_url,
227            api_key: self.api_key,
228            rate_limiter: rate_limit_config.build_limiter(),
229            retry_config,
230        })
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_builder_default() {
240        let client = CoinGeckoClientBuilder::new().build().unwrap();
241        assert_eq!(client.retry_config().max_retries, 3);
242        assert_eq!(client.base_url(), COINGECKO_API_BASE);
243    }
244
245    #[test]
246    fn test_builder_chaining() {
247        let result = CoinGeckoClientBuilder::new()
248            .with_timeout(Duration::from_secs(60))
249            .with_user_agent("test-agent")
250            .with_rate_limit(10)
251            .with_retry(RetryConfig::default())
252            .build();
253        assert!(result.is_ok());
254    }
255
256    #[test]
257    fn test_builder_with_api_key() {
258        let client = CoinGeckoClientBuilder::new()
259            .with_api_key("test-key")
260            .build()
261            .unwrap();
262        assert_eq!(client.retry_config().max_retries, 3);
263    }
264
265    #[test]
266    fn test_builder_custom_base_url() {
267        let client = CoinGeckoClientBuilder::new()
268            .with_base_url("https://custom.api.com")
269            .build()
270            .unwrap();
271        assert_eq!(client.base_url(), "https://custom.api.com");
272    }
273}