Repository navigation
fix(google): honor Retry-After floor and recognize the Cloud Code Assist ToS account ban #7819
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3aa487c
33c1f74
9901a7f
90d285e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -248,6 +248,61 @@ test("Google structured account refusals classify without accepting model permis | |
| expect(classifyPermanentAccountRefusal(403, { error: { code: 403, status: "PERMISSION_DENIED", message: "This model is not available in this project" } })).toBeUndefined(); | ||
| }); | ||
|
|
||
| // #7803: the Cloud Code Assist account-ban messages Google's backend returned in | ||
| // google-gemini/gemini-cli #19501 (with its line wrap), #20813/#27388 and #27199. | ||
| const ccaTosDisabled = "This service has been disabled in this account for violation of Terms of Service."; | ||
| // Google's published feedback address, assembled so the privacy scan does not read it as personal data. | ||
| const ccaFeedbackAddress = ["gemini-code-assist-user-feedback", "google.com"].join("@"); | ||
| const ccaTosContact = `This service has been disabled in this account for violation of Terms of\n\t\tService. If you believe this is an error, contact ${ccaFeedbackAddress}.`; | ||
| const ccaTosAppeal = "This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product."; | ||
| const googleEnvelope = (message: string, extra: Record<string, unknown> = {}) => | ||
| ({ error: { code: 403, status: "PERMISSION_DENIED", message, ...extra } }); | ||
|
|
||
| test("the Cloud Code Assist Terms of Service account ban classifies as an account refusal", () => { | ||
| for (const message of [ccaTosDisabled, ccaTosContact, ccaTosAppeal, ` ${ccaTosAppeal.replace(/ /g, "\n ")} `]) { | ||
| expect(classifyPermanentAccountRefusal(403, googleEnvelope(message))).toBe("account_permission_denied"); | ||
| } | ||
| }); | ||
|
|
||
| test("project, model and partial Google refusals never classify as an account ban", () => { | ||
| for (const message of [ | ||
| `Request failed: ${ccaTosDisabled}`, | ||
| `Eligibility check failed: PERMISSION_DENIED (code 403): ${ccaTosAppeal}`, | ||
| `${ccaTosDisabled} If you believe this is an error, contact someone-else@example.test.`, | ||
| `${ccaTosAppeal} Model gemini-3-pro is not available.`, | ||
| `"${ccaTosDisabled}"`, | ||
| "This service has been disabled in this account.", | ||
| "This service has been disabled in this project for violation of Terms of Service.", | ||
| "Permission denied: Consumer 'projects/123456' has been suspended.", | ||
| "Cloud Code Private API has not been used in project 123456 before or it is disabled.", | ||
| "Access to this model is denied.", | ||
| ]) expect(classifyPermanentAccountRefusal(403, googleEnvelope(message))).toBeUndefined(); | ||
| expect(classifyPermanentAccountRefusal(403, googleEnvelope("Permission denied: Consumer 'projects/123456' has been suspended.", | ||
| { details: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "CONSUMER_SUSPENDED" }] }))).toBeUndefined(); | ||
| // The envelope must be the consistent 403 form Google returned; other statuses and codes do not qualify. | ||
| expect(classifyPermanentAccountRefusal(401, { error: { code: 401, status: "UNAUTHENTICATED", message: ccaTosDisabled } })).toBeUndefined(); | ||
| expect(classifyPermanentAccountRefusal(403, { error: { status: "PERMISSION_DENIED", message: ccaTosDisabled } })).toBeUndefined(); | ||
| expect(classifyPermanentAccountRefusal(403, { error: { code: 401, status: "PERMISSION_DENIED", message: ccaTosDisabled } })).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("an Antigravity Terms of Service ban pauses the sending account through normalization", async () => { | ||
| const { fetchGoogleWithRetry } = await import("../../src/adapters/google-http"); | ||
| const { wasPermanentAccountRefusal } = await import("../../src/oauth/account-auto-pause"); | ||
| const config = configFor("fixture", "key"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add coverage for the OAuth account path. This test uses key authentication, so it does not exercise OAuth snapshot ownership or OAuth account persistence. Those use a separate branch in Use the existing OAuth fixture to send the qualifying response through normalization. Assert that the sending OAuth account is paused after the threshold and that its stored reason is
|
||
| config.providers.fixture!.apiKey = "synthetic-A"; | ||
| config.providers.fixture!.accountAutoPause!.consecutiveFailures = 1; | ||
| saveConfig(config); | ||
| const provider = routedProviderConfig("fixture", config.providers.fixture!); | ||
| const headers = { authorization: `Bearer ${provider.apiKey}` }; | ||
| const observe = captureAccountAutoPauseAttempt("fixture", provider, { config }, new Headers(headers))!; | ||
| const response = await fetchGoogleWithRetry("Antigravity", { url: "https://example.test", method: "POST", headers, body: "{}" }, { | ||
| executor: (async () => observe(Response.json(googleEnvelope(ccaTosContact), { status: 403 }))) as typeof fetch, | ||
| }); | ||
| expect(response.status).toBe(403); | ||
| expect(wasPermanentAccountRefusal(response)).toBe(true); | ||
| expect(wasAccountAutoPaused(response)).toBe(true); | ||
| }); | ||
|
|
||
| test("Antigravity normalization retains permanent refusal and auto-pause ownership", async () => { | ||
| const { fetchGoogleWithRetry } = await import("../../src/adapters/google-http"); | ||
| const { wasPermanentAccountRefusal } = await import("../../src/oauth/account-auto-pause"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: lidge-jun/opencodex
Length of output: 9104
Script executed:
Repository: lidge-jun/opencodex
Length of output: 30270
Cancel the discarded response before the Retry-After wait.
For a streaming Google response,
pendingResponseremains unread whilesleepWithAbortwaits.beforeDispatchcancels it only after the wait. Move the best-effort cancellation before the sleep so the body is released during the delay.An abort during the wait skips the explicit
cancelResponseBodyBestEffortcall, although the fetch signal also cancels response consumption on abort. The previous short-backoff path had the same retention order; the new Retry-After floor extends the possible retention to 60 seconds.Suggested fix
beforeDispatch: async () => { - if (retryDelayMs > 0) await sleepWithAbort(retryDelayMs, ctx.abortSignal); if (pendingResponse) cancelResponseBodyBestEffort(pendingResponse); pendingResponse = undefined; + if (retryDelayMs > 0) await sleepWithAbort(retryDelayMs, ctx.abortSignal); },Add a streaming-response test that checks cancellation starts before the wait and that aborting the wait does not leave the discarded body unreleased.