Skip to content

Commit fd39540

Browse files
author
Lonny Jepson
committed
Cherry‑pick PRs NoeFabris#476, NoeFabris#465, NoeFabris#460 and address review issues
- PR NoeFabris#476: guard isOAuthAuth against undefined/null (auth.ts) - PR NoeFabris#465: use ensureProjectContext for google_search project‑ID resolution - PR NoeFabris#460: throttle excessive file writes from account state updates - Fix abort‑during‑cooldown error‑escape (wrap all calls) - Remove stale THINKING_RECOVERY_NEEDED sentinel branch - Change abort status from HTTP 499 to standard 408 (Request Timeout) - All tests pass, type‑check clean
1 parent a9be90d commit fd39540

9 files changed

Lines changed: 486 additions & 91 deletions

File tree

src/plugin.ts

Lines changed: 208 additions & 31 deletions
Large diffs are not rendered by default.

src/plugin/accounts.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,10 +1185,19 @@ describe("AccountManager", () => {
11851185

11861186
it("parses RATE_LIMIT_EXCEEDED from reason field", () => {
11871187
expect(parseRateLimitReason("RATE_LIMIT_EXCEEDED", undefined)).toBe("RATE_LIMIT_EXCEEDED");
1188+
expect(parseRateLimitReason("TOO_MANY_REQUESTS", undefined)).toBe("RATE_LIMIT_EXCEEDED");
1189+
expect(parseRateLimitReason("RATE_LIMITED", undefined)).toBe("RATE_LIMIT_EXCEEDED");
11881190
});
11891191

11901192
it("parses MODEL_CAPACITY_EXHAUSTED from reason field", () => {
11911193
expect(parseRateLimitReason("MODEL_CAPACITY_EXHAUSTED", undefined)).toBe("MODEL_CAPACITY_EXHAUSTED");
1194+
expect(parseRateLimitReason("RESOURCE_EXHAUSTED", undefined)).toBe("MODEL_CAPACITY_EXHAUSTED");
1195+
});
1196+
1197+
it("parses server errors from reason/status field", () => {
1198+
expect(parseRateLimitReason("UNAVAILABLE", undefined)).toBe("SERVER_ERROR");
1199+
expect(parseRateLimitReason("SERVICE_UNAVAILABLE", undefined)).toBe("SERVER_ERROR");
1200+
expect(parseRateLimitReason("INTERNAL", undefined)).toBe("SERVER_ERROR");
11921201
});
11931202

11941203
it("falls back to message parsing when reason is absent", () => {

src/plugin/accounts.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,14 @@ export function parseRateLimitReason(
5858
switch (reason.toUpperCase()) {
5959
case "QUOTA_EXHAUSTED": return "QUOTA_EXHAUSTED";
6060
case "RATE_LIMIT_EXCEEDED": return "RATE_LIMIT_EXCEEDED";
61+
case "TOO_MANY_REQUESTS": return "RATE_LIMIT_EXCEEDED";
62+
case "RATE_LIMITED": return "RATE_LIMIT_EXCEEDED";
6163
case "MODEL_CAPACITY_EXHAUSTED": return "MODEL_CAPACITY_EXHAUSTED";
64+
case "RESOURCE_EXHAUSTED": return "MODEL_CAPACITY_EXHAUSTED";
65+
case "UNAVAILABLE": return "SERVER_ERROR";
66+
case "SERVICE_UNAVAILABLE": return "SERVER_ERROR";
67+
case "INTERNAL": return "SERVER_ERROR";
68+
case "INTERNAL_ERROR": return "SERVER_ERROR";
6269
}
6370
}
6471

@@ -67,17 +74,36 @@ export function parseRateLimitReason(
6774
const lower = message.toLowerCase();
6875

6976
// Capacity / Overloaded (Transient) - Check FIRST before "exhausted"
70-
if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("resource exhausted")) {
77+
if (
78+
lower.includes("capacity") ||
79+
lower.includes("overloaded") ||
80+
lower.includes("resource exhausted") ||
81+
lower.includes("resource_exhausted")
82+
) {
7183
return "MODEL_CAPACITY_EXHAUSTED";
7284
}
7385

7486
// RPM / TPM (Short Wait)
7587
// "per minute", "rate limit", "too many requests"
7688
// "presque" (French: almost) - retained for i18n parity with Rust reference
77-
if (lower.includes("per minute") || lower.includes("rate limit") || lower.includes("too many requests") || lower.includes("presque")) {
89+
if (
90+
lower.includes("per minute") ||
91+
lower.includes("rate limit") ||
92+
lower.includes("too many requests") ||
93+
lower.includes("rate_limited") ||
94+
lower.includes("presque")
95+
) {
7896
return "RATE_LIMIT_EXCEEDED";
7997
}
8098

99+
if (
100+
lower.includes("service unavailable") ||
101+
lower.includes("temporarily unavailable") ||
102+
lower.includes("internal error")
103+
) {
104+
return "SERVER_ERROR";
105+
}
106+
81107
// Quota (Long Wait)
82108
if (lower.includes("exhausted") || lower.includes("quota")) {
83109
return "QUOTA_EXHAUSTED";

src/plugin/auth.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ describe("isOAuthAuth", () => {
2121
};
2222
expect(isOAuthAuth(auth)).toBe(false);
2323
});
24+
25+
it("returns false for undefined auth", () => {
26+
expect(isOAuthAuth(undefined as any)).toBe(false);
27+
});
28+
29+
it("returns false for null auth", () => {
30+
expect(isOAuthAuth(null as any)).toBe(false);
31+
});
2432
});
2533

2634
describe("parseRefreshParts", () => {

src/plugin/auth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types";
22

33
const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
44

5-
export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails {
6-
return auth.type === "oauth";
5+
export function isOAuthAuth(auth: AuthDetails | null | undefined): auth is OAuthAuthDetails {
6+
return !!auth && auth.type === "oauth";
77
}
88

99
/**

src/plugin/request.test.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -940,35 +940,66 @@ it("removes x-api-key header", () => {
940940
);
941941

942942
await expect(transformed.text()).resolves.toContain("Request contains an invalid argument.");
943+
expect(transformed.headers.get("x-antigravity-recovery-needed")).toBeNull();
943944
});
944945

945-
it("rethrows THINKING_RECOVERY_NEEDED for outer retry handling", async () => {
946+
it("extracts fallback message from error.status when error.message is missing", async () => {
946947
const response = new Response(
947948
JSON.stringify({
948949
error: {
949-
code: 400,
950-
message: "Thinking must start with a thinking block before tool use.",
951950
status: "INVALID_ARGUMENT",
951+
details: [{ "@type": "type.googleapis.com/google.rpc.BadRequest" }],
952952
},
953953
}),
954954
{
955955
status: 400,
956-
headers: { "content-type": "application/json" },
956+
headers: { "Content-Type": "application/json" },
957957
},
958958
);
959959

960-
await expect(
961-
transformAntigravityResponse(
962-
response,
963-
true,
964-
undefined,
965-
"antigravity-claude-opus-4-6-thinking",
966-
"test-project",
967-
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse",
968-
"claude-opus-4-6-thinking",
969-
"session-1",
970-
),
971-
).rejects.toMatchObject({ message: "THINKING_RECOVERY_NEEDED" });
960+
const transformed = await transformAntigravityResponse(response, false);
961+
const body = await transformed.json() as any;
962+
963+
expect(transformed.status).toBe(400);
964+
expect(body.error.message).toContain("INVALID_ARGUMENT");
965+
});
966+
967+
it("handles batch-style error.errors[] fallback when message is missing", async () => {
968+
const response = new Response(
969+
JSON.stringify({
970+
error: {
971+
errors: [{ status: "RESOURCE_EXHAUSTED" }],
972+
},
973+
}),
974+
{
975+
status: 429,
976+
headers: { "Content-Type": "application/json" },
977+
},
978+
);
979+
980+
const transformed = await transformAntigravityResponse(response, false);
981+
const body = await transformed.json() as any;
982+
983+
expect(transformed.status).toBe(429);
984+
expect(body.error.message).toContain("RESOURCE_EXHAUSTED");
985+
});
986+
987+
it("signals thinking recovery with a response header instead of throwing", async () => {
988+
const response = new Response(
989+
JSON.stringify({
990+
error: {
991+
status: "INVALID_ARGUMENT",
992+
details: [{ reason: "thinking must start with first block" }],
993+
},
994+
}),
995+
{
996+
status: 400,
997+
headers: { "Content-Type": "application/json" },
998+
},
999+
);
1000+
1001+
const transformed = await transformAntigravityResponse(response, false);
1002+
expect(transformed.headers.get("x-antigravity-recovery-needed")).toBe("thinking_block_order");
9721003
});
9731004
});
9741005
});

src/plugin/request.ts

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -827,11 +827,9 @@ export function prepareAntigravityRequest(
827827
// Use stable session ID for signature caching across multi-turn conversations
828828
(req as any).sessionId = signatureSessionKey;
829829
stripInjectedDebugFromRequestPayload(req as Record<string, unknown>);
830+
sanitizeCrossModelPayloadInPlace(req, { targetModel: effectiveModel });
830831

831832
if (isClaude) {
832-
// Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude)
833-
sanitizeCrossModelPayloadInPlace(req, { targetModel: effectiveModel });
834-
835833
// Step 1: Strip corrupted/unsigned thinking blocks FIRST
836834
deepFilterThinkingBlocks(req, signatureSessionKey, getCachedSignature, true);
837835

@@ -1270,10 +1268,8 @@ export function prepareAntigravityRequest(
12701268
// For Claude models, filter out unsigned thinking blocks (required by Claude API)
12711269
// Attempts to restore signatures from cache for multi-turn conversations
12721270
// Handle both Gemini-style contents[] and Anthropic-style messages[] payloads.
1271+
sanitizeCrossModelPayloadInPlace(requestPayload, { targetModel: effectiveModel });
12731272
if (isClaude) {
1274-
// Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude)
1275-
sanitizeCrossModelPayloadInPlace(requestPayload, { targetModel: effectiveModel });
1276-
12771273
// Step 1: Strip corrupted/unsigned thinking blocks FIRST
12781274
deepFilterThinkingBlocks(requestPayload, signatureSessionKey, getCachedSignature, true);
12791275

@@ -1588,6 +1584,49 @@ export async function transformAntigravityResponse(
15881584
toolDebugPayload?: string,
15891585
debugLines?: string[],
15901586
): Promise<Response> {
1587+
const extractBestErrorMessage = (errorValue: unknown): string => {
1588+
if (!errorValue || typeof errorValue !== "object") {
1589+
return "";
1590+
}
1591+
1592+
const errorObj = errorValue as Record<string, unknown>;
1593+
if (typeof errorObj.message === "string" && errorObj.message.trim()) {
1594+
return errorObj.message;
1595+
}
1596+
if (typeof errorObj.status === "string" && errorObj.status.trim()) {
1597+
return errorObj.status;
1598+
}
1599+
1600+
if (Array.isArray(errorObj.details) && errorObj.details.length > 0) {
1601+
const firstDetail = errorObj.details[0];
1602+
if (firstDetail && typeof firstDetail === "object") {
1603+
const detailRecord = firstDetail as Record<string, unknown>;
1604+
if (typeof detailRecord.reason === "string" && detailRecord.reason.trim()) {
1605+
return detailRecord.reason;
1606+
}
1607+
}
1608+
}
1609+
1610+
if (Array.isArray(errorObj.errors) && errorObj.errors.length > 0) {
1611+
const firstBatchError = errorObj.errors[0];
1612+
if (firstBatchError && typeof firstBatchError === "object") {
1613+
const batchRecord = firstBatchError as Record<string, unknown>;
1614+
if (typeof batchRecord.message === "string" && batchRecord.message.trim()) {
1615+
return batchRecord.message;
1616+
}
1617+
if (typeof batchRecord.status === "string" && batchRecord.status.trim()) {
1618+
return batchRecord.status;
1619+
}
1620+
}
1621+
}
1622+
1623+
try {
1624+
return JSON.stringify(errorObj);
1625+
} catch {
1626+
return "";
1627+
}
1628+
};
1629+
15911630
const contentType = response.headers.get("content-type") ?? "";
15921631
const isJsonResponse = contentType.includes("application/json");
15931632
const isEventStreamResponse = contentType.includes("text/event-stream");
@@ -1651,31 +1690,67 @@ export async function transformAntigravityResponse(
16511690
const text = await response.text();
16521691

16531692
if (!response.ok) {
1654-
let errorBody;
1693+
let errorBody: any;
16551694
try {
16561695
errorBody = JSON.parse(text);
16571696
} catch {
16581697
errorBody = { error: { message: text } };
16591698
}
16601699

1700+
if (!errorBody || typeof errorBody !== "object") {
1701+
errorBody = { error: { message: String(errorBody ?? text) } };
1702+
}
1703+
1704+
if (!errorBody.error || typeof errorBody.error !== "object") {
1705+
errorBody.error = {
1706+
message: typeof errorBody.message === "string" ? errorBody.message : undefined,
1707+
status: typeof errorBody.status === "string" ? errorBody.status : undefined,
1708+
details: Array.isArray(errorBody.details) ? errorBody.details : undefined,
1709+
errors: Array.isArray(errorBody.errors) ? errorBody.errors : undefined,
1710+
};
1711+
}
1712+
1713+
// Extract retry headers from google.rpc.RetryInfo when present.
1714+
if (Array.isArray(errorBody?.error?.details)) {
1715+
const retryInfo = errorBody.error.details.find(
1716+
(detail: any) => detail?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
1717+
);
1718+
1719+
if (retryInfo?.retryDelay) {
1720+
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/);
1721+
if (match && match[1]) {
1722+
const retrySeconds = parseFloat(match[1]);
1723+
if (!isNaN(retrySeconds) && retrySeconds > 0) {
1724+
const retryAfterSec = Math.ceil(retrySeconds).toString();
1725+
const retryAfterMs = Math.ceil(retrySeconds * 1000).toString();
1726+
headers.set("Retry-After", retryAfterSec);
1727+
headers.set("retry-after-ms", retryAfterMs);
1728+
}
1729+
}
1730+
}
1731+
}
1732+
16611733
// Inject Debug Info
16621734
if (errorBody?.error) {
1663-
const rawErrorMessage =
1664-
typeof errorBody.error.message === "string" && errorBody.error.message.length > 0
1665-
? errorBody.error.message
1666-
: "Unknown error";
1667-
const errorType = detectErrorType(rawErrorMessage);
1735+
const extractedMessage = extractBestErrorMessage(errorBody.error) || "Unknown error";
16681736
const debugInfo = `\n\n[Debug Info]\nRequested Model: ${requestedModel || "Unknown"}\nEffective Model: ${effectiveModel || "Unknown"}\nProject: ${projectId || "Unknown"}\nEndpoint: ${endpoint || "Unknown"}\nStatus: ${response.status}\nRequest ID: ${headers.get("x-request-id") || "N/A"}${toolDebugMissing !== undefined ? `\nTool Debug Missing: ${toolDebugMissing}` : ""}${toolDebugSummary ? `\nTool Debug Summary: ${toolDebugSummary}` : ""}${toolDebugPayload ? `\nTool Debug Payload: ${toolDebugPayload}` : ""}`;
16691737
const injectedDebug = debugText ? `\n\n${debugText}` : "";
1670-
errorBody.error.message = rawErrorMessage + debugInfo + injectedDebug;
1671-
1672-
// Check if this is a recoverable thinking error - throw to trigger retry
1738+
errorBody.error.message = extractedMessage + debugInfo + injectedDebug;
1739+
1740+
// Signal recoverable thinking errors via headers so caller can retry without throwing.
1741+
const firstDetailReason =
1742+
Array.isArray(errorBody?.error?.details) &&
1743+
errorBody.error.details[0] &&
1744+
typeof errorBody.error.details[0] === "object"
1745+
? ((errorBody.error.details[0] as Record<string, unknown>).reason as string | undefined)
1746+
: undefined;
1747+
const errorType = detectErrorType(
1748+
[extractedMessage, typeof firstDetailReason === "string" ? firstDetailReason : ""]
1749+
.filter(Boolean)
1750+
.join(" "),
1751+
);
16731752
if (errorType === "thinking_block_order") {
1674-
const recoveryError = new Error("THINKING_RECOVERY_NEEDED");
1675-
(recoveryError as any).recoveryType = errorType;
1676-
(recoveryError as any).originalError = errorBody;
1677-
(recoveryError as any).debugInfo = debugInfo;
1678-
throw recoveryError;
1753+
headers.set("x-antigravity-recovery-needed", errorType);
16791754
}
16801755

16811756
// Detect context length / prompt too long errors - signal to caller for toast
@@ -1704,25 +1779,6 @@ export async function transformAntigravityResponse(
17041779
headers
17051780
});
17061781
}
1707-
1708-
if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) {
1709-
const retryInfo = errorBody.error.details.find(
1710-
(detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'
1711-
);
1712-
1713-
if (retryInfo?.retryDelay) {
1714-
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/);
1715-
if (match && match[1]) {
1716-
const retrySeconds = parseFloat(match[1]);
1717-
if (!isNaN(retrySeconds) && retrySeconds > 0) {
1718-
const retryAfterSec = Math.ceil(retrySeconds).toString();
1719-
const retryAfterMs = Math.ceil(retrySeconds * 1000).toString();
1720-
headers.set('Retry-After', retryAfterSec);
1721-
headers.set('retry-after-ms', retryAfterMs);
1722-
}
1723-
}
1724-
}
1725-
}
17261782
}
17271783

17281784
const init = {

0 commit comments

Comments
 (0)