feat(prices): balance.service prices section + rate-limit + tests #156

Closed
opened 2026-04-27 00:15:36 +00:00 by maximus · 0 comments
Owner

Goal

Extend src/services/balance.service.ts with a prices section that wraps the fetch_price Tauri command with rate-limiting, dedup, and a hard session cap.

Contract reference

docs/api-contract-prices.md §6.2 (client rate-limit), §12.1 (tests), Annexe B (i18n mapping).

Fichiers concernés

  • src/services/balance.service.ts (add prices section, ~150 lines new)
  • src/services/balance.service.test.ts (add new test suite, ~200 lines new)

Depends on

Scope

  • In balance.service.ts, add namespace export prices:
    • prices.fetchPrice(symbol: string, date: string): Promise<PriceResult> — calls invoke('fetch_price', {...})
    • Maps Rust FetchPriceError (string discriminant) to TS discriminated union PriceError
    • Mapping PriceError → balance.priceFetching.errors.<key> per Annexe B
  • Rate-limit local : pace outgoing requests at max 1 / 2 sec. Subsequent calls queue. Implementation: simple lastFiredAt timestamp + setTimeout for delay.
  • Dedup in-flight : a Map<string, Promise<PriceResult>> keyed by ${symbol}|${date}. Calls during in-flight return the same promise.
  • Backoff exponentiel sur 5xx / network / provider_unavailable : 2s, 4s, 8s. Max 3 retries. Pas de retry sur 429 (cf. décision ce soir) — surface immédiat à l'UI.
  • Pas de retry sur 401, 403, 404, 400 — surface immédiat.
  • Plafond hard 100 req / session. « Session » = lifetime of the Tauri app process. Counter resets on app restart (in-memory). 101e appel rejette avec PriceError.SessionCapReached.
  • Vitest tests with vi.useFakeTimers() :
    • happy path 200
    • 401/403/404/400 → no retry
    • 5xx → 3 retries with 2/4/8s delays, then fail
    • 429 → no retry, surface immediately with retry_after
    • dedup : two await prices.fetchPrice('AAPL', '2026-04-25') in parallel → 1 underlying invoke call
    • rate-limit pacing : 3 calls in 1ms → 1st now, 2nd at +2s, 3rd at +4s
    • session cap : 100 successful calls OK, 101e rejette

Critères d'acceptation

  • npm test src/services/balance.service.test.ts green (≥ 8 tests on prices section)
  • Toutes les tests temporelles utilisent vi.useFakeTimers() — non flaky
  • Pas d'import de lib externe (msw, nock) — uniquement vi.fn() sur invoke
  • TypeScript strict mode clean (tsc --noEmit)

Décisions prises ce soir

  • 429 = surface immédiate, pas de retry auto. Toast UI avec retry_after.
  • « Session » = app process lifetime. Pas de persistance, pas de reset sur profile switch.
  • Lib mock : vi.fn() natif sur invoke (pas de msw).

Spec source

docs/api-contract-prices.md

## Goal Extend `src/services/balance.service.ts` with a `prices` section that wraps the `fetch_price` Tauri command with rate-limiting, dedup, and a hard session cap. ## Contract reference `docs/api-contract-prices.md` §6.2 (client rate-limit), §12.1 (tests), Annexe B (i18n mapping). ## Fichiers concernés - `src/services/balance.service.ts` (add `prices` section, ~150 lines new) - `src/services/balance.service.test.ts` (add new test suite, ~200 lines new) ## Depends on - #154, #155 ## Scope - [ ] In `balance.service.ts`, add namespace export `prices`: - `prices.fetchPrice(symbol: string, date: string): Promise<PriceResult>` — calls `invoke('fetch_price', {...})` - Maps Rust `FetchPriceError` (string discriminant) to TS discriminated union `PriceError` - Mapping `PriceError → balance.priceFetching.errors.<key>` per Annexe B - [ ] **Rate-limit local** : pace outgoing requests at max 1 / 2 sec. Subsequent calls queue. Implementation: simple `lastFiredAt` timestamp + `setTimeout` for delay. - [ ] **Dedup in-flight** : a `Map<string, Promise<PriceResult>>` keyed by `${symbol}|${date}`. Calls during in-flight return the same promise. - [ ] **Backoff exponentiel** sur 5xx / network / `provider_unavailable` : 2s, 4s, 8s. Max 3 retries. **Pas de retry sur 429** (cf. décision ce soir) — surface immédiat à l'UI. - [ ] **Pas de retry sur 401, 403, 404, 400** — surface immédiat. - [ ] **Plafond hard 100 req / session**. « Session » = lifetime of the Tauri app process. Counter resets on app restart (in-memory). 101e appel rejette avec `PriceError.SessionCapReached`. - [ ] Vitest tests with `vi.useFakeTimers()` : - happy path 200 - 401/403/404/400 → no retry - 5xx → 3 retries with 2/4/8s delays, then fail - 429 → no retry, surface immediately with `retry_after` - dedup : two `await prices.fetchPrice('AAPL', '2026-04-25')` in parallel → 1 underlying invoke call - rate-limit pacing : 3 calls in 1ms → 1st now, 2nd at +2s, 3rd at +4s - session cap : 100 successful calls OK, 101e rejette ## Critères d'acceptation - [ ] `npm test src/services/balance.service.test.ts` green (≥ 8 tests on `prices` section) - [ ] Toutes les tests temporelles utilisent `vi.useFakeTimers()` — non flaky - [ ] Pas d'import de lib externe (`msw`, `nock`) — uniquement `vi.fn()` sur `invoke` - [ ] TypeScript strict mode clean (`tsc --noEmit`) ## Décisions prises ce soir - 429 = surface immédiate, pas de retry auto. Toast UI avec `retry_after`. - « Session » = app process lifetime. Pas de persistance, pas de reset sur profile switch. - Lib mock : `vi.fn()` natif sur `invoke` (pas de `msw`). ## Spec source `docs/api-contract-prices.md`
maximus added this to the spec-price-fetching milestone 2026-04-27 00:15:36 +00:00
maximus added the
status:ready
type:feature
source:human
labels 2026-04-27 00:15:36 +00:00
maximus modified the milestone from spec-price-fetching to overnight-2026-04-27-prices 2026-04-27 00:32:02 +00:00
maximus added the
status:in-progress
label 2026-04-27 12:28:44 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#156
No description provided.