Every external vendor we use has a short rate limit:
| Source | Typical limit / failure mode |
|--------|------------------------------|
| Yahoo Finance (`yahoo-finance2`) | Edge 429 / "Too Many Requests" under concurrent chart+quote+summary |
| X (bird CLI / cookie session) | HTTP 429 on search / timeline |
| FRED | API key quota; burst-sensitive |
| SEC EDGAR | Fair-access pacing (~10 req/s official guidance) |
| Reddit | OAuth / public endpoint throttles |
The product already had **shared cache + queue dedupe** (ADR-0004) and **stale-while-revalidate**, but request handlers still opened **live vendor calls** (ETF top holdings charts, condition VIX, peers) and the drain loop treated 429 like a normal error with multi-second job backoff. Result: thrash → empty UI panels → worse rate limits.
## Decision
Design the data plane around rate limits as a first-class constraint:
- While cool-down is active: **skip all jobs for that source**; do not enqueue schedule floods
- Job exponential backoff remains for *ordinary* failures only
- Rate-limit hits **do not burn**`MAX_ATTEMPTS` into permanent `failed` without a long cool-down first
### 3. Demand set bounds work
Only symbols in `symbol_demand` (watchlist ∪ holdings) get scheduled yfinance/sec/x refresh. Breadth of interest, not user count, drives cost (ADR-0001 / CONTEXT demand set).
### 4. Stale is better than empty
Showing yesterday’s holdings weights or a 10-minute-old quote with a “cached” affordance beats a blank panel that hammers the vendor. Education product (ADR-0007) does not require millisecond freshness for composition / macro context.
### 5. Observability
Queue health exposes active **source cool-downs** so operators can see “Yahoo paused 4m” instead of a pile of failed jobs.
A 429 cools the **family** (every `source_kind` in that family), not just the one job. Per-request min gaps are process-wide; job min-intervals in AdapterQueue are coarser backup.