Fix crash on quota exhaustion for OAuth-backed providers (antigravity/Gemini) (#347)

* Initial plan

* Fix crash after antigravity quota exhaustion: catch exceptions in runLoop, avoid futile retries on quota_exhausted, better error message

Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TÂCHES <afromanguy@me.com>
Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com>
This commit is contained in:
Copilot 2026-03-14 07:03:13 -06:00 committed by GitHub
parent cf8dfc8c37
commit 31c03b6caf
5 changed files with 106 additions and 1 deletions

View file

@ -138,7 +138,36 @@ async function runLoop(
}
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
let message: AssistantMessage;
try {
message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
} catch (error) {
// Critical failure before stream started (e.g. getApiKey threw, credentials in
// backoff, network unavailable). Convert to a graceful error message so the
// agent loop can end cleanly instead of crashing with an unhandled rejection.
const errorText = error instanceof Error ? error.message : String(error);
message = {
role: "assistant",
content: [],
api: config.model.api,
provider: config.model.provider,
model: config.model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: signal?.aborted ? "aborted" : "error",
errorMessage: errorText,
timestamp: Date.now(),
};
stream.push({ type: "message_start", message: { ...message } });
stream.push({ type: "message_end", message });
currentContext.messages.push(message);
}
newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") {

View file

@ -2348,6 +2348,22 @@ export class AgentSession {
return true;
}
// All credentials are backed off. For quota-exhausted errors the backoff is very
// long (30+ min), so retrying immediately is futile and will only produce
// confusing secondary errors (e.g. "Authentication failed"). Give up now and
// surface the original quota error to the user.
if (errorType === "quota_exhausted") {
this._emit({
type: "auto_retry_end",
success: false,
attempt: this._retryAttempt,
finalError: message.errorMessage,
});
this._retryAttempt = 0;
this._resolveRetry();
return false;
}
}
this._retryAttempt++;

View file

@ -178,6 +178,44 @@ describe("AuthStorage — rate-limit backoff", () => {
});
});
// ─── areAllCredentialsBackedOff ───────────────────────────────────────────────
describe("AuthStorage — areAllCredentialsBackedOff", () => {
it("returns false when no credentials are configured", () => {
const storage = inMemory({});
assert.equal(storage.areAllCredentialsBackedOff("anthropic"), false);
});
it("returns false when credentials exist and none are backed off", async () => {
const storage = inMemory({ anthropic: makeKey("sk-abc") });
assert.equal(storage.areAllCredentialsBackedOff("anthropic"), false);
});
it("returns true when the single credential is backed off", async () => {
const storage = inMemory({ anthropic: makeKey("sk-only") });
await storage.getApiKey("anthropic");
storage.markUsageLimitReached("anthropic");
assert.equal(storage.areAllCredentialsBackedOff("anthropic"), true);
});
it("returns false when at least one credential is still available", async () => {
const storage = inMemory({ anthropic: [makeKey("sk-1"), makeKey("sk-2")] });
await storage.getApiKey("anthropic"); // uses index 0
storage.markUsageLimitReached("anthropic"); // backs off index 0
// index 1 is still available
assert.equal(storage.areAllCredentialsBackedOff("anthropic"), false);
});
it("returns true when all credentials are backed off", async () => {
const storage = inMemory({ anthropic: [makeKey("sk-1"), makeKey("sk-2")] });
await storage.getApiKey("anthropic"); // uses index 0
storage.markUsageLimitReached("anthropic"); // backs off index 0
await storage.getApiKey("anthropic"); // uses index 1
storage.markUsageLimitReached("anthropic"); // backs off index 1
assert.equal(storage.areAllCredentialsBackedOff("anthropic"), true);
});
});
// ─── getAll truncation ────────────────────────────────────────────────────────
describe("AuthStorage — getAll()", () => {

View file

@ -470,6 +470,20 @@ export class AuthStorage {
this.remove(provider);
}
/**
* Returns true when the provider has credentials configured but all of them
* are currently in a backoff window (e.g. rate-limited or quota exhausted).
* Returns false when there are no credentials or at least one is available.
*/
areAllCredentialsBackedOff(provider: string): boolean {
const credentials = this.getCredentialsForProvider(provider);
if (credentials.length === 0) return false;
for (let i = 0; i < credentials.length; i++) {
if (!this.isCredentialBackedOff(provider, i)) return false;
}
return true;
}
/**
* Check if a credential index is currently backed off.
*/

View file

@ -322,6 +322,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const model = agent.state.model;
const isOAuth = model && modelRegistry.isUsingOAuth(model);
if (isOAuth) {
// If credentials exist but are all in a backoff window (quota / rate-limit),
// surface a specific message instead of the misleading "Authentication failed".
if (modelRegistry.authStorage.areAllCredentialsBackedOff(resolvedProvider)) {
throw new Error(
`Rate limit in effect for "${resolvedProvider}". ` +
`Please wait before retrying or switch to a different model.`,
);
}
throw new Error(
`Authentication failed for "${resolvedProvider}". ` +
`Credentials may have expired or network is unavailable. ` +