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:
parent
cf8dfc8c37
commit
31c03b6caf
5 changed files with 106 additions and 1 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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()", () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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. ` +
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue