use std::sync::Arc;
use std::time::Duration;
use reqwest::{Client, StatusCode};
use tracing::debug;
use super::models::CoinQuote;
use crate::error::{FinanceError, Result};
use crate::rate_limiter::RateLimiter;
const COINGECKO_BASE: &str = "https://api.coingecko.com/api/v3";
const COINGECKO_RATE_PER_SEC: f64 = 0.5;
pub(crate) struct CoinGeckoClient {
http: Client,
limiter: Arc<RateLimiter>,
}
impl CoinGeckoClient {
pub fn new() -> Result<Self> {
let http = Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(format!(
"finance-query/{} (https://github.com/Verdenroz/finance-query)",
env!("CARGO_PKG_VERSION")
))
.build()?;
Ok(Self {
http,
limiter: Arc::new(RateLimiter::new(COINGECKO_RATE_PER_SEC)),
})
}
pub async fn coins(&self, vs_currency: &str, count: usize) -> Result<Vec<CoinQuote>> {
self.limiter.acquire().await;
let per_page = count.min(250);
let url = format!(
"{COINGECKO_BASE}/coins/markets?vs_currency={vs_currency}&order=market_cap_desc&per_page={per_page}&page=1&sparkline=false"
);
debug!("CoinGecko request: coins(vs_currency={vs_currency}, count={count})");
let resp = self.http.get(&url).send().await?;
CoinGeckoClient::check_status(&resp)?;
Ok(resp.json().await?)
}
pub async fn coin(&self, id: &str, vs_currency: &str) -> Result<CoinQuote> {
self.limiter.acquire().await;
let url = format!(
"{COINGECKO_BASE}/coins/markets?vs_currency={vs_currency}&ids={id}&order=market_cap_desc&per_page=1&page=1&sparkline=false"
);
debug!("CoinGecko request: coin(id={id})");
let resp = self.http.get(&url).send().await?;
CoinGeckoClient::check_status(&resp)?;
let mut list: Vec<CoinQuote> = resp.json().await?;
list.pop().ok_or_else(|| FinanceError::SymbolNotFound {
symbol: Some(id.to_string()),
context: format!("CoinGecko returned no coin for id '{id}'"),
})
}
fn check_status(resp: &reqwest::Response) -> Result<()> {
match resp.status() {
StatusCode::OK => Ok(()),
StatusCode::TOO_MANY_REQUESTS => Err(FinanceError::RateLimited {
retry_after: Some(60),
}),
s => Err(FinanceError::ExternalApiError {
api: "CoinGecko".to_string(),
status: s.as_u16(),
}),
}
}
}