diff --git a/runtime/index.js b/runtime/index.js index 7329bc8..ae69a1a 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -101,19 +101,30 @@ export function createCatalogClient(options = {}) { if (!force && now() < expiresAt) return Promise.resolve(current()); inflight = (async () => { try { - let nextSnapshot = snapshot; - let nextEtag = etag; - if (options.catalogUrl) { - const result = await json(options.catalogUrl, etag ? { 'If-None-Match': etag } : {}); - if (!result.unchanged) { nextSnapshot = validateSnapshot(result.body); nextEtag = result.etag; } - } - const result = await json(options.gatewayUrl ?? gateways[network]); - if (result.unchanged) throw new Error('Unexpected gateway 304'); - const nextModels = validateModels(result.body?.data).filter(m => m.available !== false); + // Policy and gateway rows are fetched concurrently and independently: + // an unreachable policy host must not cost clients the live gateway + // catalog, it only keeps the last good policy. + const [policy, gateway] = await Promise.allSettled([ + options.catalogUrl + ? json(options.catalogUrl, etag ? { 'If-None-Match': etag } : {}) + .then(result => result.unchanged ? { snapshot, etag } : { snapshot: validateSnapshot(result.body), etag: result.etag }) + : Promise.resolve({ snapshot, etag }), + json(options.gatewayUrl ?? gateways[network]), + ]); + if (gateway.status === 'rejected') throw gateway.reason; + if (gateway.value.unchanged) throw new Error('Unexpected gateway 304'); + const nextModels = validateModels(gateway.value.body?.data).filter(m => m.available !== false); if (!nextModels.length) throw new Error('No available models'); - snapshot = nextSnapshot; models = nextModels; etag = nextEtag; - source = 'live'; lastError = undefined; - expiresAt = now() + (options.ttlMs ?? 300000); + models = nextModels; source = 'live'; + if (policy.status === 'fulfilled') { + snapshot = policy.value.snapshot; etag = policy.value.etag; + lastError = undefined; + expiresAt = now() + (options.ttlMs ?? 300000); + } else { + const reason = policy.reason; + lastError = `Catalog policy: ${reason instanceof Error ? reason.message : String(reason)}`; + expiresAt = now() + Math.min(options.ttlMs ?? 300000, 15000); + } } catch (error) { lastError = error instanceof Error ? error.message : String(error); // Short backoff on errors, retaining the last good snapshot. diff --git a/test/runtime.test.mjs b/test/runtime.test.mjs index 64a8d6c..258ea10 100644 --- a/test/runtime.test.mjs +++ b/test/runtime.test.mjs @@ -78,6 +78,39 @@ test('snapshot updates are atomic and use ETag', async () => { failGateway = false; assert.equal((await client.refresh({ force: true })).version, '2026.09.29.2'); }); +test('unreachable policy host still refreshes gateway rows and keeps last good policy', async () => { + let policyDown = false, clock = 0; + const id = snapshot.picker_policy.views.default_chat.model_ids[0]; + const client = createCatalogClient({ ttlMs: 60000, now: () => clock, catalogUrl: 'https://catalog.test/snapshot.json', fetch: async url => { + if (String(url).includes('catalog.test')) { + if (policyDown) throw Error('fetch failed'); + const next = structuredClone(snapshot); + next.picker_policy.views.default_chat.shortcuts.newalias = id; + return new Response(JSON.stringify(next), { headers: { etag: 'v1' } }); + } + return response([row(id), row(policyDown ? 'test/after-outage' : 'test/before-outage')]); + } }); + assert.equal((await client.refresh()).shortcuts.newalias, id); + policyDown = true; + const state = await client.refresh({ force: true }); + assert.equal(state.source, 'live'); + assert.match(state.lastError, /^Catalog policy: fetch failed/); + assert.ok(state.models.some(m => m.id === 'test/after-outage'), 'gateway rows still refresh'); + assert.equal(state.shortcuts.newalias, id, 'last good policy retained'); + clock = 15001; policyDown = false; + const recovered = await client.refresh(); + assert.equal(recovered.lastError, undefined, 'policy retried on short backoff'); +}); +test('cold start with unreachable policy host uses bundled policy with live gateway rows', async () => { + const client = createCatalogClient({ catalogUrl: 'https://catalog.test/snapshot.json', fetch: async url => { + if (String(url).includes('catalog.test')) throw Error('blocked'); + return response([row()]); + } }); + const state = await client.refresh(); + assert.equal(state.source, 'live'); + assert.equal(state.version, snapshot.catalog.catalog_version); + assert.deepEqual(state.models.map(m => m.id), ['test/new-model']); +}); test('media entries survive for media clients but never enter chat picker', async () => { const client = createCatalogClient({ fetch: async () => response([row('test/image', { categories: ['image'], billing_mode: 'per_image', pricing: { per_image: 0.1 } }), row()]) }); const state = await client.refresh();