fix(engine): exclude OAuth scope errors from transient-auth retry
Address review feedback on #1911: - Greptile P1 (blocking): OAuth scope/permission failures are permanent (operator must re-authorize), so they are removed from the transient-auth classifier. A new SCOPE_ERROR_RE exclusion runs BEFORE the transient match, so scope errors wrapped in a generic {"type":"authentication_error"} envelope are also excluded instead of being retried for ~10 s. - CodeRabbit: add a test for abort during the auth-retry sleep, covering the auth-specific short-circuit (the existing abort test only exercised the rate-limit backoff path). - Add a regression test asserting scope errors (plain text, JSON-wrapped, and OAuth error codes insufficient_scope/invalid_scope) are not retried. - Add a changeset (@runfusion/fusion: patch) — engine retry behavior ships in the published CLI bundle. - Add FNXC requirement comments encoding the retry-budget invariants (separate auth budget, flat ~5 s delay, no rate-limit-attempt consumption, abort short-circuit, scope exclusion ordering). Tests: 17/17 (rate-limit-retry). tsc --noEmit clean. eslint --fix clean.
This commit is contained in:
7
.changeset/fix-transient-auth-scope-classification.md
Normal file
7
.changeset/fix-transient-auth-scope-classification.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Retry transient OAuth token-rotation errors so in-flight agent calls survive rotation without failing the task.
|
||||
category: fix
|
||||
dev: withRateLimitRetry now retries transient auth errors (authentication_error, invalid credentials, token_expired) on a separate ~5s flat-delay budget that does not consume rate-limit attempts. OAuth scope/permission failures are explicitly excluded (operator must re-authorize) so they surface immediately instead of retrying pointlessly.
|
||||
@@ -289,7 +289,6 @@ describe("withRateLimitRetry", () => {
|
||||
"Invalid authentication credentials",
|
||||
"token_expired",
|
||||
"token expired",
|
||||
"OAuth token does not meet scope requirements",
|
||||
];
|
||||
|
||||
for (const msg of patterns) {
|
||||
@@ -305,4 +304,46 @@ describe("withRateLimitRetry", () => {
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry OAuth scope errors even when wrapped in an authentication_error envelope", async () => {
|
||||
const scopeErrors = [
|
||||
"OAuth token does not meet scope requirements",
|
||||
"insufficient_scope",
|
||||
'{"type":"error","error":{"type":"authentication_error","message":"OAuth token does not meet scope requirements"}}',
|
||||
];
|
||||
|
||||
for (const msg of scopeErrors) {
|
||||
const fn = vi.fn().mockRejectedValue(new Error(msg));
|
||||
const onRetry = vi.fn();
|
||||
|
||||
await expect(withRateLimitRetry(fn, { onRetry })).rejects.toThrow(msg);
|
||||
|
||||
// Permanent scope failures must surface immediately — no retries.
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels transient-auth retry sleep when abort signal fires", async () => {
|
||||
const authErr = new Error(
|
||||
'401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
|
||||
);
|
||||
const fn = vi.fn().mockRejectedValue(authErr);
|
||||
const ac = new AbortController();
|
||||
|
||||
const promise = withRateLimitRetry(fn, {
|
||||
maxRetries: 5,
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
// Let the first call fail and enter the auth-retry sleep (~5s).
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Abort before the 5s auth delay elapses.
|
||||
ac.abort(new Error("Task paused"));
|
||||
|
||||
await expect(promise).rejects.toThrow("Task paused");
|
||||
// Only the initial call — the auth retry never fires.
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
|
||||
import { isUsageLimitError } from "./usage-limit-detector.js";
|
||||
|
||||
/*
|
||||
FNXC:EngineAuthRetry 2026-07-05-06:07:
|
||||
A long-running agent session holds its OAuth access token in memory. Claude Max access tokens rotate mid-run (~8 h lifetime); the in-flight call fails with a 401 authentication_error even though the credentials file has already been refreshed, and the very next call succeeds. Retry these a few times so a token-boundary rotation does not surface as a spurious task-failure alert. This budget is separate from the rate-limit retry budget and must not consume rate-limit attempts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Matches transient authentication failures caused by credential rotation —
|
||||
* e.g. a Claude Max OAuth access token expiring mid-run (~8 h lifetime). A
|
||||
@@ -32,10 +37,20 @@ import { isUsageLimitError } from "./usage-limit-detector.js";
|
||||
* a couple of quick retries before propagating as a task failure.
|
||||
*/
|
||||
const TRANSIENT_AUTH_ERROR_RE =
|
||||
/"type":\s*"authentication_error"|invalid authentication credentials|token[_\s]?expired|oauth token does not meet scope/i;
|
||||
/"type":\s*"authentication_error"|invalid authentication credentials|token[_\s]?expired/i;
|
||||
|
||||
/*
|
||||
FNXC:EngineAuthRetry 2026-07-05-06:07:
|
||||
OAuth scope/permission-grant failures are NOT transient — the token is valid but lacks required grants, so the operator must re-authorize the connection. Retrying would repeat the failing call for ~10 s before surfacing the real (operator-actionable) error. This exclusion runs BEFORE the transient match because providers wrap scope errors inside a generic {"type":"authentication_error"} envelope that would otherwise match TRANSIENT_AUTH_ERROR_RE and retry pointlessly.
|
||||
*/
|
||||
const SCOPE_ERROR_RE =
|
||||
/oauth token does not meet scope|insufficient[_\s-]?scope|invalid[_\s-]?scope/i;
|
||||
|
||||
function isTransientAuthError(message: string | undefined): boolean {
|
||||
return TRANSIENT_AUTH_ERROR_RE.test(message ?? "");
|
||||
const msg = message ?? "";
|
||||
// Permanent scope failures must surface immediately instead of retrying.
|
||||
if (SCOPE_ERROR_RE.test(msg)) return false;
|
||||
return TRANSIENT_AUTH_ERROR_RE.test(msg);
|
||||
}
|
||||
|
||||
/** Transient-auth retry budget — separate from the rate-limit `maxRetries`. */
|
||||
@@ -118,6 +133,10 @@ export async function withRateLimitRetry<T>(
|
||||
|
||||
lastError = error;
|
||||
|
||||
/*
|
||||
FNXC:EngineAuthRetry 2026-07-05-06:07:
|
||||
Transient-auth retries use a separate, smaller budget (AUTH_MAX_RETRIES) at a flat ~5s delay, and decrement `attempt` so they never burn a rate-limit attempt. Credential rotation completes in seconds, so the rate-limit backoff curve (30s -> 2min) would only prolong the outage. An already-aborted signal short-circuits to throw the original auth error without sleeping, matching the rate-limit path.
|
||||
*/
|
||||
if (authError) {
|
||||
if (authRetries >= AUTH_MAX_RETRIES || signal?.aborted) {
|
||||
throw lastError;
|
||||
|
||||
Reference in New Issue
Block a user