1use 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
16pub 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 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 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_permit(&self.rate_limiter).await;
47 debug!("GET {} (attempt {})", url, attempt + 1);
48
49 let mut request = self.client.get(url);
50
51 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 let delay = if matches!(e, CgCommonError::RateLimitExceeded(_)) {
68 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 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#[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}