mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
fix(models): invalidate stale requests when clearing cache
This commit is contained in:
@@ -25,6 +25,8 @@ interface ModelsState {
|
||||
isCacheValid: (apiBase: string, apiKey?: string) => boolean;
|
||||
}
|
||||
|
||||
let modelsRequestToken = 0;
|
||||
|
||||
export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
models: [],
|
||||
loading: false,
|
||||
@@ -32,12 +34,13 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
cache: null,
|
||||
|
||||
fetchModels: async (apiBase, apiKey, forceRefresh = false) => {
|
||||
const requestId = (modelsRequestToken += 1);
|
||||
const { cache, isCacheValid } = get();
|
||||
const apiKeyScope = apiKey?.trim() || '';
|
||||
|
||||
// 检查缓存
|
||||
if (!forceRefresh && isCacheValid(apiBase, apiKeyScope) && cache) {
|
||||
set({ models: cache.data, error: null });
|
||||
set({ models: cache.data, loading: false, error: null });
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
@@ -45,6 +48,8 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(apiBase, apiKeyScope || undefined);
|
||||
// 清空缓存或后续加载已接管状态时,旧请求仅向调用方返回,不再写入 store。
|
||||
if (requestId !== modelsRequestToken) return list;
|
||||
const now = Date.now();
|
||||
|
||||
set({
|
||||
@@ -55,6 +60,7 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
|
||||
return list;
|
||||
} catch (error: unknown) {
|
||||
if (requestId !== modelsRequestToken) throw error;
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@@ -71,7 +77,8 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearCache: () => {
|
||||
set({ cache: null, models: [] });
|
||||
modelsRequestToken += 1;
|
||||
set({ cache: null, models: [], loading: false, error: null });
|
||||
},
|
||||
|
||||
isCacheValid: (apiBase, apiKey) => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import { modelsApi } from '../src/services/api/models';
|
||||
import { useModelsStore } from '../src/stores/useModelsStore';
|
||||
import type { ModelInfo } from '../src/utils/models';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (models: ModelInfo[]) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<ModelInfo[]>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const originalFetch = modelsApi.fetchModels;
|
||||
afterEach(() => {
|
||||
modelsApi.fetchModels = originalFetch;
|
||||
useModelsStore.getState().clearCache();
|
||||
});
|
||||
|
||||
describe('models cache request isolation', () => {
|
||||
it('does not restore models or connection cache after clearing an in-flight request', async () => {
|
||||
const request = deferred();
|
||||
spyOn(modelsApi, 'fetchModels').mockReturnValue(request.promise);
|
||||
const pending = useModelsStore.getState().fetchModels('https://old.invalid');
|
||||
useModelsStore.getState().clearCache();
|
||||
expect(useModelsStore.getState().loading).toBe(false);
|
||||
request.resolve([{ name: 'old' }]);
|
||||
expect(await pending).toEqual([{ name: 'old' }]);
|
||||
expect(useModelsStore.getState()).toMatchObject({
|
||||
models: [],
|
||||
cache: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale failures without clearing a newer request loading state', async () => {
|
||||
const old = deferred();
|
||||
const current = deferred();
|
||||
spyOn(modelsApi, 'fetchModels')
|
||||
.mockReturnValueOnce(old.promise)
|
||||
.mockReturnValueOnce(current.promise);
|
||||
const pending = useModelsStore.getState().fetchModels('https://old.invalid');
|
||||
useModelsStore.getState().clearCache();
|
||||
const next = useModelsStore.getState().fetchModels('https://new.invalid');
|
||||
old.reject(new Error('old failure'));
|
||||
await expect(pending).rejects.toThrow('old failure');
|
||||
expect(useModelsStore.getState()).toMatchObject({ loading: true, error: null });
|
||||
current.resolve([{ name: 'new' }]);
|
||||
await next;
|
||||
expect(useModelsStore.getState().cache?.apiBase).toBe('https://new.invalid');
|
||||
});
|
||||
|
||||
it('allows only the latest request to publish, even without an explicit clear', async () => {
|
||||
const old = deferred();
|
||||
const current = deferred();
|
||||
spyOn(modelsApi, 'fetchModels')
|
||||
.mockReturnValueOnce(old.promise)
|
||||
.mockReturnValueOnce(current.promise);
|
||||
const pending = useModelsStore.getState().fetchModels('https://old.invalid');
|
||||
const next = useModelsStore.getState().fetchModels('https://new.invalid');
|
||||
current.resolve([{ name: 'new' }]);
|
||||
await next;
|
||||
old.resolve([{ name: 'old' }]);
|
||||
await pending;
|
||||
expect(useModelsStore.getState().models).toEqual([{ name: 'new' }]);
|
||||
});
|
||||
|
||||
it('a cache hit supersedes an in-flight refresh and resets loading', async () => {
|
||||
const request = deferred();
|
||||
spyOn(modelsApi, 'fetchModels')
|
||||
.mockResolvedValueOnce([{ name: 'cached' }])
|
||||
.mockReturnValueOnce(request.promise);
|
||||
await useModelsStore.getState().fetchModels('https://cache.invalid', ' test-key ');
|
||||
const pending = useModelsStore
|
||||
.getState()
|
||||
.fetchModels('https://cache.invalid', 'test-key', true);
|
||||
await useModelsStore.getState().fetchModels('https://cache.invalid', 'test-key');
|
||||
expect(useModelsStore.getState().loading).toBe(false);
|
||||
request.resolve([{ name: 'stale refresh' }]);
|
||||
await pending;
|
||||
expect(useModelsStore.getState().models).toEqual([{ name: 'cached' }]);
|
||||
});
|
||||
|
||||
it('reports active failures, then clears error and loading on invalidation', async () => {
|
||||
spyOn(modelsApi, 'fetchModels').mockRejectedValue(new Error('active failure'));
|
||||
await expect(useModelsStore.getState().fetchModels('https://test.invalid')).rejects.toThrow();
|
||||
expect(useModelsStore.getState().error).toBe('active failure');
|
||||
useModelsStore.getState().clearCache();
|
||||
expect(useModelsStore.getState()).toMatchObject({ error: null, loading: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user