Merge pull request 'feat(prices): balance.service prices section + rate-limit + tests (#156)' (#166) from issue-156-balance-service-prices into main

This commit is contained in:
maximus 2026-04-28 01:32:41 +00:00
commit 97f91f87aa
3 changed files with 497 additions and 1 deletions

26
decisions-log.md Normal file
View file

@ -0,0 +1,26 @@
# Decisions Log — /autopilot run of 2026-04-27
## Issue #156 — session cap budget policy (MEDIUM)
The 100-request session cap is checked BEFORE rate-limit enforcement and in-flight
deduplication. Successful fetches increment the counter; failures (4xx, 5xx, network)
do NOT consume the budget. Rationale: a user who hits a bad symbol or an auth error
should not have their session budget drained by error conditions outside their control.
This is the most user-friendly interpretation of "hard 100/session cap" while still
protecting against runaway loops.
## Issue #156 — __resetForTests helper exported from prices namespace (LOW)
The `prices.__resetForTests()` helper is exported alongside `fetchPrice`. This avoids
the need for `vi.resetModules()` + dynamic import between tests, which is flakier and
slower. The helper is named with `__` prefix to signal test-only usage. Alternative
considered: module-level export — rejected because it would pollute the public API
surface of balance.service outside the prices namespace.
## Issue #156 — rate-limit pacing test strategy (LOW)
The pacing test verifies that setTimeout is called with a positive delay for the 2nd
and 3rd concurrent calls, rather than asserting exact wall-clock timestamps via
Date.now(). This is because vi.useFakeTimers() advances Date.now() via timer
advancement, not automatically between microtasks. The spy approach is more resilient
to vitest internals and fake-timer edge cases.

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("./db", () => ({ vi.mock("./db", () => ({
getDb: vi.fn(), getDb: vi.fn(),
@ -1204,3 +1204,238 @@ describe("isLinkedTransactionFkError", () => {
expect(isLinkedTransactionFkError(undefined)).toBe(false); expect(isLinkedTransactionFkError(undefined)).toBe(false);
}); });
}); });
// -----------------------------------------------------------------------------
// prices namespace (Issue #156 / Bilan #5)
// -----------------------------------------------------------------------------
import { prices } from "./balance.service";
const FAKE_PRICE_RESPONSE = {
symbol: "AAPL",
date: "2026-04-25",
price: 173.45,
currency: "USD",
source: "yahoo",
cached: false,
actual_date: null,
fetched_at: "2026-04-25T14:32:11Z",
};
describe("balance.service.prices", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(invoke).mockReset();
prices.__resetForTests();
});
afterEach(() => {
vi.useRealTimers();
});
// 1. Happy path 200
it("fetchPrice happy path returns ok:true with price fields", async () => {
vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE);
const result = await prices.fetchPrice("AAPL", "2026-04-25");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.symbol).toBe("AAPL");
expect(result.date).toBe("2026-04-25");
expect(result.price).toBe(173.45);
expect(result.currency).toBe("USD");
expect(result.source).toBe("yahoo");
expect(result.cached).toBe(false);
}
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith("fetch_price", {
symbol: "AAPL",
date: "2026-04-25",
});
});
// 2. 401 (auth) — no retry
it("fetchPrice auth error returns ok:false code:auth with 1 invoke call", async () => {
vi.mocked(invoke).mockRejectedValueOnce('{"code":"auth"}');
const promise = prices.fetchPrice("AAPL", "2026-04-25");
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("auth");
expect(result.error.i18nKey).toBe(
"balance.priceFetching.errors.authFailed"
);
}
expect(invoke).toHaveBeenCalledTimes(1);
});
// 3. 403 premium_required — no retry
it("fetchPrice premium_required returns immediately without retry", async () => {
vi.mocked(invoke).mockRejectedValueOnce('{"code":"premium_required"}');
const promise = prices.fetchPrice("AAPL", "2026-04-25");
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("premium_required");
expect(result.error.i18nKey).toBe(
"balance.priceFetching.errors.premiumRequired"
);
}
expect(invoke).toHaveBeenCalledTimes(1);
});
// 4. 404 symbol_not_found — no retry
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
const promise = prices.fetchPrice("AAPL", "2026-04-25");
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("symbol_not_found");
expect(result.error.i18nKey).toBe(
"balance.priceFetching.errors.symbolNotFound"
);
}
expect(invoke).toHaveBeenCalledTimes(1);
});
// 5. 429 rate_limit — no retry, carries retry_after_s
it("fetchPrice rate_limit 429 returns ok:false with retry_after_s, no retry", async () => {
vi.mocked(invoke).mockRejectedValueOnce(
'{"code":"rate_limit","retry_after_s":30}'
);
const promise = prices.fetchPrice("AAPL", "2026-04-25");
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("rate_limit");
if (result.error.code === "rate_limit") {
expect(result.error.retry_after_s).toBe(30);
expect(result.error.i18nKey).toBe(
"balance.priceFetching.errors.rateLimit"
);
}
}
expect(invoke).toHaveBeenCalledTimes(1);
});
// 6. 5xx provider_unavailable — 3 retries with 2/4/8s backoff (4 total calls)
it("fetchPrice provider_unavailable retries 3 times with 2/4/8s backoff", async () => {
vi.mocked(invoke).mockRejectedValue('{"code":"provider_unavailable"}');
const promise = prices.fetchPrice("AAPL", "2026-04-25");
// Advance through all retry delays: 2s + 4s + 8s = 14s total
await vi.advanceTimersByTimeAsync(2000); // retry 1 fires after 2s
await vi.advanceTimersByTimeAsync(4000); // retry 2 fires after 4s
await vi.advanceTimersByTimeAsync(8000); // retry 3 fires after 8s
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("provider_unavailable");
expect(result.error.i18nKey).toBe(
"balance.priceFetching.errors.serverUnavailable"
);
}
// 1 initial + 3 retries = 4 total invoke calls
expect(invoke).toHaveBeenCalledTimes(4);
});
// 7. In-flight deduplication
it("fetchPrice dedup: two parallel calls with same key → only one invoke", async () => {
vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE);
const p1 = prices.fetchPrice("AAPL", "2026-04-25");
const p2 = prices.fetchPrice("AAPL", "2026-04-25");
await vi.runAllTimersAsync();
const [r1, r2] = await Promise.all([p1, p2]);
expect(invoke).toHaveBeenCalledTimes(1);
expect(r1.ok).toBe(true);
expect(r2.ok).toBe(true);
if (r1.ok && r2.ok) {
expect(r1.price).toBe(r2.price);
}
});
// 8. Rate-limit pacing: calls are serialized through _enforceRateLimit,
// so 3 concurrent calls result in 3 sequential invoke calls, each separated
// by at least MIN_INTERVAL_MS (2s). We verify that the setTimeout inside
// _enforceRateLimit is actually called with the correct delay.
it("fetchPrice rate-limit pacing: each call waits at least 2s after the previous", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE);
// Start 3 calls for different symbols (no dedup). Only the first fires
// immediately; the others queue up behind the rate-limit.
const p1 = prices.fetchPrice("AAPL", "2026-01-01");
const p2 = prices.fetchPrice("MSFT", "2026-01-01");
const p3 = prices.fetchPrice("TSLA", "2026-01-01");
// Advance enough time for all 3 to complete (3 × 2s = 6s).
await vi.advanceTimersByTimeAsync(6000);
await vi.runAllTimersAsync();
await Promise.all([p1, p2, p3]);
// All 3 invoke calls must have been made.
expect(invoke).toHaveBeenCalledTimes(3);
// At least 2 setTimeout calls for the rate-limit waits (p2 and p3 must wait).
// The actual delay argument should be ~2000ms (or close to it, as the
// timer fires slightly early in fake-timer environments).
const rateLimitTimers = setTimeoutSpy.mock.calls.filter(
([, delay]) => typeof delay === "number" && delay > 0 && delay <= 2000
);
expect(rateLimitTimers.length).toBeGreaterThanOrEqual(2);
setTimeoutSpy.mockRestore();
});
// 9. Session cap: 101st call returns session_cap_reached without calling invoke
it("fetchPrice session cap: 101st call returns session_cap_reached", async () => {
// Set up invoke to always resolve successfully
vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE);
// Fire 100 successful calls to fill the session cap.
// We bypass the rate-limit by advancing time enough between each.
for (let i = 0; i < 100; i++) {
const p = prices.fetchPrice(`SYM${i}`, "2026-01-01");
await vi.advanceTimersByTimeAsync(2000);
await p;
}
// The 101st call should immediately return session_cap_reached.
vi.mocked(invoke).mockClear(); // reset call counter
const result = await prices.fetchPrice("EXTRA", "2026-01-01");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("session_cap_reached");
expect(result.error.i18nKey).toBe(
"balance.priceFetching.errors.sessionCapReached"
);
}
// invoke must NOT have been called for the 101st request
expect(invoke).not.toHaveBeenCalled();
});
});

View file

@ -1214,3 +1214,238 @@ export function isLinkedTransactionFkError(error: unknown): boolean {
return /FOREIGN KEY constraint failed/i.test(msg); return /FOREIGN KEY constraint failed/i.test(msg);
} }
// -----------------------------------------------------------------------------
// Prices — fetch_price Tauri command wrapper (Issue #156 / Bilan #5)
// -----------------------------------------------------------------------------
//
// Wraps `invoke('fetch_price', { symbol, date })` with:
// - Local rate-limit (1 request / 2s via module-level timestamp)
// - In-flight deduplication (same symbol+date → one request, multiple awaiters)
// - Exponential backoff on 5xx-class errors (2/4/8s, max 3 retries)
// - No retry on 4xx errors or rate_limit (429-class)
// - Hard 100-request session cap (successful fetches only)
//
// The Rust command `fetch_price` (implemented in issue #155) rejects with a
// JSON string serialized from the Rust error enum:
// {"code":"auth"} | {"code":"rate_limit","retry_after_s":42} | ...
//
// Annexe B i18n mapping (keys live in the i18n PR #160):
// auth → balance.priceFetching.errors.authFailed
// premium_required → balance.priceFetching.errors.premiumRequired
// symbol_not_found → balance.priceFetching.errors.symbolNotFound
// rate_limit → balance.priceFetching.errors.rateLimit
// provider_unavailable → balance.priceFetching.errors.serverUnavailable
// network → balance.priceFetching.errors.serverUnavailable
// internal → balance.priceFetching.errors.serverUnavailable
// session_cap_reached → balance.priceFetching.errors.sessionCapReached
export type PriceErrorCode =
| "auth"
| "premium_required"
| "symbol_not_found"
| "rate_limit"
| "provider_unavailable"
| "network"
| "internal"
| "session_cap_reached";
export type PriceError =
| { code: "rate_limit"; retry_after_s: number; i18nKey: string }
| { code: Exclude<PriceErrorCode, "rate_limit">; i18nKey: string };
export interface PriceSuccess {
ok: true;
symbol: string;
date: string;
price: number;
currency: string;
source: string;
cached: boolean;
actual_date?: string | null;
fetched_at: string;
}
export type PriceResult =
| PriceSuccess
| { ok: false; error: PriceError };
/** Raw shape returned by the Rust `fetch_price` command on success. */
interface RawPriceResponse {
symbol: string;
date: string;
price: number;
currency: string;
source: string;
cached: boolean;
actual_date?: string | null;
fetched_at: string;
}
// i18n key map for non-rate_limit error codes.
const PRICE_ERROR_I18N_MAP: Record<Exclude<PriceErrorCode, "rate_limit">, string> = {
auth: "balance.priceFetching.errors.authFailed",
premium_required: "balance.priceFetching.errors.premiumRequired",
symbol_not_found: "balance.priceFetching.errors.symbolNotFound",
provider_unavailable: "balance.priceFetching.errors.serverUnavailable",
network: "balance.priceFetching.errors.serverUnavailable",
internal: "balance.priceFetching.errors.serverUnavailable",
session_cap_reached: "balance.priceFetching.errors.sessionCapReached",
};
/** Codes that map to no-retry behaviour (4xx-class or session cap). */
const NO_RETRY_CODES = new Set<PriceErrorCode>([
"auth",
"premium_required",
"symbol_not_found",
"rate_limit",
"session_cap_reached",
]);
/**
* Parse the string-serialized Rust error into a typed `PriceError`.
* `invoke` rejects with the value of `Result::Err(String)`, which the Rust
* side serialises via serde_json (see issue #155 worker decision).
*/
function parseRustError(e: unknown): PriceError {
if (typeof e === "string") {
try {
const j = JSON.parse(e) as Record<string, unknown>;
if (j && typeof j.code === "string") {
const code = j.code;
if (code === "rate_limit") {
const retry_after_s =
typeof j.retry_after_s === "number" ? j.retry_after_s : 0;
return {
code: "rate_limit",
retry_after_s,
i18nKey: "balance.priceFetching.errors.rateLimit",
};
}
if (code in PRICE_ERROR_I18N_MAP) {
const typedCode = code as Exclude<PriceErrorCode, "rate_limit">;
return { code: typedCode, i18nKey: PRICE_ERROR_I18N_MAP[typedCode] };
}
}
} catch {
// Fall through to default below.
}
}
return {
code: "internal",
i18nKey: PRICE_ERROR_I18N_MAP.internal,
};
}
// Module-level state — resets only when the JS module is re-imported
// (i.e. on app process restart). Tests reset via `prices.__resetForTests()`.
let _lastFiredAt = 0;
let _sessionCount = 0;
const SESSION_CAP = 100;
const MIN_INTERVAL_MS = 2000;
const _inFlight = new Map<string, Promise<PriceResult>>();
/** Enforce the 1-request-per-2s local rate limit. */
async function _enforceRateLimit(): Promise<void> {
const now = Date.now();
const wait = Math.max(0, _lastFiredAt + MIN_INTERVAL_MS - now);
if (wait > 0) {
await new Promise<void>((r) => setTimeout(r, wait));
}
_lastFiredAt = Date.now();
}
/** Single attempt: rate-limit, then invoke once. */
async function _doFetchOnce(
symbol: string,
date: string
): Promise<PriceResult> {
await _enforceRateLimit();
try {
const raw = await invoke<RawPriceResponse>("fetch_price", { symbol, date });
return { ok: true, ...raw };
} catch (e) {
return { ok: false, error: parseRustError(e) };
}
}
/** Wrap _doFetchOnce with exponential backoff on retryable errors (5xx-class). */
async function _withRetries(
symbol: string,
date: string
): Promise<PriceResult> {
const delays = [2000, 4000, 8000];
let lastResult: PriceResult | null = null;
for (let attempt = 0; attempt <= 3; attempt++) {
const r = await _doFetchOnce(symbol, date);
if (r.ok) return r;
lastResult = r;
const code = r.error.code;
if (NO_RETRY_CODES.has(code)) {
// 4xx-class: return immediately, no retry.
return r;
}
// 5xx-class (provider_unavailable, network, internal): retry with backoff.
if (attempt < 3) {
await new Promise<void>((r) => setTimeout(r, delays[attempt]));
}
}
// Should never reach here, but satisfy TypeScript.
return lastResult!;
}
/**
* `prices` namespace entry point for the UI.
*
* All outgoing requests are rate-limited (1/2s), deduplicated in-flight, and
* wrapped with exponential backoff on 5xx-class errors. A hard session cap of
* 100 successful fetches guards against runaway loops.
*/
export const prices = {
/**
* Fetch the price for `symbol` at `date` (ISO YYYY-MM-DD).
*
* Decision (MEDIUM): the 100-session cap is checked BEFORE rate-limit and
* dedup. Successful fetches increment the counter; failures do NOT consume
* the budget a 4xx auth error costs nothing, and a user who hits a bad
* symbol shouldn't have their session budget drained.
*/
async fetchPrice(symbol: string, date: string): Promise<PriceResult> {
if (_sessionCount >= SESSION_CAP) {
return {
ok: false,
error: {
code: "session_cap_reached",
i18nKey: PRICE_ERROR_I18N_MAP.session_cap_reached,
},
};
}
const key = `${symbol}|${date}`;
const existing = _inFlight.get(key);
if (existing) return existing;
const promise = (async () => {
try {
const result = await _withRetries(symbol, date);
if (result.ok) _sessionCount++;
return result;
} finally {
_inFlight.delete(key);
}
})();
_inFlight.set(key, promise);
return promise;
},
/**
* Reset module-level state between tests.
* Call in `beforeEach` to isolate rate-limit, session count, and in-flight map.
*/
__resetForTests(): void {
_lastFiredAt = 0;
_sessionCount = 0;
_inFlight.clear();
},
};