Skip to content

Commit 40fe657

Browse files
committed
currencyrate: propagate http errors to currencyrate rpc if a source is provided
specifically coindesk was constantly hitting API rate limits causing our tests to fail so lets unit tests all endpoints with a snapshot of real responses and only allow for http error 429 in integration tests Also fix a flake in test_bkpr_currencyrate_persisted that would pick up a cached rate from CLN's own caching Changelog-None
1 parent d07eb80 commit 40fe657

2 files changed

Lines changed: 267 additions & 190 deletions

File tree

plugins/currencyrate-plugin/src/oracle.rs

Lines changed: 236 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -69,58 +69,26 @@ impl Source {
6969
let currency = currency.to_uppercase();
7070
let url = self.url(&currency_lc, &currency);
7171

72-
let resp: Value = client
72+
let response = client
7373
.get(&url)
7474
.send()
7575
.await
76-
.map_err(|e| anyhow!("Failed to request url {url} caused by: {:?}", e.source()))?
76+
.map_err(|e| anyhow!("Request failed {url}: {:?}", e.source()))?;
77+
78+
let status = response.status();
79+
80+
let resp: Value = response
7781
.json()
7882
.await
79-
.map_err(|e| {
80-
anyhow!(
81-
"Failed to decode response body from {url}, caused by: {:?}",
82-
e.source()
83-
)
84-
})?;
85-
86-
let reply_members = self.reply_members(&currency_lc, &currency);
83+
.map_err(|e| anyhow!("Failed to parse JSON from {url}: {:?}", e.source()))?;
8784

88-
let mut current = &mut resp.clone();
89-
for member in reply_members {
90-
if let Ok(pos) = member.parse::<usize>() {
91-
current = current.get_mut(pos).ok_or(anyhow!(
92-
"Positional member `{}` not found in {} response: {}",
93-
member,
94-
self.name(),
95-
resp
96-
))?;
97-
} else {
98-
current = current.get_mut(&member).ok_or(anyhow!(
99-
"Member `{}` not found in {} response: {}",
100-
member,
101-
self.name(),
102-
resp
103-
))?;
104-
}
85+
if !status.is_success() {
86+
return Err(anyhow!("HTTP error {status} from {url}: body={resp}"));
10587
}
106-
let price = match current {
107-
Value::Number(number) => number
108-
.as_f64()
109-
.ok_or(anyhow!("Json number price could not be converted to float"))?,
110-
Value::String(string) => string
111-
.parse::<f64>()
112-
.map_err(|e| anyhow!("Price string could not be converted to float: {e}"))?,
113-
_ => return Err(anyhow!("Price is invalid json type")),
114-
};
11588

116-
if price == 0.0 {
117-
log::warn!("{} returned 0.0 as price for {}", self.name, currency);
118-
return Err(anyhow!(
119-
"{} returned 0.0 as price for {}",
120-
self.name,
121-
currency
122-
));
123-
}
89+
let reply_members = self.reply_members(&currency_lc, &currency);
90+
91+
let price = extract_price_from_response(&resp, &reply_members, self.name(), &currency)?;
12492

12593
log::info!(
12694
"Fetched price in {}ms from {}: {:.2} {currency}/BTC",
@@ -133,10 +101,45 @@ impl Source {
133101
}
134102
}
135103

104+
fn extract_price_from_response(
105+
resp: &Value,
106+
reply_members: &[String],
107+
name: &str,
108+
currency: &str,
109+
) -> Result<f64, anyhow::Error> {
110+
let mut current = &mut resp.clone();
111+
for member in reply_members {
112+
if let Ok(pos) = member.parse::<usize>() {
113+
current = current.get_mut(pos).ok_or(anyhow!(
114+
"Positional member `{member}` not found in {name} response: {resp}"
115+
))?;
116+
} else {
117+
current = current.get_mut(member).ok_or(anyhow!(
118+
"Member `{member}` not found in {name} response: {resp}"
119+
))?;
120+
}
121+
}
122+
let price = match current {
123+
Value::Number(number) => number
124+
.as_f64()
125+
.ok_or(anyhow!("Json number price could not be converted to float"))?,
126+
Value::String(string) => string
127+
.parse::<f64>()
128+
.map_err(|e| anyhow!("Price string could not be converted to float: {e}"))?,
129+
_ => return Err(anyhow!("Price is invalid json type")),
130+
};
131+
132+
if price == 0.0 {
133+
log::warn!("{name} returned 0.0 as price for {currency}");
134+
return Err(anyhow!("{name} returned 0.0 as price for {currency}"));
135+
}
136+
Ok(price)
137+
}
136138
struct SourceHealth {
137139
source: Source,
138140
failures: u32,
139141
backoff_until: Instant,
142+
last_error: Option<String>,
140143
}
141144

142145
impl SourceHealth {
@@ -145,18 +148,21 @@ impl SourceHealth {
145148
source,
146149
failures: 0,
147150
backoff_until: Instant::now(),
151+
last_error: None,
148152
}
149153
}
150154

151155
fn mark_success(&mut self) {
152156
self.failures = 0;
153157
self.backoff_until = Instant::now();
158+
self.last_error = None;
154159
}
155160

156-
fn mark_failure(&mut self) {
161+
fn mark_failure(&mut self, error: String) {
157162
self.failures += 1;
158163
let delay = INITIAL_BACKOFF * 2u32.pow(self.failures.min(10));
159164
self.backoff_until = Instant::now() + delay.min(MAX_BACKOFF);
165+
self.last_error = Some(error);
160166
}
161167
}
162168

@@ -396,8 +402,10 @@ impl BtcPriceOracle {
396402
}
397403

398404
Err(e) => {
399-
log::warn!("failed to get `{currency}` rate from {source_name}: {e}");
400-
source_health.mark_failure();
405+
let err_msg =
406+
format!("failed to get `{currency}` rate from {source_name}: {e}");
407+
log::warn!("{err_msg}");
408+
source_health.mark_failure(err_msg);
401409
}
402410
}
403411
}
@@ -495,8 +503,10 @@ impl BtcPriceOracle {
495503
}
496504
}
497505
Err(e) => {
498-
log::warn!("failed to get `{currency}` rate from {name}: {e}");
499-
source_health.mark_failure();
506+
let err_msg =
507+
format!("failed to get `{currency}` rate from {name}: {e}");
508+
log::warn!("{err_msg}");
509+
source_health.mark_failure(err_msg);
500510
}
501511
}
502512
}
@@ -535,12 +545,7 @@ impl BtcPriceOracle {
535545

536546
// Give a helpful error if the source name is unknown entirely
537547
if !inner.sources.contains_key(source_name) {
538-
let available = inner
539-
.sources
540-
.keys()
541-
.cloned()
542-
.collect::<Vec<_>>()
543-
.join(", ");
548+
let available = inner.sources.keys().cloned().collect::<Vec<_>>().join(", ");
544549
return Err(anyhow!(
545550
"Unknown source `{source_name}`. Available sources: {available}"
546551
));
@@ -551,16 +556,25 @@ impl BtcPriceOracle {
551556
.get(currency)
552557
.ok_or_else(|| anyhow!("No rates available for `{currency}`"))?;
553558

554-
let price_cache = currency_cache
555-
.prices
559+
let source_err = inner
560+
.sources
556561
.get(source_name)
557-
.ok_or_else(|| anyhow!(
562+
.unwrap()
563+
.last_error
564+
.clone()
565+
.unwrap_or_else(|| format!("{source_name} returned no result or error"));
566+
567+
let price_cache = currency_cache.prices.get(source_name).ok_or_else(|| {
568+
anyhow!(
558569
"Source `{source_name}` has no data for `{currency}`. \
559-
The source may not support this currency or is currently backing off."
560-
))?;
570+
The source may not support this currency: {source_err}"
571+
)
572+
})?;
561573

562574
if price_cache.timestamp + SERVE_TTL <= Instant::now() {
563-
return Err(anyhow!("Cached rate from `{source_name}` is expired"));
575+
return Err(anyhow!(
576+
"Cached rate from `{source_name}` is expired. Last source error: {source_err}"
577+
));
564578
}
565579

566580
Ok(price_cache.price)
@@ -577,3 +591,164 @@ fn get_median(source_results: Vec<SourceResult>) -> f64 {
577591
f64::midpoint(prices[mid - 1], prices[mid])
578592
}
579593
}
594+
595+
#[test]
596+
fn test_sources() {
597+
use crate::add_default_sources;
598+
use serde_json::json;
599+
let mut sources = Vec::new();
600+
add_default_sources(&mut sources, false);
601+
let mut responses = HashMap::new();
602+
603+
let coingecko_price = 72732f64;
604+
let coingecko_response = json!({"bitcoin": {"usd": coingecko_price}});
605+
responses.insert("coingecko", (coingecko_response, coingecko_price));
606+
607+
let kraken_price = 72745.00000;
608+
let kraken_reponse = json!({
609+
"error": [],
610+
"result": {
611+
"XXBTZUSD": {
612+
"a": ["72745.00000", "1", "1.000"],
613+
"b": ["72744.90000", "3", "3.000"],
614+
"c": [kraken_price, "0.00033610"],
615+
"v": ["299.69911717", "640.87872182"],
616+
"p": ["73231.75442", "73435.07382"],
617+
"t": [17317, 39634],
618+
"l": ["72613.70000", "72613.70000"],
619+
"h": ["73960.00000", "74070.00000"],
620+
"o": "73569.90000",
621+
}
622+
},
623+
});
624+
responses.insert("kraken", (kraken_reponse, kraken_price));
625+
626+
let blockchain_info_price = 72749.07;
627+
let blockchain_info_reponse = json!({
628+
"ARS": {
629+
"15m": 1.0250388182e8,
630+
"last": 1.0250388182e8,
631+
"buy": 1.0250388182e8,
632+
"sell": 1.0250388182e8,
633+
"symbol": "ARS",
634+
},
635+
"AUD": {
636+
"15m": 101319.81,
637+
"last": 101319.81,
638+
"buy": 101319.81,
639+
"sell": 101319.81,
640+
"symbol": "AUD",
641+
},
642+
"BRL": {
643+
"15m": 366724.43,
644+
"last": 366724.43,
645+
"buy": 366724.43,
646+
"sell": 366724.43,
647+
"symbol": "BRL",
648+
},
649+
"CAD": {
650+
"15m": 100512.08,
651+
"last": 100512.08,
652+
"buy": 100512.08,
653+
"sell": 100512.08,
654+
"symbol": "CAD",
655+
},
656+
"USD": {
657+
"15m": 72749.07,
658+
"last": blockchain_info_price,
659+
"buy": 72749.07,
660+
"sell": 72749.07,
661+
"symbol": "USD",
662+
},
663+
});
664+
responses.insert(
665+
"blockchain.info",
666+
(blockchain_info_reponse, blockchain_info_price),
667+
);
668+
669+
let bitstamp_price = 72748.45;
670+
let bitstamp_reponse = json!({
671+
"timestamp": "1780301548",
672+
"open": "73568.00",
673+
"high": "74094.65",
674+
"low": "72611.68",
675+
"last": bitstamp_price,
676+
"volume": "619.76380694",
677+
"vwap": "73542.01",
678+
"bid": "72748.45",
679+
"ask": "72748.46",
680+
"side": "1",
681+
"open_24": "73769.45",
682+
"percent_change_24": "-1.38",
683+
"market_type": "SPOT",
684+
});
685+
responses.insert("bitstamp", (bitstamp_reponse, bitstamp_price));
686+
687+
let coindesk_price = 72751.6828660636;
688+
let coindes_response = json!({
689+
"Data": {
690+
"BTC-USD": {
691+
"TYPE": "266",
692+
"MARKET": "cadli",
693+
"INSTRUMENT": "BTC-USD",
694+
"CCSEQ": 1323841393,
695+
"VALUE": coindesk_price,
696+
"VALUE_FLAG": "UP",
697+
"VALUE_LAST_UPDATE_TS": 1780301570,
698+
"VALUE_LAST_UPDATE_TS_NS": 94000000,
699+
"LAST_UPDATE_QUANTITY": 0.105,
700+
"LAST_UPDATE_QUOTE_QUANTITY": 7642.36250656015,
701+
"LAST_UPDATE_VOLUME_TOP_TIER": 0,
702+
"LAST_UPDATE_QUOTE_VOLUME_TOP_TIER": 0,
703+
"LAST_UPDATE_VOLUME_DIRECT": 0,
704+
"LAST_UPDATE_CCSEQ": 1323894738,
705+
"CURRENT_HOUR_VOLUME": 1574.29740546284,
706+
"CURRENT_HOUR_QUOTE_VOLUME": 114448749.088967,
707+
"CURRENT_HOUR_VOLUME_TOP_TIER": 815.533688797,
708+
"CURRENT_HOUR_QUOTE_VOLUME_TOP_TIER": 59271744.4993065,
709+
"CURRENT_YEAR_CHANGE_PERCENTAGE": -16.8891195575271,
710+
"MOVING_24_HOUR_VOLUME": 127405.003580434,
711+
"MOVING_24_HOUR_QUOTE_VOLUME": 9365693546.36435,
712+
"MOVING_24_HOUR_VOLUME_TOP_TIER": 60402.3558377577,
713+
"MOVING_24_HOUR_QUOTE_VOLUME_TOP_TIER_DIRECT": 867716024.677109,
714+
"MOVING_24_HOUR_OPEN": 73850.0780201078,
715+
"MOVING_7_DAY_HIGH": 77996.1623459993,
716+
"MOVING_7_DAY_LOW": 72425.5243349043,
717+
"MOVING_7_DAY_TOTAL_INDEX_UPDATES": 12822192,
718+
"MOVING_7_DAY_CHANGE": -4535.8043571581,
719+
"MOVING_365_DAY_CHANGE_PERCENTAGE": -31.154821757090602,
720+
"LIFETIME_FIRST_UPDATE_TS": 1279408140,
721+
"LIFETIME_QUOTE_VOLUME_TOP_TIER_DIRECT": 4851586542024.82,
722+
"LIFETIME_OPEN": 0.04951,
723+
"LIFETIME_LOW": 0.01,
724+
"LIFETIME_LOW_TS": 1286572500,
725+
"LIFETIME_TOTAL_INDEX_UPDATES": 1329464334,
726+
"LIFETIME_CHANGE": 72751.6333560636,
727+
"LIFETIME_CHANGE_PERCENTAGE": 146943311.16151,
728+
}
729+
},
730+
"Err": {},
731+
});
732+
responses.insert("coindesk", (coindes_response, coindesk_price));
733+
734+
let coinbase_price = 72760.125;
735+
let coinbase_reponse =
736+
json!({"data": {"amount": coinbase_price, "base": "BTC", "currency": "USD"}});
737+
responses.insert("coinbase", (coinbase_reponse, coinbase_price));
738+
739+
let binance_price = 72715.35000000;
740+
let binance_reponse = json!({"symbol": "BTCUSD", "price": binance_price});
741+
responses.insert("binance", (binance_reponse, binance_price));
742+
743+
for source in &sources {
744+
let (response, price) = responses.get(source.name()).unwrap();
745+
let extracted_price = extract_price_from_response(
746+
response,
747+
&source.reply_members("usd", "USD"),
748+
&source.name,
749+
"USD",
750+
)
751+
.unwrap();
752+
assert_eq!(extracted_price, *price, "Failed for {}", source.name());
753+
}
754+
}

0 commit comments

Comments
 (0)