1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
//! [`ProviderSet`]: the ordered adapters plus the routing table, and the
//! dispatch that walks them.
use std::sync::Arc;
use std::time::Duration;
use futures::stream::StreamExt;
use super::adapter::ProviderAdapter;
use super::health::HealthTracker;
use super::retry::RetryPolicy;
use super::{Capability, Fetch, Provider, ProviderHealth, Routes};
use crate::adapters::yahoo::client::YahooClient;
use crate::error::{FinanceError, Result};
/// Not part of the stable public API — exposed only so `benches/soothfast.rs`
/// can inject a canned, network-free adapter for gating `Ticker`/`Tickers`
/// hot paths. No semver guarantees; may change or move without notice.
#[doc(hidden)]
pub struct ProviderSet {
providers: Vec<Arc<dyn ProviderAdapter>>,
yahoo_client: Option<Arc<YahooClient>>,
routes: Routes,
/// Opt-in retry policy. `None` (the default) preserves the prior
/// behavior exactly: a `RateLimited` error is treated like any
/// other failure and dispatch moves straight to the next candidate.
retry_policy: Option<RetryPolicy>,
/// Keys scoped to this set, published for the duration of each dispatch
/// so keyed adapters prefer them over the process-global singletons.
api_keys: Arc<crate::adapters::keys::KeyMap>,
/// In-memory recent success/failure tracker, always active (cheap to
/// maintain) and surfaced on request via [`ProviderSet::health`].
health: HealthTracker,
}
impl std::fmt::Debug for ProviderSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderSet")
.field(
"providers",
&self.providers.iter().map(|p| p.id()).collect::<Vec<_>>(),
)
.field("has_yahoo_client", &self.yahoo_client.is_some())
.field("routes", &self.routes)
.finish()
}
}
impl ProviderSet {
/// Assemble a set from adapters and a route table.
///
/// Adapters are tried in the order the route for a capability names them.
pub fn new(providers: Vec<Arc<dyn ProviderAdapter>>, routes: Routes) -> Self {
Self {
providers,
yahoo_client: None,
routes,
retry_policy: None,
api_keys: Arc::new(crate::adapters::keys::KeyMap::new()),
health: HealthTracker::new(),
}
}
/// Attach the shared Yahoo session that `Ticker::client_handle` and logo
/// fetching reach for. `YahooClient` is crate-private, so this cannot be
/// part of the public constructor.
pub(crate) fn with_yahoo_client(mut self, client: Option<Arc<YahooClient>>) -> Self {
self.yahoo_client = client;
self
}
/// Scope API keys to this set rather than the process-global singletons.
pub(crate) fn with_api_keys(mut self, keys: crate::adapters::keys::KeyMap) -> Self {
self.api_keys = Arc::new(keys);
self
}
/// Opt into [`RetryPolicy`]-driven retry of `RateLimited` errors.
/// `None` (the default from [`new`](Self::new)) keeps a `RateLimited`
/// error treated like any other failure.
pub fn with_retry_policy(mut self, policy: Option<RetryPolicy>) -> Self {
self.retry_policy = policy;
self
}
/// Snapshot recent health for every configured provider, in the order
/// they were added (see [`crate::Providers::health`]).
pub fn health(&self) -> Vec<ProviderHealth> {
self.providers
.iter()
.map(|p| {
let mut snapshot = self.health.snapshot(p.id());
snapshot.requests_remaining_estimate = p.rate_limit_remaining();
snapshot
})
.collect()
}
/// Returns the providers to use for a given capability, respecting any
/// explicit route configured via `.route()`. When no route is configured,
/// defaults to Yahoo for all capabilities and EDGAR for filings.
fn candidates_for(&self, cap: Capability) -> Vec<&Arc<dyn ProviderAdapter>> {
if let Some(route) = self.routes.map.get(&cap) {
route
.providers
.iter()
.filter_map(|id| self.providers.iter().find(|p| p.id() == *id))
.collect()
} else if cap == Capability::FILINGS {
// Default: EDGAR (keyless SEC filings) first, then Yahoo
let mut v: Vec<&Arc<dyn ProviderAdapter>> = self
.providers
.iter()
.filter(|p| p.id() == Provider::Edgar)
.collect();
v.extend(self.providers.iter().filter(|p| p.id() == Provider::Yahoo));
v
} else {
// Default: Yahoo only
self.providers
.iter()
.filter(|p| p.id() == Provider::Yahoo)
.collect()
}
}
fn no_provider(cap: Capability) -> FinanceError {
FinanceError::NoProviderAvailable {
operation: cap,
candidates: cap.candidate_providers(),
}
}
/// Real provider failures outrank `NotSupported` (which just means "next
/// candidate"), but when *every* candidate lacked the operation, surface
/// the precise per-operation `NotSupported` instead of collapsing to a
/// capability-level `NoProviderAvailable`.
fn finish_err(
cap: Capability,
last: Option<FinanceError>,
unsupported: Option<FinanceError>,
) -> FinanceError {
last.or(unsupported)
.unwrap_or_else(|| Self::no_provider(cap))
}
/// Call `f(p)`, retrying in place on `FinanceError::RateLimited` per
/// `self.retry_policy`. With no policy configured this is
/// exactly `f(p).await` — zero behavior change for existing callers.
async fn call_with_retry<T, F, Fut>(&self, p: &Arc<dyn ProviderAdapter>, f: &F) -> Result<T>
where
F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let Some(policy) = self.retry_policy.as_ref() else {
return f(p).await;
};
let mut seed = crate::backoff::seed_from_time();
let mut attempt: u32 = 0;
loop {
match f(p).await {
Ok(v) => return Ok(v),
Err(FinanceError::RateLimited { retry_after })
if attempt + 1 < policy.max_attempts =>
{
let delay =
policy.delay_for(attempt, retry_after.map(Duration::from_secs), &mut seed);
tokio::time::sleep(delay).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}
/// Record a candidate's outcome in the health tracker. `NotSupported`
/// isn't a provider failure (it just means "try the next candidate"), so
/// it's excluded from health accounting entirely.
fn record_health<T>(&self, provider: Provider, result: &Result<T>) {
match result {
Ok(_) => self.health.record(provider, true, None),
Err(FinanceError::NotSupported { .. }) => {}
Err(e) => self.health.record(provider, false, Some(e.to_string())),
}
}
pub(crate) async fn fetch<T, F, Fut>(&self, cap: Capability, f: F) -> Result<T>
where
F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
if self.api_keys.is_empty() {
return self.dispatch(cap, f).await;
}
// Boxed so the scoped variant does not enlarge the future every
// dispatch builds; only callers that configure a key pay for it.
Box::pin(crate::adapters::keys::scope(
Arc::clone(&self.api_keys),
self.dispatch(cap, f),
))
.await
}
async fn dispatch<T, F, Fut>(&self, cap: Capability, f: F) -> Result<T>
where
F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let candidates = self.candidates_for(cap);
if candidates.is_empty() {
return Err(Self::no_provider(cap));
}
match self.routes.fetch_mode_for(cap) {
Fetch::Sequential => {
let mut last = None;
let mut unsupported = None;
for p in &candidates {
let result = self.call_with_retry(p, &f).await;
self.record_health(p.id(), &result);
match result {
Ok(v) => return Ok(v),
Err(e @ FinanceError::NotSupported { .. }) => unsupported = Some(e),
Err(e) => last = Some(e),
}
}
Err(Self::finish_err(cap, last, unsupported))
}
Fetch::Parallel => {
let mut futs = futures::stream::FuturesUnordered::new();
for p in &candidates {
let id = p.id();
// Capture `&f` (Copy) rather than `f` itself — `f: F` isn't
// necessarily `Copy`, and `async move` would otherwise try
// (and fail) to move it out on every loop iteration.
let f_ref = &f;
futs.push(async move {
let result = self.call_with_retry(p, f_ref).await;
self.record_health(id, &result);
result
});
}
let mut last = None;
let mut unsupported = None;
while let Some(r) = futs.next().await {
match r {
Ok(v) => return Ok(v),
Err(e @ FinanceError::NotSupported { .. }) => unsupported = Some(e),
Err(e) => last = Some(e),
}
}
Err(Self::finish_err(cap, last, unsupported))
}
}
}
/// Fetch one page. A continuation stays on the provider that issued it and must
/// match `operation` and `identity`; the returned continuation is bound to both.
pub(crate) async fn fetch_paged<T, F, Fut>(
&self,
cap: Capability,
operation: super::Operation,
identity: &str,
cursor: Option<&crate::PageCursor>,
f: F,
) -> Result<crate::ProviderPage<T>>
where
F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
Fut: std::future::Future<Output = Result<crate::ProviderPage<T>>>,
{
// A custom adapter builds its own `ProviderPage`, so its `provider_id` can't be
// trusted to name the adapter that answered.
let answered_by = |p: &Arc<dyn ProviderAdapter>| {
let id = p.id();
let page = f(p);
async move {
let mut page = page.await?;
page.provider_id = id;
Ok(page)
}
};
let mut page = match cursor {
Some(cursor) => {
cursor.validate(operation, identity)?;
self.fetch_from(cursor.provider(), cap, answered_by).await?
}
None => self.fetch(cap, answered_by).await?,
};
if let Some(next) = &mut page.next {
if next.provider() != page.provider_id {
return Err(FinanceError::ResponseStructureError {
field: "next".into(),
context: "continuation belongs to another provider".into(),
});
}
next.bind(operation, identity);
}
Ok(page)
}
async fn fetch_from<T, F, Fut>(
&self,
provider: super::Provider,
cap: Capability,
f: F,
) -> Result<T>
where
F: Fn(&Arc<dyn ProviderAdapter>) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let p = self
.candidates_for(cap)
.into_iter()
.find(|p| p.id() == provider)
.ok_or_else(|| FinanceError::InvalidParameter {
param: "cursor".into(),
reason: "cursor provider is not configured on this route".into(),
})?;
crate::adapters::keys::scope(Arc::clone(&self.api_keys), async {
let result = self.call_with_retry(p, &f).await;
self.record_health(p.id(), &result);
result
})
.await
}
pub(crate) fn adapter(&self, id: super::Provider) -> Option<&Arc<dyn ProviderAdapter>> {
self.providers.iter().find(|p| p.id() == id)
}
pub(crate) fn first_yahoo(&self) -> Result<Arc<YahooClient>> {
self.yahoo_client.as_ref().map(Arc::clone).ok_or_else(|| {
FinanceError::NoProviderAvailable {
operation: Capability::QUOTE,
candidates: vec![Provider::Yahoo],
}
})
}
}