Repository navigation
fix(web-shared): share in-flight fetches and back off failures in createCachedFetch - #7881
Conversation
…ateCachedFetch Concurrent callers of an expired cache entry previously ran the fetcher independently, producing one underlying query or fetch per caller. A failing refresh was retried on every call, so an upstream outage amplified into one failing fetch per request. createCachedFetch now stores the pending promise and returns it to callers that arrive while a refresh is in flight, clearing it in a finally block, mirroring createModelsByProviderIndexLoader. A failed refresh is negatively cached for a bounded backoff window (FAILURE_BACKOFF_MS, 30s default, overridable per caller via failureBackoffMs); callers keep receiving the last-known-good or default value until the window passes. Verified against local Postgres: 20 concurrent requests through a createCachedFetch consumer ran one query (baseline 20); jest suites cover sharing, backoff, and recovery.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryThe incremental change fixes the prior warning by storing failed refreshes with Notes
Files Reviewed (2 files)
Previous Review Summaries (2 snapshots, latest commit e4734c0)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit e4734c0)Status: No Issues Found | Recommendation: Merge Executive SummaryThe follow-up stops a failed refresh from looking fresh ( Files Reviewed (2 files)
Previous review (commit 5c629b3)Status: 1 Issue Found | Recommendation: Address before merge Executive SummaryThe shared in-flight refresh and never-rejecting promise are correct, but re-storing Overview
Issue Details (click to expand)WARNING
Files Reviewed (2 files)
Reviewed by deepseek-v4.1-flash-1017 · Input: 34.9K · Output: 4.8K · Cached: 172.4K Review guidance: REVIEW.md from base branch |
A failed refresh stored the failure deadline while keeping the stale value's original timestamp, so a still-fresh last-known-good value satisfied the freshness check and hid the backoff window entirely: the next call kept hitting the stale value without ever observing the backoff, and callers pinned to immediate retry (direct-byok model lists) kept retrying every call. The failure now resets the stored clock, so the backoff window is always observed. The direct-byok model-list suite, which pins the immediate-retry behaviour per the owner's decision, is rewritten to assert the backoff semantics: a failed read is not retried inside the window, is retried at the deadline, and an unrelated provider's loader is unaffected.
|
bot: Fixed in e4734c0. The failure path now stores the stale/default value with |
There was a problem hiding this comment.
bot: Looks good. The shared in-flight refresh, the never-rejecting shared promise, and storing the failure fallback with at: 0 so the backoff window is honored exactly all check out. I checked every current caller: all fetchers are async functions or return promises from async functions, plain data, no request-scoped APIs, so sharing one refresh is safe.
One non-blocking hardening suggestion: inFlight = (async () => { ... finally { inFlight = null } })() pins inFlight forever if a fetcher ever throws synchronously. The IIFE runs its catch/finally synchronously before the assignment, so inFlight ends up holding an already-settled promise and every later call returns it without fetching again. Reproduced with a plain () => { throw ... } fetcher: the fetcher is never called again, even after the TTL and backoff. No current caller can hit this (all fetchers are async), but it's a shared helper. Assigning first avoids it:
const refresh = (async (): Promise<T> => { /* try/catch without finally */ })();
inFlight = refresh;
void refresh.finally(() => {
if (inFlight === refresh) inFlight = null;
});
return await refresh;plus a test with a synchronously throwing fetcher.
|
bot: Addressed the synchronous-throw suggestion in e3a8db3. The initiating caller now assigns the refresh promise before awaiting it and clears inFlight in its own finally block. Added a regression covering repeated synchronous failures, suppression inside each backoff window, and successful recovery at the deadline. The pre-fix probe stayed on fallback with one attempt; the fixed probe recovered on attempt two. Verification: 10 cached-fetch tests passed, targeted TypeScript and lint checks passed, and a loopback HTTP smoke run served two fallback requests followed by 20 concurrent recovery requests sharing one upstream read. |
Change
createCachedFetch(packages/web-shared/src/lib/cached-fetch.ts) previously ran the fetcher once per concurrent caller once an entry's TTL had expired, and retried a failing fetcher on every call with no backoff. Both are now fixed in the helper, so every existing consumer (11 call sites: preferred models, gateway model metadata caches, routing table, routing config, request-logging opt-ins, unavailable models, auto-free config, blacklist domains, providers catalogue, public snowflake reports) inherits the behaviour:finallyblock — the same pattern ascreateModelsByProviderIndexLoader. An expired entry can no longer produce N concurrent underlying queries.FAILURE_BACKOFF_MS(30s default, exported). Callers keep receiving the last-known-good or default value and no new fetch is attempted until the window passes. Callers can pass{ failureBackoffMs: 0 }to restore retry-every-call; the focused unit tests use that option to keep TTL/refresh semantics deterministic.No call-site changes were needed: every consumer already serves the stale/default value when the fetcher throws, so the backoff only replaces an unthrottled failing retry with a bounded one.
Verification
jestsuites: 32 tests pass across cached-fetch, preferred-models, request-logging-opt-ins, and unavailable-models (the suites that exercise this helper directly or through consumers).cached-fetch.test.tsgained three tests: N concurrent callers after TTL expiry run the fetcher once; a throwing fetcher runs once across many calls inside the backoff window and again at the deadline; the default value is served for the window when nothing is cached.pnpm --filter @kilocode/web-shared run typecheckpasses;oxlintreports 0 warnings/errors on the changed files;oxfmt --list-differentclean.createCachedFetchconsumers with their production TTLs was hit by 20 concurrent requests; Postgres statement logging (enabled viaALTER SYSTEM SET log_statement='all') captured exactly oneselect "openrouter" from "models_by_provider" ...and oneselect "preferred_models" from "ai_gateway_config" ...for the bursts. A pre-fix baseline of 20 independent (uncached) calls against the same sequence counted 20 query executions. Statement logging was disabled and the seeded row and throwaway route removed afterwards./api/v1/providersrequest returned HTTP 200 with the seeded payload through the fixed helper.Proxy answers
The owner's proxy gave these answers in the owner's place.
Answer: Default for all callers (30s), opt-out per caller
Confidence: 0.75 · Precedent: Fix the root cause and the class of the issue: session 7f3470e0ba6f, "fix class of issue preventing progress"
Answer: Optional 4th arg { failureBackoffMs }, default 30s, constant exported
Confidence: 0.70 · Precedent: Fix the root cause and the class of the issue: session 00eb2a1d3e4b, "if anything repeats the same mistake over and over again, fix the class"
Answer: Keep Date.now() and jest.spyOn(Date, 'now') as the existing test file does
Confidence: 0.75 · Precedent: Do the least work possible with no drop in quality: session 7f3470e0ba6f, "do the least amount of work possible, with no drop in quality"
Answer: Rewrite the direct-byok tests
Confidence: 0.55 · Precedent: Fix the root cause and the class of the issue: session 00eb2a1d3e4b, "if anything repeats the same mistake over and over again, fix the class". Also: replace old paths instead of keeping them alongside: session ad6a3f589364, "alongside? why not instead?"