Skip to content

Commit 2707599

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 efafbb9 commit 2707599

9 files changed

Lines changed: 501 additions & 71 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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
prepareAntigravityRequest,
44
getPluginSessionId,
55
isGenerativeLanguageRequest,
6+
transformAntigravityResponse,
67
__testExports,
78
} from "./request";
89
import type { SignatureStore, ThoughtBuffer, StreamingCallbacks, StreamingOptions } from "./core/streaming/types";
@@ -805,4 +806,65 @@ it("removes x-api-key header", () => {
805806
});
806807
});
807808
});
809+
810+
describe("transformAntigravityResponse", () => {
811+
it("extracts fallback message from error.status when error.message is missing", async () => {
812+
const response = new Response(
813+
JSON.stringify({
814+
error: {
815+
status: "INVALID_ARGUMENT",
816+
details: [{ "@type": "type.googleapis.com/google.rpc.BadRequest" }],
817+
},
818+
}),
819+
{
820+
status: 400,
821+
headers: { "Content-Type": "application/json" },
822+
},
823+
);
824+
825+
const transformed = await transformAntigravityResponse(response, false);
826+
const body = await transformed.json() as any;
827+
828+
expect(transformed.status).toBe(400);
829+
expect(body.error.message).toContain("INVALID_ARGUMENT");
830+
});
831+
832+
it("handles batch-style error.errors[] fallback when message is missing", async () => {
833+
const response = new Response(
834+
JSON.stringify({
835+
error: {
836+
errors: [{ status: "RESOURCE_EXHAUSTED" }],
837+
},
838+
}),
839+
{
840+
status: 429,
841+
headers: { "Content-Type": "application/json" },
842+
},
843+
);
844+
845+
const transformed = await transformAntigravityResponse(response, false);
846+
const body = await transformed.json() as any;
847+
848+
expect(transformed.status).toBe(429);
849+
expect(body.error.message).toContain("RESOURCE_EXHAUSTED");
850+
});
851+
852+
it("signals thinking recovery with a response header instead of throwing", async () => {
853+
const response = new Response(
854+
JSON.stringify({
855+
error: {
856+
status: "INVALID_ARGUMENT",
857+
details: [{ reason: "thinking must start with first block" }],
858+
},
859+
}),
860+
{
861+
status: 400,
862+
headers: { "Content-Type": "application/json" },
863+
},
864+
);
865+
866+
const transformed = await transformAntigravityResponse(response, false);
867+
expect(transformed.headers.get("x-antigravity-recovery-needed")).toBe("thinking_block_order");
868+
});
869+
});
808870
});

src/plugin/request.ts

Lines changed: 95 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -737,11 +737,9 @@ export function prepareAntigravityRequest(
737737
// Use stable session ID for signature caching across multi-turn conversations
738738
(req as any).sessionId = signatureSessionKey;
739739
stripInjectedDebugFromRequestPayload(req as Record<string, unknown>);
740+
sanitizeCrossModelPayloadInPlace(req, { targetModel: effectiveModel });
740741

741742
if (isClaude) {
742-
// Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude)
743-
sanitizeCrossModelPayloadInPlace(req, { targetModel: effectiveModel });
744-
745743
// Step 1: Strip corrupted/unsigned thinking blocks FIRST
746744
deepFilterThinkingBlocks(req, signatureSessionKey, getCachedSignature, true);
747745

@@ -1180,10 +1178,8 @@ export function prepareAntigravityRequest(
11801178
// For Claude models, filter out unsigned thinking blocks (required by Claude API)
11811179
// Attempts to restore signatures from cache for multi-turn conversations
11821180
// Handle both Gemini-style contents[] and Anthropic-style messages[] payloads.
1181+
sanitizeCrossModelPayloadInPlace(requestPayload, { targetModel: effectiveModel });
11831182
if (isClaude) {
1184-
// Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude)
1185-
sanitizeCrossModelPayloadInPlace(requestPayload, { targetModel: effectiveModel });
1186-
11871183
// Step 1: Strip corrupted/unsigned thinking blocks FIRST
11881184
deepFilterThinkingBlocks(requestPayload, signatureSessionKey, getCachedSignature, true);
11891185

@@ -1497,6 +1493,49 @@ export async function transformAntigravityResponse(
14971493
toolDebugPayload?: string,
14981494
debugLines?: string[],
14991495
): Promise<Response> {
1496+
const extractBestErrorMessage = (errorValue: unknown): string => {
1497+
if (!errorValue || typeof errorValue !== "object") {
1498+
return "";
1499+
}
1500+
1501+
const errorObj = errorValue as Record<string, unknown>;
1502+
if (typeof errorObj.message === "string" && errorObj.message.trim()) {
1503+
return errorObj.message;
1504+
}
1505+
if (typeof errorObj.status === "string" && errorObj.status.trim()) {
1506+
return errorObj.status;
1507+
}
1508+
1509+
if (Array.isArray(errorObj.details) && errorObj.details.length > 0) {
1510+
const firstDetail = errorObj.details[0];
1511+
if (firstDetail && typeof firstDetail === "object") {
1512+
const detailRecord = firstDetail as Record<string, unknown>;
1513+
if (typeof detailRecord.reason === "string" && detailRecord.reason.trim()) {
1514+
return detailRecord.reason;
1515+
}
1516+
}
1517+
}
1518+
1519+
if (Array.isArray(errorObj.errors) && errorObj.errors.length > 0) {
1520+
const firstBatchError = errorObj.errors[0];
1521+
if (firstBatchError && typeof firstBatchError === "object") {
1522+
const batchRecord = firstBatchError as Record<string, unknown>;
1523+
if (typeof batchRecord.message === "string" && batchRecord.message.trim()) {
1524+
return batchRecord.message;
1525+
}
1526+
if (typeof batchRecord.status === "string" && batchRecord.status.trim()) {
1527+
return batchRecord.status;
1528+
}
1529+
}
1530+
}
1531+
1532+
try {
1533+
return JSON.stringify(errorObj);
1534+
} catch {
1535+
return "";
1536+
}
1537+
};
1538+
15001539
const contentType = response.headers.get("content-type") ?? "";
15011540
const isJsonResponse = contentType.includes("application/json");
15021541
const isEventStreamResponse = contentType.includes("text/event-stream");
@@ -1558,27 +1597,67 @@ export async function transformAntigravityResponse(
15581597
const text = await response.text();
15591598

15601599
if (!response.ok) {
1561-
let errorBody;
1600+
let errorBody: any;
15621601
try {
15631602
errorBody = JSON.parse(text);
15641603
} catch {
15651604
errorBody = { error: { message: text } };
15661605
}
15671606

1607+
if (!errorBody || typeof errorBody !== "object") {
1608+
errorBody = { error: { message: String(errorBody ?? text) } };
1609+
}
1610+
1611+
if (!errorBody.error || typeof errorBody.error !== "object") {
1612+
errorBody.error = {
1613+
message: typeof errorBody.message === "string" ? errorBody.message : undefined,
1614+
status: typeof errorBody.status === "string" ? errorBody.status : undefined,
1615+
details: Array.isArray(errorBody.details) ? errorBody.details : undefined,
1616+
errors: Array.isArray(errorBody.errors) ? errorBody.errors : undefined,
1617+
};
1618+
}
1619+
1620+
// Extract retry headers from google.rpc.RetryInfo when present.
1621+
if (Array.isArray(errorBody?.error?.details)) {
1622+
const retryInfo = errorBody.error.details.find(
1623+
(detail: any) => detail?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
1624+
);
1625+
1626+
if (retryInfo?.retryDelay) {
1627+
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/);
1628+
if (match && match[1]) {
1629+
const retrySeconds = parseFloat(match[1]);
1630+
if (!isNaN(retrySeconds) && retrySeconds > 0) {
1631+
const retryAfterSec = Math.ceil(retrySeconds).toString();
1632+
const retryAfterMs = Math.ceil(retrySeconds * 1000).toString();
1633+
headers.set("Retry-After", retryAfterSec);
1634+
headers.set("retry-after-ms", retryAfterMs);
1635+
}
1636+
}
1637+
}
1638+
}
1639+
15681640
// Inject Debug Info
15691641
if (errorBody?.error) {
1642+
const extractedMessage = extractBestErrorMessage(errorBody.error) || "Unknown error";
15701643
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}` : ""}`;
15711644
const injectedDebug = debugText ? `\n\n${debugText}` : "";
1572-
errorBody.error.message = (errorBody.error.message || "Unknown error") + debugInfo + injectedDebug;
1573-
1574-
// Check if this is a recoverable thinking error - throw to trigger retry
1575-
const errorType = detectErrorType(errorBody.error.message || "");
1645+
errorBody.error.message = extractedMessage + debugInfo + injectedDebug;
1646+
1647+
// Signal recoverable thinking errors via headers so caller can retry without throwing.
1648+
const firstDetailReason =
1649+
Array.isArray(errorBody?.error?.details) &&
1650+
errorBody.error.details[0] &&
1651+
typeof errorBody.error.details[0] === "object"
1652+
? ((errorBody.error.details[0] as Record<string, unknown>).reason as string | undefined)
1653+
: undefined;
1654+
const errorType = detectErrorType(
1655+
[extractedMessage, typeof firstDetailReason === "string" ? firstDetailReason : ""]
1656+
.filter(Boolean)
1657+
.join(" "),
1658+
);
15761659
if (errorType === "thinking_block_order") {
1577-
const recoveryError = new Error("THINKING_RECOVERY_NEEDED");
1578-
(recoveryError as any).recoveryType = errorType;
1579-
(recoveryError as any).originalError = errorBody;
1580-
(recoveryError as any).debugInfo = debugInfo;
1581-
throw recoveryError;
1660+
headers.set("x-antigravity-recovery-needed", errorType);
15821661
}
15831662

15841663
// Detect context length / prompt too long errors - signal to caller for toast
@@ -1607,25 +1686,6 @@ export async function transformAntigravityResponse(
16071686
headers
16081687
});
16091688
}
1610-
1611-
if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) {
1612-
const retryInfo = errorBody.error.details.find(
1613-
(detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'
1614-
);
1615-
1616-
if (retryInfo?.retryDelay) {
1617-
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/);
1618-
if (match && match[1]) {
1619-
const retrySeconds = parseFloat(match[1]);
1620-
if (!isNaN(retrySeconds) && retrySeconds > 0) {
1621-
const retryAfterSec = Math.ceil(retrySeconds).toString();
1622-
const retryAfterMs = Math.ceil(retrySeconds * 1000).toString();
1623-
headers.set('Retry-After', retryAfterSec);
1624-
headers.set('retry-after-ms', retryAfterMs);
1625-
}
1626-
}
1627-
}
1628-
}
16291689
}
16301690

16311691
const init = {

0 commit comments

Comments
 (0)