use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq)]
pub enum Quote {
PerBtc(f64),
PerBase { base: String, value: f64 },
}
pub type ProviderQuotes = HashMap<String, Quote>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum ProviderId {
Yadio,
CoinGecko,
CurrencyApi,
Blockchain,
ElToque,
}
impl fmt::Display for ProviderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
ProviderId::Yadio => "yadio",
ProviderId::CoinGecko => "coingecko",
ProviderId::CurrencyApi => "currency_api",
ProviderId::Blockchain => "blockchain",
ProviderId::ElToque => "eltoque",
};
f.write_str(s)
}
}
impl FromStr for ProviderId {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"yadio" => Ok(ProviderId::Yadio),
"coingecko" => Ok(ProviderId::CoinGecko),
"currency_api" => Ok(ProviderId::CurrencyApi),
"blockchain" => Ok(ProviderId::Blockchain),
"eltoque" => Ok(ProviderId::ElToque),
other => Err(format!("unknown price provider id: {other}")),
}
}
}
#[derive(Debug)]
pub enum ProviderError {
Http(String),
Parse(String),
Misconfigured(String),
}
impl fmt::Display for ProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProviderError::Http(e) => write!(f, "http error: {e}"),
ProviderError::Parse(e) => write!(f, "parse error: {e}"),
ProviderError::Misconfigured(e) => write!(f, "misconfigured: {e}"),
}
}
}
impl std::error::Error for ProviderError {}
#[async_trait::async_trait]
pub trait PriceProvider: Send + Sync {
fn id(&self) -> ProviderId;
async fn fetch(&self, http: &reqwest::Client) -> Result<ProviderQuotes, ProviderError>;
}
#[derive(Debug, Clone, Default)]
pub struct ProviderHealth {
consecutive_failures: u32,
open_until: Option<i64>,
}
impl ProviderHealth {
pub fn new() -> Self {
Self::default()
}
pub fn is_available(&self, now: i64) -> bool {
match self.open_until {
Some(until) => now >= until,
None => true,
}
}
pub fn record_success(&mut self) {
self.consecutive_failures = 0;
self.open_until = None;
}
pub fn record_failure(
&mut self,
now: i64,
failure_threshold: u32,
base_cooldown_secs: u64,
cooldown_cap_secs: u64,
) {
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
if self.consecutive_failures < failure_threshold.max(1) {
return;
}
let over = self.consecutive_failures - failure_threshold.max(1);
let factor = 1u64.checked_shl(over.min(63)).unwrap_or(u64::MAX);
let cooldown = base_cooldown_secs
.saturating_mul(factor)
.min(cooldown_cap_secs);
let cooldown = i64::try_from(cooldown).unwrap_or(i64::MAX);
self.open_until = Some(now.saturating_add(cooldown));
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockProvider {
id: ProviderId,
quotes: ProviderQuotes,
fail: bool,
}
#[async_trait::async_trait]
impl PriceProvider for MockProvider {
fn id(&self) -> ProviderId {
self.id
}
async fn fetch(&self, _http: &reqwest::Client) -> Result<ProviderQuotes, ProviderError> {
if self.fail {
Err(ProviderError::Http("mock failure".into()))
} else {
Ok(self.quotes.clone())
}
}
}
#[test]
fn provider_id_string_roundtrip() {
for id in [
ProviderId::Yadio,
ProviderId::CoinGecko,
ProviderId::CurrencyApi,
ProviderId::Blockchain,
ProviderId::ElToque,
] {
let s = id.to_string();
assert_eq!(ProviderId::from_str(&s).unwrap(), id, "roundtrip {s}");
}
assert!(ProviderId::from_str("nope").is_err());
}
#[tokio::test]
async fn mock_provider_returns_canned_quotes() {
let mut quotes = ProviderQuotes::new();
quotes.insert("USD".to_string(), Quote::PerBtc(50_000.0));
let p = MockProvider {
id: ProviderId::Yadio,
quotes: quotes.clone(),
fail: false,
};
let client = reqwest::Client::new();
assert_eq!(p.id(), ProviderId::Yadio);
assert_eq!(p.fetch(&client).await.unwrap(), quotes);
}
#[tokio::test]
async fn mock_provider_failure_is_err() {
let p = MockProvider {
id: ProviderId::CoinGecko,
quotes: ProviderQuotes::new(),
fail: true,
};
let client = reqwest::Client::new();
assert!(p.fetch(&client).await.is_err());
}
#[test]
fn health_stays_available_below_threshold() {
let mut h = ProviderHealth::new();
assert!(h.is_available(0));
h.record_failure(0, 3, 120, 1800);
h.record_failure(0, 3, 120, 1800);
assert!(h.is_available(0));
}
#[test]
fn health_opens_at_threshold_and_recovers_after_cooldown() {
let mut h = ProviderHealth::new();
for _ in 0..3 {
h.record_failure(1_000, 3, 120, 1800);
}
assert!(!h.is_available(1_000));
assert!(!h.is_available(1_119));
assert!(h.is_available(1_120));
}
#[test]
fn health_backoff_is_exponential_and_capped() {
let mut h = ProviderHealth::new();
h.record_failure(0, 1, 100, 250); assert!(!h.is_available(99));
assert!(h.is_available(100));
h.record_failure(100, 1, 100, 250); assert!(!h.is_available(299));
assert!(h.is_available(300));
h.record_failure(300, 1, 100, 250); assert!(!h.is_available(549));
assert!(h.is_available(550));
}
#[test]
fn health_success_resets_breaker() {
let mut h = ProviderHealth::new();
for _ in 0..5 {
h.record_failure(0, 3, 120, 1800);
}
assert!(!h.is_available(0));
h.record_success();
assert!(h.is_available(0));
}
}