feat(prices): balance.service prices section + rate-limit + tests (#156) #166
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", () => ({
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue