@@ -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+ }
136138struct SourceHealth {
137139 source : Source ,
138140 failures : u32 ,
139141 backoff_until : Instant ,
142+ last_error : Option < String > ,
140143}
141144
142145impl 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