Skip to content

fix(google-gemini): two HIGH-severity cache-checkpoint error-handling fixes (follow-up to #641)#643

Open
sroussey wants to merge 2 commits into
ai-provider-cache-checkpointsfrom
claude/beautiful-mayer-j6vmqi
Open

fix(google-gemini): two HIGH-severity cache-checkpoint error-handling fixes (follow-up to #641)#643
sroussey wants to merge 2 commits into
ai-provider-cache-checkpointsfrom
claude/beautiful-mayer-j6vmqi

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

Two HIGH-severity fixes to the Gemini cache-checkpoint error-handling paths introduced in #641.

H1 — Narrow the warm-up catch (Gemini_CacheCheckpoint). The warm-up wrapped ai.caches.create in a blanket try/catch that logged and swallowed every error, so a caller-initiated abort, a 401/403 auth failure, a 429 quota, and a 5xx transport error all silently degraded to the same "no cache, replay inline" fate as a genuine prefix-too-small 400. Introduce classifyGeminiCacheError (abort / degrade / throw): abort and throw rethrow with a best-effort delete of any resource whose name was returned before the failure; only a 400 / INVALID_ARGUMENT whose message names the too-small-prefix / unsupported condition degrades to inline replay. Gemini_CacheStore gets a matching createdAtMs on each entry, an isGeminiCacheEntryStale helper (default 3_500_000 ms — a hair under the 1h write-time TTL), and a deleteGeminiCachedContentLocal for runtime-only eviction, so H2 can chain on top.

H2 — Fallback in the two consume paths (Gemini_TextGeneration, Gemini_ToolCalling). The consumers treated the cache handle as guaranteed to resolve: a near-TTL entry still went out with cachedContent, and a NOT_FOUND surfaced as a hard failure to the caller. Extract the inline-replay branch into a named local buildInlineReplayRequest in each run-fn and route both requests through a new Gemini_CachedContentFallback.generateGeminiStreamWithCacheFallback. It runs a proactive stale check up front (evicts the runtime-local entry, leaves the server-side to its own TTL) and, on a reactive 404 / NOT_FOUND, evicts (locally + best-effort server delete), rebuilds inline, and retries once. The streaming contract is unchanged — providers still yield text-delta / object-delta and a final empty finish; the consumer accumulates.

Test plan

  • bun test packages/test/src/test/ai-provider/OpenAIGeminiCheckpointParams.test.ts — 24 tests green, adds 7 new cases (H1-abort, H1-degrade-preserved, H1-throws-429, H1-partial-delete; H2-NOT_FOUND-Text, H2-NOT_FOUND-ToolCalling, H2-proactive-stale) via the existing fakeGeminiClient seam.
  • bun test packages/test/src/test/ai-provider/Gemini_SessionDispose.test.ts — no regressions.
  • bun test packages/test/src/test/ai/CacheCheckpoint.test.ts — no regressions.
  • bun run build:types — clean across all packages.

Base

Stacks on #641 (like #642) — targets ai-provider-cache-checkpoints, not main. Merge #641 first; this branch rebases onto the resulting main state.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UC9LGu3iokhuhAB46yt1ee


Generated by Claude Code

claude added 2 commits July 19, 2026 08:34
The Gemini cache-checkpoint warm-up wrapped ai.caches.create in a blanket
try/catch that logged and swallowed every error, so a caller-initiated abort,
a 401/403 auth failure, a 429 quota, and a 5xx transport error all silently
degraded to the same "no cache, replay inline" fate as a genuine prefix-too-
small 400. It also failed to clean up when a resource had been minted but a
downstream step (bookkeeping / write) threw, leaving a server-side CachedContent
billing until TTL.

Classify the error into abort / degrade / throw. Abort and throw rethrow (with
a best-effort delete of any resource whose name we saw); only a 400 /
INVALID_ARGUMENT whose message names the too-small-prefix / cache-unsupported
condition degrades to inline replay. Track `createdName` across the try / catch
so the partial-delete cleanup can fire.

Gemini_CacheStore gets a matching `createdAtMs` field on each entry, an
`isGeminiCacheEntryStale` helper (default 3_500_000 ms — a hair under the 1h
TTL used at write time), and a `deleteGeminiCachedContentLocal` for
runtime-only eviction. These land here so PR #641's follow-up (H2, the
consume-side fallback) can chain on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UC9LGu3iokhuhAB46yt1ee
…achedContent

Gemini's explicit CachedContent is TTL-bound (~1h at creation) and can also
vanish server-side outside the consumer's control. The text.generation and
tool-calling run-fns treated the cache handle as guaranteed to resolve: an
entry near-TTL still went out with `cachedContent`, and a NOT_FOUND surfaced
as a hard failure to the caller.

Add Gemini_CachedContentFallback: a proactive stale check (evicts the
runtime-local entry before the request, leaves the server-side to its own TTL)
plus a reactive NOT_FOUND catch that evicts (locally + best-effort server
delete), rebuilds the request inline (prefix + tail), and retries once.
Refactor both run-fns to share `buildCachedRequest` / `buildInlineReplayRequest`
locals so the retry rebuilds an identical shape. Streaming contract unchanged
(providers still yield text-delta/object-delta then finish; no accumulation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UC9LGu3iokhuhAB46yt1ee
@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.27% 27976 / 44925
🔵 Statements 62.12% 28956 / 46608
🔵 Functions 63.3% 5322 / 8407
🔵 Branches 50.62% 13953 / 27561
File CoverageNo changed files found.
Generated in workflow #2748 for commit 04fd0b9 by the Vitest Coverage Report Action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants