Skip to content

Commit 7eeee7a

Browse files
authored
Merge pull request #189 from PortalTechnologiesInc/wheatley/issue-129-fallback-only
fix(rates): fallback-only market source failover
2 parents c8f711d + 6eef42f commit 7eeee7a

2 files changed

Lines changed: 193 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1212

1313
#### Added
1414
- `MessageRouter` now exposes `SendOutcome` / `EventSendResult` from the underlying `portal` crate, giving relay delivery feedback per event. `SendOutcome::Delivered { relays }` includes the URLs of relays that accepted the event. Not yet surfaced on the REST API; wiring will follow in a future release (#85)
15+
- `portal-rates`: added fallback-only market source failover in `MarketAPI` (tries fallback providers when the primary source fails). No `fiatUnits` mapping changes in this update (#129).
1516

1617
---
1718

@@ -100,6 +101,7 @@ First release — Docker image available at [`getportal/sdk-daemon`](https://hub
100101

101102
#### Added
102103
- `MessageRouter::add_conversation`, `add_conversation_with_relays` and `add_and_subscribe` now return `Vec<EventSendResult>` alongside their existing values, pairing each broadcasted Nostr event ID with a `SendOutcome`. `Delivered { relays }` includes the list of relay URLs that accepted the event; `Queued` means no relay was available (event queued for retry); `Dropped` means the queue was full. Callers can now detect when a command is silently queued because no relay is connected (#85). Existing mobile app behavior is preserved — outcomes are currently ignored, ready to be wired into the UI when needed.
104+
- `portal-rates`: added fallback-only market source failover in `MarketAPI` (tries fallback providers when the primary source fails). No `fiatUnits` mapping changes in this update (#129).
103105

104106
#### Changed
105107
- `register_nip05()` now delegates to `portal::register_nip05()` (moved to `portal` crate). UniFFI bindings unchanged.

crates/portal-rates/src/lib.rs

Lines changed: 191 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ struct FiatUnit {
6969
// country: Option<String>,
7070
}
7171

72-
#[derive(Debug, Deserialize, Clone)]
72+
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
7373
#[cfg_attr(feature = "bindings", derive(uniffi::Enum))]
7474
enum 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,86 @@ 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) -> &'static [Source] {
271+
const KRAKEN_FALLBACKS: [Source; 2] = [Source::CoinGecko, Source::CoinDesk];
272+
const COINGECKO_FALLBACKS: [Source; 2] = [Source::Kraken, Source::CoinDesk];
273+
const YADIO_FALLBACKS: [Source; 2] = [Source::CoinGecko, Source::YadioConvert];
274+
const COINGECKO_ONLY_FALLBACKS: [Source; 1] = [Source::CoinGecko];
275+
276+
match primary {
277+
Source::Kraken => &KRAKEN_FALLBACKS,
278+
Source::CoinGecko => &COINGECKO_FALLBACKS,
279+
Source::Yadio => &YADIO_FALLBACKS,
280+
Source::Exir => &COINGECKO_ONLY_FALLBACKS,
281+
Source::YadioConvert
282+
| Source::Coinpaprika
283+
| Source::Bitstamp
284+
| Source::Coinbase
285+
| Source::BNR
286+
| Source::CoinDesk => &COINGECKO_ONLY_FALLBACKS,
287+
}
288+
}
289+
290+
async fn resolve_price_with_fallback(
291+
self: Arc<Self>,
292+
unit: &FiatUnit,
293+
) -> Result<(String, Source), RatesError> {
294+
for source in std::iter::once(unit.source)
295+
.chain(Self::fallback_sources(&unit.source).iter().copied())
296+
{
297+
match self
298+
.clone()
299+
.fetch_price_for_source(&source, &unit.end_point_key)
300+
.await
301+
{
302+
Ok(Some(price_str)) => return Ok((price_str, source)),
303+
Ok(None) => {
304+
log::debug!(
305+
"No price from source={} for key={}",
306+
source,
307+
unit.end_point_key
308+
);
309+
}
310+
Err(e) => {
311+
log::warn!(
312+
"Price fetch failed from source={} for key={}: {}",
313+
source,
314+
unit.end_point_key,
315+
e
316+
);
317+
}
318+
}
319+
}
320+
321+
Err(RatesError::MarketDataFetchFailed)
322+
}
323+
324+
#[cfg(test)]
325+
async fn resolve_price_with_fallback_with_fetcher<F, Fut>(
326+
unit: &FiatUnit,
327+
mut fetcher: F,
328+
) -> Result<(String, Source), RatesError>
329+
where
330+
F: FnMut(&Source, &str) -> Fut,
331+
Fut: std::future::Future<Output = Result<Option<String>, RatesError>>,
332+
{
333+
for source in std::iter::once(unit.source)
334+
.chain(Self::fallback_sources(&unit.source).iter().copied())
335+
{
336+
match fetcher(&source, &unit.end_point_key).await {
337+
Ok(Some(price_str)) => return Ok((price_str, source)),
338+
Ok(None) => {}
339+
Err(_) => {}
340+
}
341+
}
342+
343+
Err(RatesError::MarketDataFetchFailed)
344+
}
345+
266346
async fn fetch_market_data_internal(
267347
self: Arc<Self>,
268348
currency: &str,
@@ -274,21 +354,22 @@ impl MarketAPI {
274354
None => return Err(RatesError::UnsupportedCurrency),
275355
};
276356

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-
}
357+
let (price_str, used_source) = self
358+
.clone()
359+
.resolve_price_with_fallback(&unit)
360+
.await?;
361+
362+
if let Ok(rate) = price_str.parse::<f64>() {
363+
let data = MarketData {
364+
price: format!("{} {:.0}", unit.symbol, rate),
365+
rate: rate,
366+
source: used_source.to_string(),
367+
};
368+
log::debug!("Market data fetched in {:?}", start.elapsed());
369+
return Ok(data);
289370
}
290371

291-
Err(RatesError::MarketDataFetchFailed)
372+
Err(RatesError::PriceParseFailed(price_str))
292373
}
293374
}
294375

@@ -328,6 +409,99 @@ impl MarketAPI {
328409
}
329410
}
330411

412+
#[tokio::test]
413+
async fn test_fallback_primary_success() -> Result<(), RatesError> {
414+
let unit = FiatUnit {
415+
end_point_key: "USD".to_string(),
416+
source: Source::Kraken,
417+
symbol: "$".to_string(),
418+
};
419+
420+
let attempts = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Source>::new()));
421+
let attempts_closure = attempts.clone();
422+
423+
let (price, used_source) = MarketAPI::resolve_price_with_fallback_with_fetcher(&unit, move |source, _key| {
424+
let attempts = attempts_closure.clone();
425+
let source = source.clone();
426+
async move {
427+
attempts.lock().unwrap().push(source.clone());
428+
if source == Source::Kraken {
429+
Ok(Some("60000.0".to_string()))
430+
} else {
431+
Ok(None)
432+
}
433+
}
434+
})
435+
.await?;
436+
437+
assert_eq!(price, "60000.0");
438+
assert_eq!(used_source, Source::Kraken);
439+
assert_eq!(attempts.lock().unwrap().as_slice(), &[Source::Kraken]);
440+
Ok(())
441+
}
442+
443+
#[tokio::test]
444+
async fn test_fallback_primary_fail_then_success() -> Result<(), RatesError> {
445+
let unit = FiatUnit {
446+
end_point_key: "USD".to_string(),
447+
source: Source::Kraken,
448+
symbol: "$".to_string(),
449+
};
450+
451+
let attempts = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Source>::new()));
452+
let attempts_closure = attempts.clone();
453+
454+
let (price, used_source) = MarketAPI::resolve_price_with_fallback_with_fetcher(&unit, move |source, _key| {
455+
let attempts = attempts_closure.clone();
456+
let source = source.clone();
457+
async move {
458+
attempts.lock().unwrap().push(source.clone());
459+
match source {
460+
Source::Kraken => Err(RatesError::HttpRequest("kraken down".to_string())),
461+
Source::CoinGecko => Ok(Some("61000.0".to_string())),
462+
_ => Ok(None),
463+
}
464+
}
465+
})
466+
.await?;
467+
468+
assert_eq!(price, "61000.0");
469+
assert_eq!(used_source, Source::CoinGecko);
470+
assert_eq!(
471+
attempts.lock().unwrap().as_slice(),
472+
&[Source::Kraken, Source::CoinGecko]
473+
);
474+
Ok(())
475+
}
476+
477+
#[tokio::test]
478+
async fn test_fallback_all_fail() {
479+
let unit = FiatUnit {
480+
end_point_key: "USD".to_string(),
481+
source: Source::Kraken,
482+
symbol: "$".to_string(),
483+
};
484+
485+
let attempts = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Source>::new()));
486+
let attempts_closure = attempts.clone();
487+
488+
let result = MarketAPI::resolve_price_with_fallback_with_fetcher(&unit, move |source, _key| {
489+
let attempts = attempts_closure.clone();
490+
let source = source.clone();
491+
async move {
492+
attempts.lock().unwrap().push(source);
493+
Ok(None)
494+
}
495+
})
496+
.await;
497+
498+
assert!(matches!(result, Err(RatesError::MarketDataFetchFailed)));
499+
assert_eq!(
500+
attempts.lock().unwrap().as_slice(),
501+
&[Source::Kraken, Source::CoinGecko, Source::CoinDesk]
502+
);
503+
}
504+
331505
#[tokio::test]
332506
async fn test_market_data_fetch_eur() -> Result<(), RatesError> {
333507
let api = MarketAPI::new()?;

0 commit comments

Comments
 (0)