@@ -69,7 +69,7 @@ struct FiatUnit {
6969 // country: Option<String>,
7070}
7171
72- #[ derive( Debug , Deserialize , Clone ) ]
72+ #[ derive( Debug , Deserialize , Clone , PartialEq , Eq , Hash ) ]
7373#[ cfg_attr( feature = "bindings" , derive( uniffi:: Enum ) ) ]
7474enum Source {
7575 Yadio ,
@@ -242,8 +242,12 @@ impl MarketAPI {
242242 }
243243 }
244244
245- async fn fetch_price ( self : Arc < Self > , unit : & FiatUnit ) -> Result < Option < String > , RatesError > {
246- let url = Self :: build_url ( & unit. source , & unit. end_point_key ) ;
245+ async fn fetch_price_for_source (
246+ self : Arc < Self > ,
247+ source : & Source ,
248+ key : & str ,
249+ ) -> Result < Option < String > , RatesError > {
250+ let url = Self :: build_url ( source, key) ;
247251 let res = self
248252 . client
249253 . get ( & url)
@@ -259,10 +263,61 @@ impl MarketAPI {
259263 . text ( )
260264 . await
261265 . map_err ( |e| RatesError :: HttpRequest ( e. to_string ( ) ) ) ?;
262- let parsed = Self :: parse_price_json ( & text, & unit . source , & unit . end_point_key ) ?;
266+ let parsed = Self :: parse_price_json ( & text, source, key ) ?;
263267 Ok ( Some ( parsed) )
264268 }
265269
270+ fn fallback_sources ( primary : & Source ) -> Vec < Source > {
271+ match primary {
272+ Source :: Kraken => vec ! [ Source :: CoinGecko , Source :: CoinDesk ] ,
273+ Source :: CoinGecko => vec ! [ Source :: Kraken , Source :: CoinDesk ] ,
274+ Source :: Yadio => vec ! [ Source :: CoinGecko , Source :: YadioConvert ] ,
275+ Source :: Exir => vec ! [ Source :: CoinGecko ] ,
276+ Source :: YadioConvert
277+ | Source :: Coinpaprika
278+ | Source :: Bitstamp
279+ | Source :: Coinbase
280+ | Source :: BNR
281+ | Source :: CoinDesk => vec ! [ Source :: CoinGecko ] ,
282+ }
283+ }
284+
285+ async fn resolve_price_with_fallback < F , Fut > (
286+ unit : & FiatUnit ,
287+ mut fetcher : F ,
288+ ) -> Result < ( String , Source ) , RatesError >
289+ where
290+ F : FnMut ( & Source , & str ) -> Fut ,
291+ Fut : std:: future:: Future < Output = Result < Option < String > , RatesError > > ,
292+ {
293+ let mut attempts = Vec :: with_capacity ( 1 + Self :: fallback_sources ( & unit. source ) . len ( ) ) ;
294+ attempts. push ( unit. source . clone ( ) ) ;
295+ attempts. extend ( Self :: fallback_sources ( & unit. source ) ) ;
296+
297+ for source in attempts {
298+ match fetcher ( & source, & unit. end_point_key ) . await {
299+ Ok ( Some ( price_str) ) => return Ok ( ( price_str, source) ) ,
300+ Ok ( None ) => {
301+ log:: debug!(
302+ "No price from source={} for key={}" ,
303+ source,
304+ unit. end_point_key
305+ ) ;
306+ }
307+ Err ( e) => {
308+ log:: warn!(
309+ "Price fetch failed from source={} for key={}: {}" ,
310+ source,
311+ unit. end_point_key,
312+ e
313+ ) ;
314+ }
315+ }
316+ }
317+
318+ Err ( RatesError :: MarketDataFetchFailed )
319+ }
320+
266321 async fn fetch_market_data_internal (
267322 self : Arc < Self > ,
268323 currency : & str ,
@@ -274,21 +329,25 @@ impl MarketAPI {
274329 None => return Err ( RatesError :: UnsupportedCurrency ) ,
275330 } ;
276331
277- if let Some ( price_str) = self . fetch_price ( & unit) . await ? {
278- if let Ok ( rate) = price_str. parse :: < f64 > ( ) {
279- let data = MarketData {
280- price : format ! ( "{} {:.0}" , unit. symbol, rate) ,
281- rate : rate,
282- source : unit. source . to_string ( ) ,
283- } ;
284- log:: debug!( "Market data fetched in {:?}" , start. elapsed( ) ) ;
285- return Ok ( data) ;
286- } else {
287- return Err ( RatesError :: PriceParseFailed ( price_str) ) ;
288- }
332+ let ( price_str, used_source) = Self :: resolve_price_with_fallback ( & unit, |source, key| {
333+ let api = self . clone ( ) ;
334+ let source = source. clone ( ) ;
335+ let key = key. to_string ( ) ;
336+ async move { api. fetch_price_for_source ( & source, & key) . await }
337+ } )
338+ . await ?;
339+
340+ if let Ok ( rate) = price_str. parse :: < f64 > ( ) {
341+ let data = MarketData {
342+ price : format ! ( "{} {:.0}" , unit. symbol, rate) ,
343+ rate : rate,
344+ source : used_source. to_string ( ) ,
345+ } ;
346+ log:: debug!( "Market data fetched in {:?}" , start. elapsed( ) ) ;
347+ return Ok ( data) ;
289348 }
290349
291- Err ( RatesError :: MarketDataFetchFailed )
350+ Err ( RatesError :: PriceParseFailed ( price_str ) )
292351 }
293352}
294353
@@ -328,6 +387,99 @@ impl MarketAPI {
328387 }
329388}
330389
390+ #[ tokio:: test]
391+ async fn test_fallback_primary_success ( ) -> Result < ( ) , RatesError > {
392+ let unit = FiatUnit {
393+ end_point_key : "USD" . to_string ( ) ,
394+ source : Source :: Kraken ,
395+ symbol : "$" . to_string ( ) ,
396+ } ;
397+
398+ let attempts = std:: sync:: Arc :: new ( std:: sync:: Mutex :: new ( Vec :: < Source > :: new ( ) ) ) ;
399+ let attempts_closure = attempts. clone ( ) ;
400+
401+ let ( price, used_source) = MarketAPI :: resolve_price_with_fallback ( & unit, move |source, _key| {
402+ let attempts = attempts_closure. clone ( ) ;
403+ let source = source. clone ( ) ;
404+ async move {
405+ attempts. lock ( ) . unwrap ( ) . push ( source. clone ( ) ) ;
406+ if source == Source :: Kraken {
407+ Ok ( Some ( "60000.0" . to_string ( ) ) )
408+ } else {
409+ Ok ( None )
410+ }
411+ }
412+ } )
413+ . await ?;
414+
415+ assert_eq ! ( price, "60000.0" ) ;
416+ assert_eq ! ( used_source, Source :: Kraken ) ;
417+ assert_eq ! ( attempts. lock( ) . unwrap( ) . as_slice( ) , & [ Source :: Kraken ] ) ;
418+ Ok ( ( ) )
419+ }
420+
421+ #[ tokio:: test]
422+ async fn test_fallback_primary_fail_then_success ( ) -> Result < ( ) , RatesError > {
423+ let unit = FiatUnit {
424+ end_point_key : "USD" . to_string ( ) ,
425+ source : Source :: Kraken ,
426+ symbol : "$" . to_string ( ) ,
427+ } ;
428+
429+ let attempts = std:: sync:: Arc :: new ( std:: sync:: Mutex :: new ( Vec :: < Source > :: new ( ) ) ) ;
430+ let attempts_closure = attempts. clone ( ) ;
431+
432+ let ( price, used_source) = MarketAPI :: resolve_price_with_fallback ( & unit, move |source, _key| {
433+ let attempts = attempts_closure. clone ( ) ;
434+ let source = source. clone ( ) ;
435+ async move {
436+ attempts. lock ( ) . unwrap ( ) . push ( source. clone ( ) ) ;
437+ match source {
438+ Source :: Kraken => Err ( RatesError :: HttpRequest ( "kraken down" . to_string ( ) ) ) ,
439+ Source :: CoinGecko => Ok ( Some ( "61000.0" . to_string ( ) ) ) ,
440+ _ => Ok ( None ) ,
441+ }
442+ }
443+ } )
444+ . await ?;
445+
446+ assert_eq ! ( price, "61000.0" ) ;
447+ assert_eq ! ( used_source, Source :: CoinGecko ) ;
448+ assert_eq ! (
449+ attempts. lock( ) . unwrap( ) . as_slice( ) ,
450+ & [ Source :: Kraken , Source :: CoinGecko ]
451+ ) ;
452+ Ok ( ( ) )
453+ }
454+
455+ #[ tokio:: test]
456+ async fn test_fallback_all_fail ( ) {
457+ let unit = FiatUnit {
458+ end_point_key : "USD" . to_string ( ) ,
459+ source : Source :: Kraken ,
460+ symbol : "$" . to_string ( ) ,
461+ } ;
462+
463+ let attempts = std:: sync:: Arc :: new ( std:: sync:: Mutex :: new ( Vec :: < Source > :: new ( ) ) ) ;
464+ let attempts_closure = attempts. clone ( ) ;
465+
466+ let result = MarketAPI :: resolve_price_with_fallback ( & unit, move |source, _key| {
467+ let attempts = attempts_closure. clone ( ) ;
468+ let source = source. clone ( ) ;
469+ async move {
470+ attempts. lock ( ) . unwrap ( ) . push ( source) ;
471+ Ok ( None )
472+ }
473+ } )
474+ . await ;
475+
476+ assert ! ( matches!( result, Err ( RatesError :: MarketDataFetchFailed ) ) ) ;
477+ assert_eq ! (
478+ attempts. lock( ) . unwrap( ) . as_slice( ) ,
479+ & [ Source :: Kraken , Source :: CoinGecko , Source :: CoinDesk ]
480+ ) ;
481+ }
482+
331483#[ tokio:: test]
332484async fn test_market_data_fetch_eur ( ) -> Result < ( ) , RatesError > {
333485 let api = MarketAPI :: new ( ) ?;
0 commit comments