Skip to content

Commit d7d9c44

Browse files
Harden PriceProvider with retries, short-TTL cache, and URL-encoded tickers
Three improvements wrapped into one commit because they share the same code paths: 1. Retries: Yahoo Finance occasionally 5xxs under load. Both get_current_price and get_historical_prices now wrap the request in retry_with_backoff (3 attempts, jittered exponential). 2. Short-TTL cache: A dashboard render frequently calls get_current_price for the same ticker several times within a few hundred milliseconds (portfolio + alerts + UI). Add a 30s in-memory cache keyed by uppercase ticker to absorb those bursts without touching Yahoo. 3. URL encoding: Tickers with '.' or '-' (BRK.B, BF-B) work today only because Yahoo happens to accept raw chars; switch to a small RFC3986 unreserved-set encoder so odd symbols can't break the URL or inject path segments. Also truncates f64 volumes via .trunc() before the i64 cast so we no longer implicit-convert a potential NaN into a garbage integer. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent f25b95b commit d7d9c44

1 file changed

Lines changed: 120 additions & 46 deletions

File tree

src/providers/price.rs

Lines changed: 120 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
1+
use crate::utils::retry_with_backoff;
12
use anyhow::{anyhow, Context, Result};
23
use chrono::{DateTime, Utc};
34
use serde::Deserialize;
5+
use std::{
6+
collections::HashMap,
7+
sync::{Arc, Mutex},
8+
time::{Duration, Instant},
9+
};
10+
11+
/// How long a current-price result stays fresh in the in-memory cache. Short
12+
/// enough to feel live on a dashboard yet long enough to absorb bursts
13+
/// (dashboard refresh + alerts evaluate + portfolio render all hit the same
14+
/// ticker within milliseconds). Intraday movement inside this window is
15+
/// acceptable for the callers that use the PriceProvider.
16+
const PRICE_CACHE_TTL: Duration = Duration::from_secs(30);
17+
/// Max attempts for Yahoo Finance retries. Yahoo's public chart API returns
18+
/// the occasional 5xx under load; three tries with jittered exponential
19+
/// backoff recovers the vast majority of those without user-visible errors.
20+
const PRICE_FETCH_ATTEMPTS: u32 = 3;
421

522
#[derive(Clone)]
623
pub struct PriceProvider {
724
client: reqwest::Client,
25+
current_cache: Arc<Mutex<HashMap<String, (Instant, PriceData)>>>,
826
}
927

1028
#[derive(Debug, Clone)]
@@ -27,35 +45,50 @@ impl PriceProvider {
2745
.build()
2846
.context("failed to build reqwest client")?;
2947

30-
Ok(Self { client })
48+
Ok(Self {
49+
client,
50+
current_cache: Arc::new(Mutex::new(HashMap::new())),
51+
})
3152
}
3253

33-
/// Fetch current price for a ticker using Yahoo Finance API
54+
/// Fetch current price for a ticker using Yahoo Finance API. Served from
55+
/// a 30-second in-memory cache to absorb duplicate calls from the same
56+
/// dashboard render and retried with exponential backoff on transient
57+
/// upstream errors.
3458
pub async fn get_current_price(&self, ticker: &str) -> Result<PriceData> {
35-
// Use Yahoo Finance query API
59+
let key = ticker.to_uppercase();
60+
if let Some(cached) = self.cached_current(&key) {
61+
return Ok(cached);
62+
}
63+
// URL-encode the ticker so symbols like `BRK.B` / `BF-B` behave and
64+
// callers can't inject path segments.
3665
let url = format!(
3766
"https://query1.finance.yahoo.com/v8/finance/chart/{}?interval=1d&range=1d",
38-
ticker
67+
urlencoding_encode(ticker)
3968
);
4069

41-
let response = self
42-
.client
43-
.get(&url)
44-
.send()
45-
.await
46-
.context("failed to fetch price from Yahoo Finance")?;
47-
48-
if !response.status().is_success() {
49-
return Err(anyhow!(
50-
"Yahoo Finance returned status {}",
51-
response.status()
52-
));
53-
}
54-
55-
let body: YahooChartResponse = response
56-
.json()
57-
.await
58-
.context("failed to parse Yahoo Finance response")?;
70+
let body: YahooChartResponse = retry_with_backoff(
71+
|| async {
72+
let response = self
73+
.client
74+
.get(&url)
75+
.send()
76+
.await
77+
.context("failed to fetch price from Yahoo Finance")?;
78+
if !response.status().is_success() {
79+
return Err(anyhow!(
80+
"Yahoo Finance returned status {}",
81+
response.status()
82+
));
83+
}
84+
response
85+
.json::<YahooChartResponse>()
86+
.await
87+
.context("failed to parse Yahoo Finance response")
88+
},
89+
PRICE_FETCH_ATTEMPTS,
90+
)
91+
.await?;
5992

6093
let chart = body
6194
.chart
@@ -107,16 +140,34 @@ impl PriceProvider {
107140
.map(|dt| dt.date_naive())
108141
.unwrap_or_else(|| Utc::now().date_naive());
109142

110-
Ok(PriceData {
111-
ticker: ticker.to_uppercase(),
143+
let data = PriceData {
144+
ticker: key.clone(),
112145
date,
113146
open,
114147
close,
115148
high,
116149
low,
117-
volume: volume as i64,
150+
volume: volume.trunc() as i64,
118151
adjusted_close: meta.regular_market_price.unwrap_or(close),
119-
})
152+
};
153+
self.store_current(key, data.clone());
154+
Ok(data)
155+
}
156+
157+
fn cached_current(&self, key: &str) -> Option<PriceData> {
158+
let guard = self.current_cache.lock().ok()?;
159+
let (ts, data) = guard.get(key)?;
160+
if ts.elapsed() <= PRICE_CACHE_TTL {
161+
Some(data.clone())
162+
} else {
163+
None
164+
}
165+
}
166+
167+
fn store_current(&self, key: String, data: PriceData) {
168+
if let Ok(mut guard) = self.current_cache.lock() {
169+
guard.insert(key, (Instant::now(), data));
170+
}
120171
}
121172

122173
/// Fetch historical prices for a ticker
@@ -143,27 +194,33 @@ impl PriceProvider {
143194

144195
let url = format!(
145196
"https://query1.finance.yahoo.com/v8/finance/chart/{}?period1={}&period2={}&interval=1d",
146-
ticker, period1, period2
197+
urlencoding_encode(ticker),
198+
period1,
199+
period2
147200
);
148201

149-
let response = self
150-
.client
151-
.get(&url)
152-
.send()
153-
.await
154-
.context("failed to fetch historical prices from Yahoo Finance")?;
155-
156-
if !response.status().is_success() {
157-
return Err(anyhow!(
158-
"Yahoo Finance returned status {}",
159-
response.status()
160-
));
161-
}
162-
163-
let body: YahooChartResponse = response
164-
.json()
165-
.await
166-
.context("failed to parse Yahoo Finance response")?;
202+
let body: YahooChartResponse = retry_with_backoff(
203+
|| async {
204+
let response = self
205+
.client
206+
.get(&url)
207+
.send()
208+
.await
209+
.context("failed to fetch historical prices from Yahoo Finance")?;
210+
if !response.status().is_success() {
211+
return Err(anyhow!(
212+
"Yahoo Finance returned status {}",
213+
response.status()
214+
));
215+
}
216+
response
217+
.json::<YahooChartResponse>()
218+
.await
219+
.context("failed to parse Yahoo Finance response")
220+
},
221+
PRICE_FETCH_ATTEMPTS,
222+
)
223+
.await?;
167224

168225
let chart = body
169226
.chart
@@ -211,7 +268,7 @@ impl PriceProvider {
211268
close,
212269
high,
213270
low,
214-
volume: volume as i64,
271+
volume: volume.trunc() as i64,
215272
adjusted_close: close,
216273
});
217274
}
@@ -220,6 +277,23 @@ impl PriceProvider {
220277
}
221278
}
222279

280+
/// Minimal URL path-segment encoder that preserves alphanumerics, dot, dash,
281+
/// underscore, and tilde (the unreserved set per RFC 3986) and percent-encodes
282+
/// everything else. We avoid pulling in the full `percent-encoding` crate for
283+
/// what amounts to ticker symbols.
284+
fn urlencoding_encode(input: &str) -> String {
285+
let mut out = String::with_capacity(input.len());
286+
for byte in input.as_bytes() {
287+
let b = *byte;
288+
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
289+
out.push(b as char);
290+
} else {
291+
out.push_str(&format!("%{:02X}", b));
292+
}
293+
}
294+
out
295+
}
296+
223297
#[derive(Debug, Deserialize)]
224298
struct YahooChartResponse {
225299
chart: YahooChart,

0 commit comments

Comments
 (0)