diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index d03dcc234d36..03f0b6b48fa8 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -118,6 +118,8 @@ const QUOTA_CODES = new Set([ "gousagelimiterror", "freeusagelimiterror", "creditlimitexceeded", + // Google Cloud Code 5h/weekly account caps. Bare RESOURCE_EXHAUSTED stays a throttle. + "quota_exhausted", ]) // Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`. // Z.ai's Responses API reports account and plan rejections mid-stream as `permission_denied`. diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index eb8588ca8d7f..87a03863a444 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { isContextOverflow } from "../src/index.js" +import { AIError, isContextOverflow, isRetryable } from "../src/index.js" import { classifyProviderFailure, providerErrorMessage } from "../src/provider-error.js" describe("provider error classification", () => { @@ -349,6 +349,44 @@ describe("provider error classification", () => { ).toBe("ContentPolicy") }) + test("classifies Google 5h quota exhaustion as non-retryable quota", () => { + const message = "You have exhausted your capacity on this model. Your quota will reset after 5h." + const quota = classifyProviderFailure({ + message, + status: 429, + rawBody: JSON.stringify({ + error: { + code: 429, + message, + status: "RESOURCE_EXHAUSTED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "QUOTA_EXHAUSTED", + domain: "cloudcode-pa.googleapis.com", + }, + ], + }, + }), + }) + const throttle = classifyProviderFailure({ + message: "Resource has been exhausted (e.g. check quota).", + status: 429, + rawBody: JSON.stringify({ + error: { + code: 429, + message: "Resource has been exhausted (e.g. check quota).", + status: "RESOURCE_EXHAUSTED", + details: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "RATE_LIMIT_EXCEEDED" }], + }, + }), + }) + expect(quota._tag).toBe("QuotaExceeded") + expect(isRetryable(new AIError({ reason: quota }))).toBe(false) + expect(throttle._tag).toBe("RateLimit") + expect(isRetryable(new AIError({ reason: throttle }))).toBe(true) + }) + test("keeps Z.ai throttling and overload retryable", () => { expect( [ diff --git a/packages/antigravity-plugin/README.md b/packages/antigravity-plugin/README.md index 7d1f5045f25f..f22bee579193 100644 --- a/packages/antigravity-plugin/README.md +++ b/packages/antigravity-plugin/README.md @@ -23,6 +23,11 @@ There are no Core imports, SDK transport wrappers, or proxy servers. - Native Gemini requests resolve the active credential afresh, then use the Cloud Code envelope and `metadata.projectId`. Only responses belonging to rewritten requests are unwrapped. API-key connections are unchanged. +- Failed rewritten responses are re-emitted as a Google error with the Cloud + Code status, message, and (on 429) a best-effort quota summary readout. Only + an explicit `QUOTA_EXHAUSTED` reason, an exhausted 5h bucket in the failed + model's own group, or a long-window reset message becomes non-retryable + `QUOTA_EXHAUSTED`; other `RESOURCE_EXHAUSTED` errors stay retryable throttles. - Browser login is explicit. No HOME, legacy account, or IDE credential discovery is performed. Offline credential import belongs to the host's test importer. - Credentials marked `metadata.shuvcodeAuthImport: "access-only"` cannot refresh; diff --git a/packages/antigravity-plugin/src/index.ts b/packages/antigravity-plugin/src/index.ts index 78008fd5e0f3..14204913f642 100644 --- a/packages/antigravity-plugin/src/index.ts +++ b/packages/antigravity-plugin/src/index.ts @@ -152,9 +152,49 @@ export const GoogleAntigravityPlugin = Plugin.define({ yield* ctx.session.hook( "http.response", (evt) => - Effect.sync(() => { + Effect.gen(function* () { if (evt.model.providerID !== Provider.ID.google || !mapped.has(evt.request)) return - if (!evt.response.ok || !evt.response.body) return + if (!evt.response.body) return + if (!evt.response.ok) { + const text = yield* Effect.tryPromise({ + try: () => evt.response.clone().text(), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => "")) + const parsed = GoogleAntigravityWire.parseCloudCodeError(text) + if (!parsed) return + const connection = yield* ctx.integration.connection.active(GoogleAntigravityOAuth.integrationID) + const resolved = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) + : undefined + const oauth = + resolved?.type === "oauth" && GoogleAntigravityOAuth.isSubscription(resolved) ? resolved : undefined + const projectId = oauth ? GoogleAntigravityOAuth.projectId(oauth.metadata) : undefined + const summary = + evt.response.status === 429 && oauth && projectId + ? yield* Effect.tryPromise({ + try: (signal) => + GoogleAntigravityOAuth.fetchQuotaSummary(oauth.access, projectId, undefined, signal), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => undefined)) + : undefined + const explained = GoogleAntigravityWire.explainCloudCodeError({ + error: parsed, + summary, + httpStatus: evt.response.status, + modelID: evt.model.id, + }) + if (!explained) return + const headers = new Headers(evt.response.headers) + headers.delete("content-length") + headers.delete("content-encoding") + headers.set("content-type", "application/json") + evt.response = new Response(explained.body, { + status: evt.response.status, + statusText: evt.response.statusText, + headers, + }) + return + } const headers = new Headers(evt.response.headers) headers.delete("content-length") headers.delete("content-encoding") diff --git a/packages/antigravity-plugin/src/oauth.ts b/packages/antigravity-plugin/src/oauth.ts index e596c57ec8a1..ea699c52e39d 100644 --- a/packages/antigravity-plugin/src/oauth.ts +++ b/packages/antigravity-plugin/src/oauth.ts @@ -301,6 +301,23 @@ export async function fetchAvailableModels( ) } +/** Best-effort quota readout. HTTP failures return undefined so a 429 can still be explained. */ +export async function fetchQuotaSummary(access: string, project: string, request: Fetch = fetch, signal?: AbortSignal) { + const response = await request(`${cloudCodeEndpoint}/v1internal:retrieveUserQuotaSummary`, { + method: "POST", + headers: { + Authorization: `Bearer ${access}`, + "Content-Type": "application/json", + "User-Agent": userAgent(), + }, + body: JSON.stringify({ project }), + signal: requestSignal(signal), + }) + if (!response.ok) return + const body: unknown = await response.json() + return body +} + export async function completeAccount( input: AccountTokens | Tokens, request: Fetch = fetch, diff --git a/packages/antigravity-plugin/src/wire.ts b/packages/antigravity-plugin/src/wire.ts index 8f2a5f0406a9..2a895bea23ae 100644 --- a/packages/antigravity-plugin/src/wire.ts +++ b/packages/antigravity-plugin/src/wire.ts @@ -219,6 +219,201 @@ export function unwrapSSE() { }) } +export type CloudCodeError = { + code?: number + status?: string + message?: string + reason?: string + resetTime?: string +} + +type QuotaWindow = { + id: string + remainingFraction: number + resetTime?: string +} + +/** + * Read a Cloud Code error from the complete body. A 500-character prefix is not valid JSON + * once the payload is longer than that, and slicing it drops the Google status and message. + */ +export function parseCloudCodeError(text: string): CloudCodeError | undefined { + const trimmed = text.trim() + if (!trimmed) return + return cloudCodeError(trimmed) ?? cloudCodeError(payloadAfterPrefix(trimmed) ?? "") +} + +function quotaWindows(summary: unknown): QuotaWindow[] { + const root = isRecord(summary) ? summary : undefined + if (!root || !Array.isArray(root.groups)) return [] + const windows: QuotaWindow[] = [] + const seen = new Set() + root.groups.forEach((rawGroup, index) => { + const group = isRecord(rawGroup) ? rawGroup : undefined + if (!group || !Array.isArray(group.buckets)) return + const prefix = quotaGroupID(group, index) + for (const rawBucket of group.buckets) { + const bucket = isRecord(rawBucket) ? rawBucket : undefined + if (!bucket) continue + const fraction = finiteNumber(bucket.remainingFraction ?? bucket.remaining_fraction) + if (fraction === undefined) continue + const id = `${prefix}:${quotaWindowID(bucket)}` + if (seen.has(id)) continue + seen.add(id) + const resetTime = textField(bucket.resetTime ?? bucket.reset_time) + windows.push({ id, remainingFraction: fraction, ...(resetTime ? { resetTime } : {}) }) + } + }) + return windows +} + +export function explainCloudCodeError(input: { + error: CloudCodeError + summary?: unknown + httpStatus: number + modelID?: string +}) { + if (!input.error.status && !input.error.message && !input.error.reason) return + const windows = quotaWindows(input.summary) + const fiveHour = fiveHourWindow(windows, input.modelID) + const quota = + input.error.reason?.toLowerCase() === "quota_exhausted" || + (input.error.status === "RESOURCE_EXHAUSTED" && fiveHour !== undefined && fiveHour.remainingFraction <= 0) || + (input.error.status === "RESOURCE_EXHAUSTED" && messageIsLongQuota(input.error.message)) + const headline = + input.error.status && input.error.message + ? `${input.error.status}: ${input.error.message}` + : (input.error.status ?? input.error.message ?? "") + const readout = windows.map( + (window) => + `${window.id} remainingFraction=${JSON.stringify(window.remainingFraction)}${window.resetTime ? ` resetTime=${window.resetTime}` : ""}`, + ) + const fallbackReset = windows.length === 0 && input.error.resetTime ? [`resetTime=${input.error.resetTime}`] : [] + const message = [headline, ...readout, ...fallbackReset].filter((part) => part.length > 0).join(" ") + const reason = quota ? "QUOTA_EXHAUSTED" : input.error.reason + return { + message, + quota, + body: JSON.stringify({ + error: { + code: input.httpStatus, + message, + ...(input.error.status ? { status: input.error.status } : {}), + ...(reason ? { details: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason }] } : {}), + }, + }), + } +} + +function cloudCodeError(text: string): CloudCodeError | undefined { + if (!text) return + const parsed = Option.getOrUndefined(decodeJson(text)) + if (!isRecord(parsed)) return + const error = isRecord(parsed.error) ? parsed.error : parsed + const outerMessage = textField(error.message) + const nested = outerMessage ? nestedGoogleError(outerMessage) : undefined + const outerStatus = textField(error.status) + const innerStatus = nested ? textField(nested.status) : undefined + const status = innerStatus && (!outerStatus || outerStatus === "UNKNOWN") ? innerStatus : (outerStatus ?? innerStatus) + const innerMessage = nested ? textField(nested.message) : undefined + const message = + outerMessage && innerMessage && outerMessage.includes("{") ? innerMessage : (outerMessage ?? innerMessage) + const outerDetails = errorDetails(error) + const details = outerDetails.reason ? outerDetails : nested ? errorDetails(nested) : outerDetails + const code = finiteNumber(error.code ?? nested?.code) + if (!status && !message && !details.reason) return + return { + ...(code === undefined ? {} : { code }), + ...(status ? { status } : {}), + ...(message ? { message } : {}), + ...(details.reason ? { reason: details.reason } : {}), + ...(details.resetTime ? { resetTime: details.resetTime } : {}), + } +} + +function nestedGoogleError(message: string) { + const start = message.indexOf("{") + if (start < 0) return + const parsed = Option.getOrUndefined(decodeJson(message.slice(start))) + if (!isRecord(parsed)) return + const error = isRecord(parsed.error) ? parsed.error : parsed + if (!textField(error.status) && !textField(error.message) && !Array.isArray(error.details)) return + return error +} + +function errorDetails(error: Record | undefined) { + const details = error && Array.isArray(error.details) ? error.details : [] + return details.reduce<{ reason?: string; resetTime?: string }>((acc, detail) => { + if (!isRecord(detail)) return acc + const metadata = isRecord(detail.metadata) ? detail.metadata : undefined + return { + reason: acc.reason ?? textField(detail.reason), + resetTime: + acc.resetTime ?? + textField(metadata?.quotaResetTimeStamp) ?? + textField(metadata?.quotaResetTimestamp) ?? + textField(metadata?.resetTime), + } + }, {}) +} + +function payloadAfterPrefix(text: string) { + const data = text.split(/\r?\n/).flatMap((line) => { + const match = /^data:\s*(.*)$/.exec(line) + return match?.[1] && match[1] !== "[DONE]" ? [match[1]] : [] + }) + const payload = [...data].reverse().find((line) => line.includes("{")) + if (payload) return payload + const start = text.indexOf("{") + if (start < 0) return + return text.slice(start) +} + +function quotaGroupID(group: Record, index: number) { + const text = ( + textField(group.displayName) ?? + textField(group.display_name) ?? + textField(group.id) ?? + "" + ).toLowerCase() + if (text.includes("gemini")) return "gemini" + if (text.includes("claude") || text.includes("gpt")) return "3p" + return textField(group.id) ?? `group-${index}` +} + +function quotaWindowID(bucket: Record) { + const raw = (textField(bucket.window) ?? "").toLowerCase() + if (raw === "5h" || raw === "weekly") return raw + return raw || textField(bucket.bucketId) || textField(bucket.bucket_id) || "limit" +} + +function fiveHourWindow(windows: readonly QuotaWindow[], modelID: string | undefined) { + const name = (modelID ?? "").toLowerCase() + const preferred = + name.includes("claude") || name.includes("gpt") ? "3p:5h" : name.includes("gemini") ? "gemini:5h" : undefined + return windows.find((window) => window.id === preferred) +} + +function messageIsLongQuota(message: string | undefined) { + if (!message) return false + if (/\b5h\b|5-hour|five[- ]hour/i.test(message)) return true + const hours = /reset after\s+(\d+)h/i.exec(message) + return hours !== null && Number(hours[1]) >= 1 +} + +function finiteNumber(value: unknown) { + if (typeof value === "number") return Number.isFinite(value) ? value : undefined + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +function textField(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : undefined +} + export function applyProvider(evt: ProviderEditor, active: boolean) { if (!active) return evt.update(googleProviderID, (provider) => { diff --git a/packages/antigravity-plugin/test/error.test.ts b/packages/antigravity-plugin/test/error.test.ts new file mode 100644 index 000000000000..74d3fb983e0b --- /dev/null +++ b/packages/antigravity-plugin/test/error.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test" +import { GoogleAntigravityWire } from "../src/wire" + +const reset = "2026-08-14T18:00:00Z" +const quotaMessage = "You have exhausted your capacity on this model. Your quota will reset after 5h." + +const summary = { + groups: [ + { + displayName: "Gemini models", + buckets: [ + { window: "5h", remainingFraction: 0, resetTime: reset }, + { window: "weekly", remainingFraction: 0.4, resetTime: "2026-08-20T00:00:00Z" }, + ], + }, + ], +} + +describe("Cloud Code 429 bodies", () => { + test("keeps the Google status and message when they sit past 500 characters", () => { + const padding = "x".repeat(500) + const inner = { + error: { + code: 429, + message: quotaMessage, + status: "RESOURCE_EXHAUSTED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "QUOTA_EXHAUSTED", + metadata: { quotaResetTimeStamp: reset }, + }, + ], + }, + } + const body = JSON.stringify({ + noise: padding, + error: { code: 429, message: padding + JSON.stringify(inner), status: "UNKNOWN" }, + }) + expect(body.length).toBeGreaterThan(500) + expect(body.slice(0, 500).includes("RESOURCE_EXHAUSTED")).toBe(false) + + const parsed = GoogleAntigravityWire.parseCloudCodeError(body) + expect(parsed).toMatchObject({ + status: "RESOURCE_EXHAUSTED", + message: quotaMessage, + reason: "QUOTA_EXHAUSTED", + resetTime: reset, + }) + if (!parsed) return + const explained = GoogleAntigravityWire.explainCloudCodeError({ + error: parsed, + httpStatus: 429, + modelID: "gemini-3.8-flash-high", + }) + expect(explained?.quota).toBe(true) + expect(explained?.message).toContain(`RESOURCE_EXHAUSTED: ${quotaMessage}`) + expect(explained?.message).toContain(`resetTime=${reset}`) + }) + + test("reads an SSE error line without trimming the payload to 500 characters", () => { + const padding = "y".repeat(500) + const body = `data: ${JSON.stringify({ + error: { + code: 429, + message: `${padding} ${quotaMessage}`, + status: "RESOURCE_EXHAUSTED", + }, + })}\n\n` + expect(GoogleAntigravityWire.parseCloudCodeError(body)).toMatchObject({ + status: "RESOURCE_EXHAUSTED", + message: `${padding} ${quotaMessage}`, + }) + }) + + test("surfaces remainingFraction and resetTime and marks an empty 5h bucket as quota", () => { + const explained = GoogleAntigravityWire.explainCloudCodeError({ + error: { + status: "RESOURCE_EXHAUSTED", + message: "Resource has been exhausted (e.g. check quota).", + reason: "RATE_LIMIT_EXCEEDED", + }, + summary, + httpStatus: 429, + modelID: "gemini-3.8-flash-high", + }) + expect(explained?.quota).toBe(true) + expect(explained?.message).toContain("RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. check quota).") + expect(explained?.message).toContain("gemini:5h remainingFraction=0 resetTime=2026-08-14T18:00:00Z") + expect(explained?.message).toContain("gemini:weekly remainingFraction=0.4 resetTime=2026-08-20T00:00:00Z") + expect(JSON.parse(explained?.body ?? "{}")).toMatchObject({ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + details: [{ reason: "QUOTA_EXHAUSTED" }], + }, + }) + }) + + test("keeps a short throttle retryable when the 5h bucket still has quota", () => { + const explained = GoogleAntigravityWire.explainCloudCodeError({ + error: { + status: "RESOURCE_EXHAUSTED", + message: "Resource has been exhausted (e.g. check quota).", + reason: "RATE_LIMIT_EXCEEDED", + }, + summary: { + groups: [ + { + displayName: "Gemini models", + buckets: [{ window: "5h", remainingFraction: 0.5, resetTime: reset }], + }, + ], + }, + httpStatus: 429, + modelID: "gemini-3.8-flash-high", + }) + expect(explained?.quota).toBe(false) + expect(explained?.message).toContain("gemini:5h remainingFraction=0.5 resetTime=2026-08-14T18:00:00Z") + expect(JSON.parse(explained?.body ?? "{}").error.details).toEqual([ + { "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "RATE_LIMIT_EXCEEDED" }, + ]) + }) + + test.each([ + { + modelID: "gemini-3.8-flash-high", + groups: [{ displayName: "Claude + GPT", buckets: [{ window: "5h", remainingFraction: 0 }] }], + }, + { modelID: "claude-sonnet-4-6", groups: summary.groups }, + { modelID: "gpt-oss-120b", groups: summary.groups }, + { + modelID: "gemini-3.8-flash-high", + groups: [{ displayName: "Unknown models", buckets: [{ window: "5h", remainingFraction: 0 }] }], + }, + { modelID: "gemini-3.8-flash-high", groups: [] }, + { modelID: "unknown-model", groups: summary.groups }, + { modelID: undefined, groups: summary.groups }, + ])("does not borrow quota from an unrelated or missing group: %j", ({ modelID, groups }) => { + const explained = GoogleAntigravityWire.explainCloudCodeError({ + error: { + status: "RESOURCE_EXHAUSTED", + message: "Resource has been exhausted (e.g. check quota).", + reason: "RATE_LIMIT_EXCEEDED", + }, + summary: { groups }, + httpStatus: 429, + modelID, + }) + expect(explained?.quota).toBe(false) + expect(JSON.parse(explained?.body ?? "{}").error.details[0].reason).toBe("RATE_LIMIT_EXCEEDED") + }) + + test.each(["claude-sonnet-4-6", "gpt-oss-120b"])("uses the matching third-party group for %s", (modelID) => { + expect( + GoogleAntigravityWire.explainCloudCodeError({ + error: { status: "RESOURCE_EXHAUSTED", message: "Resource exhausted" }, + summary: { groups: [{ displayName: "Claude + GPT", buckets: [{ window: "5h", remainingFraction: 0 }] }] }, + httpStatus: 429, + modelID, + })?.quota, + ).toBe(true) + }) + + test.each([ + { reason: "QUOTA_EXHAUSTED", message: "Resource exhausted" }, + { reason: "RATE_LIMIT_EXCEEDED", message: quotaMessage }, + ])("retains affirmative quota evidence without a matching group: %j", (error) => { + expect( + GoogleAntigravityWire.explainCloudCodeError({ + error: { status: "RESOURCE_EXHAUSTED", ...error }, + summary: { groups: [] }, + httpStatus: 429, + modelID: "unknown-model", + })?.quota, + ).toBe(true) + }) + + test("ignores success-shaped bodies", () => { + expect(GoogleAntigravityWire.parseCloudCodeError('data: {"response":{"candidates":[]}}\n\n')).toBeUndefined() + }) +}) diff --git a/packages/core/test/session-antigravity-error.test.ts b/packages/core/test/session-antigravity-error.test.ts new file mode 100644 index 000000000000..c61e29d91a05 --- /dev/null +++ b/packages/core/test/session-antigravity-error.test.ts @@ -0,0 +1,118 @@ +import { expect } from "bun:test" +import { isRetryable } from "@opencode/ai" +import { RequestExecutor } from "@opencode/ai/route" +import { Agent } from "@opencode/schema/agent" +import { Session } from "@opencode/schema/session" +import { Credential } from "@opencode/core/credential" +import { Plugin } from "@opencode/core/plugin" +import { Model } from "@opencode/core/model" +import { Provider } from "@opencode/core/provider" +import { PluginHooks } from "@opencode/core/plugin/hooks" +import { PluginHost } from "@opencode/core/plugin/host" +import { GoogleAntigravityPlugin, GoogleAntigravityOAuth, GoogleAntigravityWire } from "@shuvcode/antigravity-plugin" +import { Effect } from "effect" +import { testEffect } from "./lib/effect" +import { PluginTestLayer } from "./plugin/fixture" + +const it = testEffect(PluginTestLayer) +const identity = { + sessionID: Session.ID.make("ses_antigravity_error"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.google, id: Model.ID.make("gemini-3.8-flash-high") }), + kind: "primary" as const, +} + +for (const scenario of [ + { reason: "QUOTA_EXHAUSTED", message: "Capacity exhausted", tag: "QuotaExceeded", retryable: false }, + { reason: "RATE_LIMIT_EXCEEDED", message: "Your quota will reset after 5h.", tag: "QuotaExceeded", retryable: false }, + { + reason: "RATE_LIMIT_EXCEEDED", + message: "Resource has been exhausted (e.g. check quota).", + tag: "RateLimit", + retryable: true, + }, +] as const) { + it.effect( + `Session receives the full Antigravity ${scenario.reason} body as ${scenario.tag}: ${scenario.message}`, + () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + let responsePhase = false + yield* GoogleAntigravityPlugin.effect({ + ...host, + integration: { + ...host.integration, + connection: { + active: () => Effect.succeed({ type: "credential", id: "fixture", label: "fixture", method: "oauth" }), + // Resolution is unavailable after sending: the response hook must still explain + // the body without making a quota-summary request or touching real credentials. + resolve: () => + Effect.succeed( + responsePhase + ? undefined + : Credential.OAuth.make({ + type: "oauth", + methodID: GoogleAntigravityOAuth.methodID, + access: "fixture-access", + refresh: "", + expires: Date.now() + 3_600_000, + metadata: { projectId: "fixture-project", shuvcodeAuthImport: "access-only" }, + }), + ), + status: () => Effect.void, + }, + }, + }) + const hooks = yield* PluginHooks.Service + const event = yield* hooks.trigger("session", "http.request", { + ...identity, + request: new Request( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash-high:streamGenerateContent", + { + method: "POST", + body: '{"contents":[]}', + }, + ), + }) + expect(event.request.url).toBe(GoogleAntigravityWire.generateURL) + responsePhase = true + const message = `${"padding ".repeat(100)}${scenario.message}` + const body = JSON.stringify({ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + message, + details: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: scenario.reason }], + }, + }) + expect(body.slice(0, 500)).not.toContain(scenario.message) + const response = (yield* hooks.trigger("session", "http.response", { + ...identity, + request: event.request, + response: new Response(body, { + status: 429, + headers: { + "content-type": "text/event-stream", + "content-length": String(body.length), + }, + }), + })).response + const responseBody = yield* Effect.promise(() => response.text()) + expect(JSON.parse(responseBody).error.message).toBe(`RESOURCE_EXHAUSTED: ${message}`) + const error = RequestExecutor.httpFailure({ + message: responseBody, + url: event.request.url, + status: response.status, + responseHeaders: Object.fromEntries(response.headers), + responseBody, + }) + expect(error.reason._tag).toBe(scenario.tag) + expect(isRetryable(error)).toBe(scenario.retryable) + expect(error.message).toContain(message) + expect(error.reason.http?.status).toBe(429) + expect(error.reason.http?.headers["content-type"]).toBe("application/json") + expect(error.reason.http?.headers["content-length"]).toBeUndefined() + }), + ) +}