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:
commit
97f91f87aa
3 changed files with 497 additions and 1 deletions
26
decisions-log.md
Normal file
26
decisions-log.md
Normal 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.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
getDb: vi.fn(),
|
||||
|
|
@ -1204,3 +1204,238 @@ describe("isLinkedTransactionFkError", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1214,3 +1214,238 @@ export function isLinkedTransactionFkError(error: unknown): boolean {
|
|||
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();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue