From 549ea427fde99b23bc6a2511d5384d7f135cd032 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:02:25 -0300 Subject: [PATCH] feat(sse): parse Gemini CLI 429 retryDelay from structured RetryInfo (#4738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. --- open-sse/executors/gemini-cli.ts | 37 +++++++++++++++++- tests/unit/executor-gemini-cli.test.ts | 52 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index e8caba72ea..979d157aa1 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -534,11 +534,18 @@ export class GeminiCLIExecutor extends BaseExecutor { } } - // Parse retry time from Gemini error message body - // Format: "Your quota will reset after 2h7m23s" + // Parse retry time from Gemini error message body. Two shapes are handled: + // 1. Structured google.rpc.RetryInfo in the 429/503 JSON body: + // { error: { details: [{ "@type": ".../google.rpc.RetryInfo", retryDelay: "30s" }] } } + // 2. Human-readable prose: "Your quota will reset after 2h7m23s" + // The structured hint is authoritative (it is what the Cloud Code Assist API + // sends), so it is checked first and falls through to the prose form on miss. parseRetryFromErrorMessage(errorMessage: unknown): number | null { if (!errorMessage || typeof errorMessage !== "string") return null; + const structuredMs = this.parseStructuredRetryDelay(errorMessage); + if (structuredMs !== null) return structuredMs; + const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i); if (!match) return null; @@ -549,6 +556,32 @@ export class GeminiCLIExecutor extends BaseExecutor { return totalMs || 2_000; } + + // Read google.rpc.RetryInfo.retryDelay from a Google API error JSON body and + // convert the protobuf Duration string ("30s", "1.5s", "0.500s") into ms. + // Returns null when the body is not JSON or carries no RetryInfo detail. + private parseStructuredRetryDelay(bodyText: string): number | null { + if (!bodyText.includes("RetryInfo")) return null; + try { + const parsed = JSON.parse(bodyText); + const details = parsed?.error?.details; + if (!Array.isArray(details)) return null; + for (const detail of details) { + if ( + detail?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo" && + typeof detail?.retryDelay === "string" + ) { + const seconds = parseFloat(detail.retryDelay.replace(/s$/i, "")); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.round(seconds * 1000) || 2_000; + } + } + } + } catch { + /* not JSON — caller falls back to prose parsing */ + } + return null; + } } export default GeminiCLIExecutor; diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index 9a122bf33e..9159129ba2 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -473,3 +473,55 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code globalThis.fetch = originalFetch; } }); + +test("GeminiCLIExecutor.parseRetryFromErrorMessage reads structured google.rpc.RetryInfo.retryDelay", () => { + const executor = new GeminiCLIExecutor(); + + const body = JSON.stringify({ + error: { + code: 429, + message: "Resource has been exhausted (e.g. check quota).", + status: "RESOURCE_EXHAUSTED", + details: [ + { "@type": "type.googleapis.com/google.rpc.QuotaFailure", violations: [] }, + { "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "30s" }, + ], + }, + }); + + // The prose regex would not match this JSON body, so a positive result proves + // the structured RetryInfo path is what produced the hint. + assert.equal(executor.parseRetryFromErrorMessage(body), 30_000); +}); + +test("GeminiCLIExecutor.parseRetryFromErrorMessage parses fractional retryDelay seconds", () => { + const executor = new GeminiCLIExecutor(); + + const body = JSON.stringify({ + error: { + details: [ + { "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "1.5s" }, + ], + }, + }); + + assert.equal(executor.parseRetryFromErrorMessage(body), 1_500); +}); + +test("GeminiCLIExecutor.parseRetryFromErrorMessage still parses prose 'reset after' format", () => { + const executor = new GeminiCLIExecutor(); + + assert.equal( + executor.parseRetryFromErrorMessage("Your quota will reset after 1h2m3s"), + 1 * 3600 * 1000 + 2 * 60 * 1000 + 3 * 1000 + ); +}); + +test("GeminiCLIExecutor.parseRetryFromErrorMessage returns null for unrelated JSON", () => { + const executor = new GeminiCLIExecutor(); + + assert.equal( + executor.parseRetryFromErrorMessage(JSON.stringify({ error: { message: "nope" } })), + null + ); +});