Skip to content

Commit 055e052

Browse files
committed
fix(model-cache): preserve per-caller failure contract on shared in-flight fetch
1 parent 33f7b04 commit 055e052

2 files changed

Lines changed: 96 additions & 76 deletions

File tree

src/api/providers/fetchers/__tests__/modelCache.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,37 @@ describe("empty cache protection", () => {
463463
expect(refreshResult).toEqual(mockModels)
464464
})
465465

466+
it("preserves each entry point's own failure contract when joining a shared in-flight fetch", async () => {
467+
// getModels() and refreshModels() share the same underlying provider fetch
468+
// (dedupedFetch), but must not share its resolution/rejection wholesale: getModels()
469+
// always re-throws on failure, while refreshModels() always degrades to cache/{}.
470+
// Whichever call happens to start the shared fetch must not impose its own contract
471+
// on the other caller that joined it.
472+
const fetchError = new Error("provider unreachable")
473+
474+
let rejectPromise: (error: Error) => void
475+
const delayedRejection = new Promise<never>((_resolve, reject) => {
476+
rejectPromise = reject
477+
})
478+
mockGetOpenRouterModels.mockReturnValue(delayedRejection)
479+
mockGet.mockReturnValue(undefined)
480+
481+
const { refreshModels } = await import("../modelCache")
482+
483+
// refreshModels() starts (and registers) the shared fetch; getModels() joins it.
484+
const refreshPromise = refreshModels({ provider: providerIdentifiers.openrouter })
485+
const getPromise = getModels({ provider: providerIdentifiers.openrouter })
486+
487+
expect(mockGetOpenRouterModels).toHaveBeenCalledTimes(1)
488+
489+
rejectPromise!(fetchError)
490+
491+
// refreshModels() degrades gracefully (no existing cache -> {}); getModels() still
492+
// re-throws the original error instead of silently returning refreshModels()'s {}.
493+
await expect(refreshPromise).resolves.toEqual({})
494+
await expect(getPromise).rejects.toThrow("provider unreachable")
495+
})
496+
466497
it("does not share an in-flight fetch between different endpoints/keys", async () => {
467498
const mockModelsA = {
468499
"litellm/model-a": {

src/api/providers/fetchers/modelCache.ts

Lines changed: 65 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -305,63 +305,72 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
305305
return models
306306
}
307307

308-
// Route the cache-miss fetch through the same single-flight coordinator refreshModels()
309-
// uses (inFlightRefresh), keyed on the same compound cacheKey. Without this, concurrent
308+
// Route the cache-miss fetch through dedupedFetch(), the same single-flight coordinator
309+
// refreshModels() uses, keyed on the same compound cacheKey. Without this, concurrent
310310
// getModels() calls for the same key each independently miss the cache and fire their own
311311
// redundant provider fetch, and a getModels() fetch racing a refreshModels() fetch for the
312312
// same key has no ordering guarantee -- whichever call's memoryCache.set() lands last wins,
313-
// even if it started (and thus reflects) an earlier, staler request. Sharing the map means
314-
// every caller for a given key -- get or refresh -- converges on one in-flight fetch.
315-
if (!shouldSkipCache) {
316-
const existingRequest = inFlightRefresh.get(cacheKey)
317-
if (existingRequest) {
318-
return existingRequest
319-
}
320-
}
321-
322-
const fetchPromise = (async (): Promise<ModelRecord> => {
323-
try {
324-
const fetched = await fetchModelsFromProvider(options)
325-
const modelCount = Object.keys(fetched).length
326-
327-
// Only cache non-empty results so a failed API response doesn't get persisted
328-
// as if the provider had no models. Auth-scoped providers skip caching entirely.
329-
if (modelCount > 0) {
330-
// Clear the empty-response throttle for any non-empty response, including from
331-
// auth-scoped providers that skip caching, so a later empty response is reported again.
332-
reportedEmptyModelResponse.delete(cacheKey)
333-
334-
if (!shouldSkipCache) {
335-
memoryCache.set(cacheKey, fetched)
336-
337-
await writeModels(cacheKey, fetched).catch((err) =>
338-
console.error(`[MODEL_CACHE] Error writing ${cacheKey} models to file cache:`, err),
339-
)
340-
}
341-
} else {
342-
captureModelCacheEmptyResponseOnce(provider, cacheKey, {
343-
context: "getModels",
344-
hasExistingCache: false,
345-
})
346-
}
313+
// even if it started (and thus reflects) an earlier, staler request. Sharing dedupedFetch()
314+
// means every caller for a given key -- get or refresh -- converges on one underlying
315+
// provider fetch. Each entry point still applies its own success/failure contract on top
316+
// (see below) rather than returning the shared promise directly, so a fetch failure that
317+
// refreshModels() degrades to cached data doesn't surface as a silent stale result to
318+
// getModels(), and a fetch failure joined from refreshModels() still re-throws for
319+
// getModels() callers.
320+
const sharedFetch = shouldSkipCache ? fetchModelsFromProvider(options) : dedupedFetch(cacheKey, options)
347321

348-
return fetched
349-
} catch (error) {
350-
// Log the error and re-throw it so the caller can handle it (e.g., show a UI message).
351-
console.error(`[getModels] Failed to fetch models in modelCache for ${provider}:`, error)
322+
try {
323+
const fetched = await sharedFetch
324+
const modelCount = Object.keys(fetched).length
325+
326+
// Only cache non-empty results so a failed API response doesn't get persisted
327+
// as if the provider had no models. Auth-scoped providers skip caching entirely.
328+
if (modelCount > 0) {
329+
// Clear the empty-response throttle for any non-empty response, including from
330+
// auth-scoped providers that skip caching, so a later empty response is reported again.
331+
reportedEmptyModelResponse.delete(cacheKey)
352332

353-
throw error // Re-throw the original error to be handled by the caller.
354-
} finally {
355333
if (!shouldSkipCache) {
356-
inFlightRefresh.delete(cacheKey)
334+
memoryCache.set(cacheKey, fetched)
335+
336+
await writeModels(cacheKey, fetched).catch((err) =>
337+
console.error(`[MODEL_CACHE] Error writing ${cacheKey} models to file cache:`, err),
338+
)
357339
}
340+
} else {
341+
captureModelCacheEmptyResponseOnce(provider, cacheKey, {
342+
context: "getModels",
343+
hasExistingCache: false,
344+
})
358345
}
359-
})()
360346

361-
if (!shouldSkipCache) {
362-
inFlightRefresh.set(cacheKey, fetchPromise)
347+
return fetched
348+
} catch (error) {
349+
// Log the error and re-throw it so the caller can handle it (e.g., show a UI message).
350+
console.error(`[getModels] Failed to fetch models in modelCache for ${provider}:`, error)
351+
352+
throw error // Re-throw the original error to be handled by the caller.
353+
}
354+
}
355+
356+
/**
357+
* Single-flight the raw provider fetch for a cache key across getModels() and refreshModels().
358+
* Callers apply their own caching/degradation/telemetry behavior on top of the resolved value
359+
* or rejection -- this only ensures at most one fetchModelsFromProvider() call is in flight per
360+
* cache key at a time.
361+
*/
362+
function dedupedFetch(cacheKey: string, options: GetModelsOptions): Promise<ModelRecord> {
363+
const existingRequest = inFlightRefresh.get(cacheKey)
364+
if (existingRequest) {
365+
return existingRequest
363366
}
364367

368+
const fetchPromise = fetchModelsFromProvider(options).finally(() => {
369+
inFlightRefresh.delete(cacheKey)
370+
})
371+
372+
inFlightRefresh.set(cacheKey, fetchPromise)
373+
365374
return fetchPromise
366375
}
367376

@@ -380,30 +389,20 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
380389

381390
const shouldSkipCache = isAuthScopedProvider(provider)
382391

383-
// Check if there's already an in-flight refresh for this provider+url combination.
384-
// This prevents race conditions where multiple concurrent refreshes might
385-
// overwrite each other's results. Skip de-duplication for auth-scoped
386-
// providers because two concurrent calls may carry different tokens
387-
// (e.g., after a sign-out/sign-in within the same session) and we must
388-
// not return the first caller's results to the second caller.
389-
if (!shouldSkipCache) {
390-
const existingRequest = inFlightRefresh.get(cacheKey)
391-
if (existingRequest) {
392-
return existingRequest
393-
}
394-
}
395-
396-
// Create the refresh promise and track it.
392+
// De-duplication is skipped for auth-scoped providers because two concurrent calls may
393+
// carry different tokens (e.g., after a sign-out/sign-in within the same session) and we
394+
// must not return the first caller's results to the second caller.
397395
//
398-
// The `finally` cleanup below runs only after the first `await` inside this async
399-
// function yields, which cannot happen until the current synchronous run -- including
400-
// the `inFlightRefresh.set(cacheKey, ...)` registration below -- has completed. So the
401-
// entry is always present in the map before `finally` can delete it; the registration
402-
// can never be lost to a microtask race even if the fetch resolves immediately.
396+
// Shares the same underlying fetch getModels() uses (see dedupedFetch) so a refreshModels()
397+
// call racing a getModels() cache-miss for the same key converges on one provider fetch --
398+
// but each function still applies its own success/failure contract on the result below
399+
// rather than sharing that promise's resolution/rejection wholesale.
400+
const sharedFetch = shouldSkipCache ? fetchModelsFromProvider(options) : dedupedFetch(cacheKey, options)
401+
403402
const refreshPromise = (async (): Promise<ModelRecord> => {
404403
try {
405404
// Force fresh API fetch - skip getModelsFromCache() check
406-
const models = await fetchModelsFromProvider(options)
405+
const models = await sharedFetch
407406
const modelCount = Object.keys(models).length
408407

409408
// Get existing cached data for comparison
@@ -443,19 +442,9 @@ export const refreshModels = async (options: GetModelsOptions): Promise<ModelRec
443442
return {}
444443
}
445444
return getModelsFromCache(options) || {}
446-
} finally {
447-
// Always clean up the in-flight tracking
448-
if (!shouldSkipCache) {
449-
inFlightRefresh.delete(cacheKey)
450-
}
451445
}
452446
})()
453447

454-
// Track the in-flight request (auth-scoped providers are excluded; see above).
455-
if (!shouldSkipCache) {
456-
inFlightRefresh.set(cacheKey, refreshPromise)
457-
}
458-
459448
return refreshPromise
460449
}
461450

0 commit comments

Comments
 (0)