chat-gemini: preserve the typed SDK error chain so cross-provider failover works¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- DRAFT — pending review
- Related
- architectural review (chat-family section, HIGH finding),
chat-openai request isolation (sibling provider-conformance fix from the same review),
the chat extraction (
pkg/chat→go/chat+ provider modules) that introduced the SDK-free status-extractor registry this defect defeats
1. Problem¶
The go/chat core is deliberately SDK-free: cross-provider failover classifies
vendor errors through registered HTTPStatusExtractor functions
(chat/fallback_policy.go:93-110). Each provider module registers one in init();
chat-gemini registers geminiHTTPStatus (chat-gemini/gemini.go:18, 29-36),
which does errors.As(err, &apiErr) against *genai.APIError.
That extractor can never fire for Chat or StreamChat, because every error on
those paths routes through handleGeminiError
(chat-gemini/gemini.go:338-345, called at gemini.go:313 and gemini.go:493):
func (g *Gemini) handleGeminiError(err error, step int) error {
var apiErr *genai.APIError
if errors.As(err, &apiErr) {
return errors.Newf("Gemini API Error (%d): %s", apiErr.Code, apiErr.Message)
}
return errors.Newf("gemini send message failed (step %d): %v", step, err)
}
Neither branch uses %w (the second flattens with %v), so the returned error is
a new error whose chain no longer contains the typed SDK error — or anything
else from the original. Two core mechanisms are defeated at once:
errors.As(err, &apiErr)ingeminiHTTPStatusfinds no*genai.APIError→providerHTTPStatusreports no status.errors.Is(err, context.DeadlineExceeded)inDefaultFailoverPolicy(chat/fallback_policy.go:73) never matches a flattened per-request timeout.
Concrete failure scenario. A tool configured with the fallback chain
[gemini, claude] calls Chat and Gemini returns a 429 (rate limit) or 503.
DefaultFailoverPolicy.Classify (chat/fallback_policy.go:45-91) finds no HTTP
status, no deadline error, no net error → falls through to FailoverFatal
(fallback_policy.go:90) → the composite returns the error to the caller instead
of failing over to Claude. The entire point of configuring a fallback chain is
silently void for Gemini's primary call paths.
Only Ask classifies correctly, because it wraps with %w directly
(gemini.go:231-234). The other providers preserve the chain on all paths:
Anthropic wraps with %w / errors.Wrap (chat-anthropic/claude.go:196, 357,
550), and OpenAI either returns the SDK error untouched
(chat-openai/openai.go:613) or wraps it (openai.go:246, 471). The
provider-status extractors for both are exercised by tests against wrapped errors
(chat-openai/openai_status_internal_test.go:31); Gemini's defect is exactly the
case those tests guard against, but on the producing side.
2. Proposed change¶
Make handleGeminiError wrap rather than rebuild, preserving the chain on
both branches while keeping the current message text:
- API branch:
errors.Wrapf(err, "Gemini API Error (%d): %s", apiErr.Code, apiErr.Message)(orerrors.Newf("...: %w", ...)— match the module's prevailing style, which iserrors.Newfwith%w, e.g.gemini.go:228,233). - Fallback branch: replace
%vwith%w:errors.Newf("gemini send message failed (step %d): %w", step, err).
Add unit tests in chat-gemini mirroring openai_status_internal_test.go:
geminiHTTPStatus must extract the status from the exact error value
handleGeminiError returns (both branches), not just from a raw
*genai.APIError.
Follow-on, not in scope here: the review recommends a shared
provider-conformance test suite in the go/chat core that provider modules run in
CI. "The error returned by Chat/StreamChat/Ask must satisfy the module's
registered status extractor for a synthetic provider HTTP error" belongs in that
suite so this class of regression is caught for every provider, including future
ones. This spec adds the Gemini-local regression tests only; the conformance suite
is tracked as its own piece of work (see also the sibling
chat-openai request isolation spec,
which feeds the same suite).
3. Scope & release plan¶
- Module:
gitlab.com/phpboyscout/go/chat-geminionly. No API change — the fix is internal tohandleGeminiErrorplus tests. No core (go/chat) change. - Release:
fix(gemini):patch via releaser-pleaser in thechat-geminirepo. The chat family's matching-minors compatibility contract is unaffected (patch release; core stays put). - GTB: picks the fix up via a routine
go.modbump ofchat-gemini(Renovate or manual); no GTB code change.
4. Acceptance criteria¶
handleGeminiErrorpreserves the error chain on both branches: for any inputerr,errors.Is(handleGeminiError(err, n), err)holds, and a wrapped*genai.APIErroris recoverable viaerrors.As.geminiHTTPStatusunit test: givenhandleGeminiErrorapplied to a*genai.APIError{Code: 429}, the extractor returns(429, true).- Failover regression test (in
chat-gemini, using the core's fallback composite orDefaultFailoverPolicydirectly): a GeminiChaterror carrying a 429/503*genai.APIErrorclassifies asFailoverNext; a 400 classifies asFailoverFatal; a wrappedcontext.DeadlineExceededclassifies asFailoverNext. - User-facing error text is unchanged in substance (message prefix and code/step detail retained).
- Module CI green (tests, race, lint); coverage on the touched file does not regress.
5. Open questions¶
None.