Repository navigation
fix(autofix): retry a model API error instead of stranding the PR #7247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
0e1ff5b
ec6e644
cbbbfad
4451171
f3e28ec
55563e3
719991a
5522ffb
0216f01
8956e0d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -67,6 +67,63 @@ function writeFailure(workdir, message) { | |||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Classify a model-side [API Error] render as recoverable, and by CAUSE: | ||||||||||||||||||||
| // 'transient' - 429 / 5xx / rate-limit / overload / quota: self-heals on its | ||||||||||||||||||||
| // own once the limit resets, so it earns the full retry budget. | ||||||||||||||||||||
| // 'auth' - 401 / 402 / 403, or a render saying the model does not exist | ||||||||||||||||||||
| // or the key has no access: ONLY a maintainer can fix it, so | ||||||||||||||||||||
| // the workflow caps these retries low and then goes terminal | ||||||||||||||||||||
| // with the operator fix (each attempt costs an agent run AND a | ||||||||||||||||||||
| // PR comment; a hundred of them help nobody). | ||||||||||||||||||||
| // '' - terminal: reproduces identically forever (a malformed 400). | ||||||||||||||||||||
| // | ||||||||||||||||||||
| // The status code is read from its POSITION in the render (`[API Error: <code>`) | ||||||||||||||||||||
| // rather than matched anywhere in the text. Matching anywhere made permanent | ||||||||||||||||||||
| // failures look retryable: `400 Invalid value for max_tokens: must be <= 512` | ||||||||||||||||||||
| // matched a bare \b5\d\d\b, and `400 context length exceeded` matched a bare | ||||||||||||||||||||
| // `exceeded` - both reproduce forever. `exceeded` therefore only counts as part | ||||||||||||||||||||
| // of `quota`. The match is single-line ([^\]\n]) so a multi-line render cannot | ||||||||||||||||||||
| // smuggle a newline into the marker or the PR-comment headline. | ||||||||||||||||||||
| const TRANSIENT_API_ERROR = | ||||||||||||||||||||
| /rate.?limit|quota|RESOURCE_EXHAUSTED|overloaded|temporarily|too many requests|速率限制|配额|服务(?:繁忙|不可用)/i; | ||||||||||||||||||||
| // "does not exist or you do not have access to it" is the OpenAI-compatible | ||||||||||||||||||||
| // render of the same condition a 403 reports - same root cause, same fix. | ||||||||||||||||||||
| const AUTH_API_ERROR = | ||||||||||||||||||||
| /api key|do not have access|does not exist|unauthorized|forbidden/i; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function classifyApiError(render) { | ||||||||||||||||||||
| const code = render.match(/\[API Error:\s*(\d{3})\b/)?.[1]; | ||||||||||||||||||||
| if (code) { | ||||||||||||||||||||
| const status = Number(code); | ||||||||||||||||||||
| if (status === 429 || (status >= 500 && status <= 599)) return 'transient'; | ||||||||||||||||||||
| if (status === 401 || status === 402 || status === 403) return 'auth'; | ||||||||||||||||||||
| // Any other code (400, 404, ...) is permanent UNLESS the message itself | ||||||||||||||||||||
| // names an access/existence problem. | ||||||||||||||||||||
| return AUTH_API_ERROR.test(render) ? 'auth' : ''; | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The two existing 400 tests ( Failure scenario: model returns
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||
| } | ||||||||||||||||||||
| // Code-less render: fall back to the keyword arms. | ||||||||||||||||||||
| if (AUTH_API_ERROR.test(render)) return 'auth'; | ||||||||||||||||||||
| return TRANSIENT_API_ERROR.test(render) ? 'transient' : ''; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Returns { error, kind }; error is '' when nothing recoverable was found. | ||||||||||||||||||||
| // NOTE: the caller passes the last 20 KB of output, so an API error emitted | ||||||||||||||||||||
| // early in a long run can scroll out and be classified terminal. That is the | ||||||||||||||||||||
| // fail-safe direction (a missed retry, never a wrongly-retried permanent | ||||||||||||||||||||
| // failure), but detection is best-effort rather than guaranteed. | ||||||||||||||||||||
| function recoverableApiError(output) { | ||||||||||||||||||||
| const wrapped = output.match(/\[API Error:[^\]\n]*\]/g) || []; | ||||||||||||||||||||
| for (const render of wrapped.reverse()) { | ||||||||||||||||||||
| const kind = classifyApiError(render); | ||||||||||||||||||||
| if (kind) return { error: render, kind }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
| // Some quota errors are never wrapped in [API Error: ...] (e.g. Qwen OAuth | ||||||||||||||||||||
| // quota returns early before formatting) - catch the known standalone form. | ||||||||||||||||||||
| const oauth = output.match(/Qwen OAuth quota exceeded[^\n]*/i); | ||||||||||||||||||||
| if (oauth) return { error: `[API Error: ${oauth[0]}]`, kind: 'transient' }; | ||||||||||||||||||||
| return { error: '', kind: '' }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function writeHandoff(workdir, message) { | ||||||||||||||||||||
| mkdirSync(workdir, { recursive: true }); | ||||||||||||||||||||
| writeFileSync(file(workdir, 'handoff.md'), `${message}\n`); | ||||||||||||||||||||
|
|
@@ -111,10 +168,15 @@ function runQwen(options, prompt) { | |||||||||||||||||||
| settled = true; | ||||||||||||||||||||
| clearTimeout(timer); | ||||||||||||||||||||
| clearTimeout(killTimer); | ||||||||||||||||||||
| const apiErrorInfo = recoverableApiError(outputTail); | ||||||||||||||||||||
| const payload = { | ||||||||||||||||||||
| ...result, | ||||||||||||||||||||
| timedOut, | ||||||||||||||||||||
| loopDetected: loopDetected || isLoopGuardOutput(outputTail), | ||||||||||||||||||||
| // A RECOVERABLE model error means qwen never evaluated the feedback — | ||||||||||||||||||||
| // the workflow retries it rather than advancing the watermark. | ||||||||||||||||||||
| apiError: apiErrorInfo.error, | ||||||||||||||||||||
| apiErrorKind: apiErrorInfo.kind, | ||||||||||||||||||||
| }; | ||||||||||||||||||||
| if (log.destroyed) { | ||||||||||||||||||||
| resolve(payload); | ||||||||||||||||||||
|
|
@@ -227,8 +289,28 @@ if (result.error || result.signal || result.status !== 0) { | |||||||||||||||||||
| } else { | ||||||||||||||||||||
| writeFailure( | ||||||||||||||||||||
| options.workdir, | ||||||||||||||||||||
| `Qwen failed during ${options.mode}: ${detail}.`, | ||||||||||||||||||||
| `Qwen failed during ${options.mode}: ${detail}.${ | ||||||||||||||||||||
| result.apiError ? ` ${result.apiError}` : '' | ||||||||||||||||||||
| }`, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| // Only a BARE, un-evaluated API failure is retryable. A loop guard, a | ||||||||||||||||||||
| // timeout, or an agent-written failure.md is a real verdict — leave | ||||||||||||||||||||
| // those terminal even if an API-error string appears in the output tail. | ||||||||||||||||||||
| // Signals the workflow to retry (sentinel ts) instead of advancing the | ||||||||||||||||||||
| // watermark and stranding the PR. | ||||||||||||||||||||
| if (result.apiError && !result.timedOut) { | ||||||||||||||||||||
| writeFileSync( | ||||||||||||||||||||
| file(options.workdir, 'agent-api-error'), | ||||||||||||||||||||
| `${result.apiError}\n`, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| // Cause class ("transient" | "auth") — the handoff step gives a | ||||||||||||||||||||
| // self-healing transient error the full round budget, but caps an | ||||||||||||||||||||
| // auth/access error that only a maintainer can fix. | ||||||||||||||||||||
| writeFileSync( | ||||||||||||||||||||
| file(options.workdir, 'agent-api-error-kind'), | ||||||||||||||||||||
| `${result.apiErrorKind}\n`, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
wenshao marked this conversation as resolved.
Resolved
|
||||||||||||||||||||
| } | ||||||||||||||||||||
| } else { | ||||||||||||||||||||
| writeHandoff( | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The auth-specific reduced retry cap (
API_AUTH_MAX_ROUNDS) has no workflow-level test.runMarkandrunHeadlinepassAPI_ERROR_DETAILbut neverAPI_ERROR_KINDorAPI_AUTH_MAX_ROUNDS, so theCAUSE_MAXbranching, theMARK_ROUND="${MAX_ROUNDS}"terminal-round override, and theattempt N/3headline denominator are unexercised by the bash-replay tests.Concrete cost: a bug in
CAUSE_MAXcomparison, the override, or the headline template ships undetected — the auth cap silently falls back to the full 5-round budget, producing ~100 PR comments over ~17h instead of the intended ~6 over ~3h.— qwen3.7-max via Qwen Code /review