diff --git a/packages/core/src/cachekeep.ts b/packages/core/src/cachekeep.ts index 3d9621ad..8a88a007 100644 --- a/packages/core/src/cachekeep.ts +++ b/packages/core/src/cachekeep.ts @@ -339,7 +339,7 @@ export type CacheKeepPrewarmResult = cache_read_input_tokens?: number } } - | { ok: false; reason: string; status?: number } + | { ok: false; reason: string; status?: number; transient?: true } export class CacheKeepManager { private readonly targets = new Map() @@ -576,7 +576,15 @@ export class CacheKeepManager { const dueAt = now + CACHE_KEEP_PREWARM_LEAD_MS for (const target of this.targets.values()) { if (target.cacheExpiresAt > dueAt) continue - await this.prewarm(target, now) + try { + await this.prewarm(target, now) + } catch (error) { + logger.warn('cachekeep', 'prewarm failed', { + session: target.id, + reason: error instanceof Error ? error.message : String(error), + }) + target.cacheExpiresAt = now + CACHE_KEEP_PREWARM_LEAD_MS + 5 * 60_000 + } } this.publishTrackedSessions() } @@ -597,14 +605,23 @@ export class CacheKeepManager { : new Headers(target.headers) headers.delete('content-length') headers.delete('transfer-encoding') - const response = await fetchImpl(target.url, { - method: 'POST', - headers, - body: prewarm.bodyText, - signal: AbortSignal.timeout( - this.options.prewarmTimeoutMs ?? CACHE_KEEP_PREWARM_TIMEOUT_MS, - ), - }) + let response: Response + try { + response = await fetchImpl(target.url, { + method: 'POST', + headers, + body: prewarm.bodyText, + signal: AbortSignal.timeout( + this.options.prewarmTimeoutMs ?? CACHE_KEEP_PREWARM_TIMEOUT_MS, + ), + }) + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + transient: true, + } + } if (!response.ok) { return { ok: false, @@ -632,7 +649,7 @@ export class CacheKeepManager { private async prewarm(target: CacheKeepTarget, now: number) { const result = await this.sendPrewarm(target) if (!result.ok) { - if (result.status == null) { + if (result.status == null && !result.transient) { logger.debug('cachekeep', 'prewarm skipped', { session: target.id, reason: result.reason, diff --git a/packages/opencode/src/tests/cachekeep.test.ts b/packages/opencode/src/tests/cachekeep.test.ts index a350dad8..3d0de358 100644 --- a/packages/opencode/src/tests/cachekeep.test.ts +++ b/packages/opencode/src/tests/cachekeep.test.ts @@ -314,7 +314,7 @@ describe('CacheKeepManager', () => { prewarmTimeoutMs: 5, }) - const result = manager.prewarmNow({ + const result = await manager.prewarmNow({ sessionId: 'ses_timeout', url: 'https://api.anthropic.com/v1/messages?beta=true', headers: new Headers(), @@ -330,7 +330,10 @@ describe('CacheKeepManager', () => { }), }) - await expect(result).rejects.toMatchObject({ name: 'TimeoutError' }) + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected prewarm to fail') + expect(result.transient).toBe(true) + expect(result.reason).toContain('timed out') }) test('passes OAuth account identity to prewarm header refresh', async () => { @@ -651,4 +654,98 @@ describe('CacheKeepManager', () => { expect(fetchImpl).toHaveBeenCalledTimes(1) manager.stop() }) + + test('contains thrown prewarm errors and continues with later targets', async () => { + let now = new Date('2026-05-18T10:00:00').getTime() + const attempts: string[] = [] + let publishCount = 0 + const timeout = new Error('The operation timed out.') + timeout.name = 'TimeoutError' + const fetchImpl = mock((input: string | URL | Request) => { + const url = String(input) + attempts.push(url) + if (url.endsWith('/A')) throw timeout + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const manager = new CacheKeepManager({ + loadStorage: () => Promise.resolve(hybridStorage()), + fetchImpl, + now: () => now, + onTrackedSessionsChanged: () => { + publishCount++ + }, + }) + const body = JSON.stringify({ + system: [ + { type: 'text', text: 'stable', cache_control: { type: 'ephemeral' } }, + ], + messages: [{ role: 'user', content: 'hello' }], + }) + + for (const id of ['A', 'B', 'C']) { + await manager.track({ + sessionId: `ses_${id}`, + url: `https://api.anthropic.com/v1/messages/${id}`, + headers: new Headers(), + bodyText: body, + storage: hybridStorage(), + cacheMode: 'hybrid', + }) + } + + const publishedBeforeTick = publishCount + now += 55 * 60_000 + await manager.tick() + + expect(attempts).toEqual([ + 'https://api.anthropic.com/v1/messages/A', + 'https://api.anthropic.com/v1/messages/B', + 'https://api.anthropic.com/v1/messages/C', + ]) + expect(manager.trackedSessions().map((session) => session.id)).toEqual([ + 'ses_A', + 'ses_B', + 'ses_C', + ]) + expect( + manager.trackedSessions().find((session) => session.id === 'ses_A') + ?.cacheExpiresAt, + ).toBe(now + 10 * 60_000) + expect(publishCount).toBeGreaterThan(publishedBeforeTick) + + attempts.length = 0 + now += 60_000 + await manager.tick() + expect(attempts).toEqual([]) + manager.stop() + }) + + test('deletes a tracked target when its prewarm body is unbuildable', async () => { + let now = new Date('2026-05-18T10:00:00').getTime() + const fetchImpl = mock(() => + Promise.resolve(new Response('{}', { status: 200 })), + ) as unknown as typeof fetch + const manager = new CacheKeepManager({ + loadStorage: () => Promise.resolve(hybridStorage()), + fetchImpl, + now: () => now, + }) + + await manager.track({ + sessionId: 'ses_unbuildable', + url: 'https://api.anthropic.com/v1/messages/unbuildable', + headers: new Headers(), + bodyText: JSON.stringify({ + messages: [{ role: 'user', content: 'hello' }], + }), + storage: hybridStorage(), + cacheMode: 'hybrid', + }) + + now += 55 * 60_000 + await manager.tick() + expect(fetchImpl).not.toHaveBeenCalled() + expect(manager.trackedCount()).toBe(0) + manager.stop() + }) })