From cf4b662aadf9fea02c874e9f6e4568c42666b6b9 Mon Sep 17 00:00:00 2001 From: hhang <17309584214@163.com> Date: Fri, 3 Jul 2026 10:58:11 +0800 Subject: [PATCH] fix(daemon): stop retrying hard quota failures (#4439) --- apps/daemon/src/run-failure-classification.ts | 12 ++++++---- apps/daemon/src/runtimes/opencode-log.ts | 11 +++++++-- apps/daemon/src/server.ts | 6 ++--- .../tests/run-failure-classification.test.ts | 23 ++++++++++++++++++- .../runtimes/opencode-log-failure.test.ts | 3 ++- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/daemon/src/run-failure-classification.ts b/apps/daemon/src/run-failure-classification.ts index d330952525..afde4ba05d 100644 --- a/apps/daemon/src/run-failure-classification.ts +++ b/apps/daemon/src/run-failure-classification.ts @@ -671,17 +671,19 @@ export function classifyRunFailure( } if (errorCode === 'RATE_LIMITED' || serviceFailure === 'RATE_LIMITED' || isHardQuotaText(text) || isRateLimitText(text)) { - const retryable = retryableHint ?? !isHardQuotaText(text); + const hardQuota = isHardQuotaText(text); + const workspaceCredits = isWorkspaceCreditsText(text); + const retryable = hardQuota ? false : (retryableHint ?? true); return classification( 'rate_limit', - isWorkspaceCreditsText(text) + workspaceCredits ? 'workspace_credits_exhausted' - : isHardQuotaText(text) + : hardQuota ? 'hard_quota' : 'rate_limit_429', 'session_init', retryable, - retryable ? 'retry' : isWorkspaceCreditsText(text) ? 'recharge' : 'none', + retryable ? 'retry' : workspaceCredits ? 'recharge' : 'none', ); } @@ -784,4 +786,4 @@ export function classifyRunFailure( retryableHint ?? false, retryableHint ? 'retry' : 'none', ); -} +} \ No newline at end of file diff --git a/apps/daemon/src/runtimes/opencode-log.ts b/apps/daemon/src/runtimes/opencode-log.ts index f55e26ce94..06c578883c 100644 --- a/apps/daemon/src/runtimes/opencode-log.ts +++ b/apps/daemon/src/runtimes/opencode-log.ts @@ -13,6 +13,7 @@ import { classifyAgentServiceFailure, type AgentServiceFailureCode } from './aut export interface OpenCodeServiceFailure { code: AgentServiceFailureCode; message: string; + retryable: boolean; statusCode: number | null; } @@ -84,6 +85,9 @@ export function readLatestOpenCodeLogTail( const SERVICE_ERROR_MESSAGE_RE = /usage limit|rate[ _-]?limit|quota|limit reached|insufficient|credit|balance|overloaded|unavailable|unauthor|authenticat|invalid[ _-]?(?:api[ _-]?)?key|api key|\/login|exhaust|too many requests/i; +const HARD_QUOTA_MESSAGE_RE = + /\b(session limit|usage limit|limit reached|quota|billing (?:hard )?limit|insufficient[ _-]?(?:quota|credit|funds)|exceeded your current quota)\b/i; + function pickServiceErrorMessage(line: string): string | null { const re = /"message":"((?:[^"\\]|\\.)*)"/g; let fallback: string | null = null; @@ -151,8 +155,11 @@ export function extractOpenCodeServiceFailure( statusCode != null ? codeFromStatus(statusCode) : null; if (!code && message) code = classifyAgentServiceFailure(message); if (!code) return null; + const retryable = code === 'UPSTREAM_UNAVAILABLE' || ( + code === 'RATE_LIMITED' && !HARD_QUOTA_MESSAGE_RE.test(message ?? '') + ); - return { code, message: message || defaultMessageForCode(code), statusCode }; + return { code, message: message || defaultMessageForCode(code), retryable, statusCode }; } // Convenience for the run close handler / inactivity watchdog: resolve the @@ -167,4 +174,4 @@ export function readOpenCodeServiceFailure( const tail = readLatestOpenCodeLogTail(logDir, options); if (!tail) return null; return extractOpenCodeServiceFailure(tail); -} +} \ No newline at end of file diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 57d261d150..41c2b452d9 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -7099,7 +7099,7 @@ export async function startServer({ stallPayload = createSseErrorPayload( logFailure.code, logFailure.message, - { retryable: true }, + { retryable: logFailure.retryable }, ); } } @@ -8608,7 +8608,7 @@ export async function startServer({ send('error', createSseErrorPayload( openCodeFailure.code, openCodeFailure.message, - { retryable: true }, + { retryable: openCodeFailure.retryable }, )); } else { const rewritten = rewriteKnownAgentStreamError( @@ -9511,4 +9511,4 @@ function sanitizeSlug(text) { .replace(/[\s_]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 64); -} +} \ No newline at end of file diff --git a/apps/daemon/tests/run-failure-classification.test.ts b/apps/daemon/tests/run-failure-classification.test.ts index 6d3b4ec7bd..9f90e8ef1a 100644 --- a/apps/daemon/tests/run-failure-classification.test.ts +++ b/apps/daemon/tests/run-failure-classification.test.ts @@ -268,6 +268,27 @@ describe('classifyRunFailure', () => { }); }); + it('does not let retryable hints override session-limit hard quota text', () => { + expect( + classify( + 'RATE_LIMITED', + "You've hit your session limit; resets at 3:10am.", + [ + errorEvent( + 'RATE_LIMITED', + "You've hit your session limit; resets at 3:10am.", + true, + ), + ], + ), + ).toMatchObject({ + failure_category: 'rate_limit', + failure_detail: 'hard_quota', + retryable: false, + user_action: 'none', + }); + }); + it('maps upstream failures to retry guidance', () => { expect(classify('UPSTREAM_UNAVAILABLE', 'HTTP 503 upstream unavailable')).toMatchObject({ failure_category: 'upstream_unavailable', @@ -1189,4 +1210,4 @@ describe('classifyRunFailure — batch A reclassification out of execution_faile expect(result?.failure_category).toBe('process_exit'); expect(result?.failure_detail).toBe('session_resume_expired'); }); -}); +}); \ No newline at end of file diff --git a/apps/daemon/tests/runtimes/opencode-log-failure.test.ts b/apps/daemon/tests/runtimes/opencode-log-failure.test.ts index 0438fd6222..00fdfdc66b 100644 --- a/apps/daemon/tests/runtimes/opencode-log-failure.test.ts +++ b/apps/daemon/tests/runtimes/opencode-log-failure.test.ts @@ -30,6 +30,7 @@ describe('extractOpenCodeServiceFailure', () => { const failure = extractOpenCodeServiceFailure(USAGE_LIMIT_LINE); expect(failure).not.toBeNull(); expect(failure!.code).toBe('RATE_LIMITED'); + expect(failure!.retryable).toBe(false); expect(failure!.statusCode).toBe(429); expect(failure!.message).toContain('Monthly usage limit reached'); expect(failure!.message).toContain('Resets in 6 days'); @@ -161,4 +162,4 @@ describe('readOpenCodeServiceFailure (end to end from env)', () => { readOpenCodeServiceFailure({ HOME: home }, { since: runStart }), ).toBeNull(); }); -}); +}); \ No newline at end of file