From a330ebdf2a2209fdf46aab51a771974283ec0898 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 26 Mar 2026 10:11:24 +0900 Subject: [PATCH] fix: suppress Claude 502 provider errors from chat output --- src/agent-error-detection.test.ts | 18 +++++ src/agent-error-detection.ts | 35 +++++++++- src/message-agent-executor.test.ts | 54 +++++++++++++++ src/provider-fallback.test.ts | 11 +++ src/streamed-output-evaluator.test.ts | 22 ++++++ src/streamed-output-evaluator.ts | 3 +- src/task-scheduler.test.ts | 98 +++++++++++++++++++++++++++ 7 files changed, 239 insertions(+), 2 deletions(-) diff --git a/src/agent-error-detection.test.ts b/src/agent-error-detection.test.ts index 1454c73..695b152 100644 --- a/src/agent-error-detection.test.ts +++ b/src/agent-error-detection.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + classifyAgentError, classifyClaudeAuthError, + detectClaudeProviderFailureMessage, isClaudeOrgAccessDeniedMessage, isNoFallbackCooldownReason, shouldRotateClaudeToken, @@ -38,6 +40,22 @@ describe('agent-error-detection', () => { }); }); + it('classifies Cloudflare 502 HTML as overloaded', () => { + const message = `API Error: 502 +502 Bad Gateway + +

502 Bad Gateway

+
cloudflare
+ +`; + + expect(classifyAgentError(message)).toEqual({ + category: 'overloaded', + reason: 'overloaded', + }); + expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded'); + }); + it('marks only Claude quota/auth reasons as Claude rotation reasons', () => { expect(shouldRotateClaudeToken('429')).toBe(true); expect(shouldRotateClaudeToken('usage-exhausted')).toBe(true); diff --git a/src/agent-error-detection.ts b/src/agent-error-detection.ts index 0d6192d..6599cf0 100644 --- a/src/agent-error-detection.ts +++ b/src/agent-error-detection.ts @@ -54,6 +54,32 @@ export function isClaudeAuthExpiredMessage(text: string): boolean { ); } +export function detectClaudeProviderFailureMessage( + text: string, +): Extract | '' { + const normalized = text.trim().toLowerCase().replace(/\s+/g, ' '); + const looksLikeProviderError = + normalized.startsWith('api error:') || + normalized.startsWith('error: api error:') || + normalized.startsWith('network error') || + normalized.startsWith('fetch failed'); + + if (!looksLikeProviderError) { + return ''; + } + + const classification = classifyAgentError(text); + if ( + classification.category === 'rate-limit' || + classification.category === 'overloaded' || + classification.category === 'network-error' + ) { + return classification.reason; + } + + return ''; +} + export function isClaudeOrgAccessDeniedMessage(text: string): boolean { const normalized = text.trim().toLowerCase().replace(/\s+/g, ' '); const hasOrgAccessDeniedMarker = normalized.includes( @@ -192,7 +218,14 @@ export function classifyAgentError( } // 503 / Overloaded - if (lower.includes('503') || lower.includes('overloaded')) { + if ( + lower.includes('503') || + lower.includes('overloaded') || + ((lower.includes('502') || lower.includes('bad gateway')) && + (lower.includes('cloudflare') || + lower.includes(' { expect(outputs).toEqual(['새 Claude 토큰 응답입니다.']); }); + it('suppresses Claude 502 HTML and falls back without forwarding it', async () => { + const outputs: string[] = []; + + vi.mocked(agentRunner.runAgentProcess) + .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'intermediate', + result: + 'API Error: 502 502 Bad Gateway

502 Bad Gateway


cloudflare
', + }); + await onOutput?.({ + status: 'success', + phase: 'final', + result: + 'API Error: 502 502 Bad Gateway

502 Bad Gateway


cloudflare
', + }); + return { + status: 'success', + result: null, + }; + }) + .mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'final', + result: 'Kimi 폴백 응답입니다.', + }); + return { + status: 'success', + result: null, + }; + }); + + const result = await runAgentForGroup(makeDeps(), { + group: makeGroup(), + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-claude-502-fallback', + onOutput: async (output) => { + if (typeof output.result === 'string') outputs.push(output.result); + }, + }); + + expect(result).toBe('success'); + expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); + expect(tokenRotation.rotateToken).not.toHaveBeenCalled(); + expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith( + 'overloaded', + undefined, + ); + expect(outputs).toEqual(['Kimi 폴백 응답입니다.']); + }); + it('stops after all Claude accounts are usage-exhausted without falling back to Kimi', async () => { const outputs: string[] = []; diff --git a/src/provider-fallback.test.ts b/src/provider-fallback.test.ts index 445ef6c..20e29d4 100644 --- a/src/provider-fallback.test.ts +++ b/src/provider-fallback.test.ts @@ -135,6 +135,17 @@ describe('provider fallback usage recovery', () => { }); }); + it('treats Cloudflare 502 HTML as an overloaded fallback trigger', () => { + expect( + detectFallbackTrigger( + 'API Error: 502 502 Bad Gateway

502 Bad Gateway


cloudflare
', + ), + ).toEqual({ + shouldFallback: true, + reason: 'overloaded', + }); + }); + it('treats Claude org access denied banners as an org-access-denied fallback trigger', () => { expect( detectFallbackTrigger( diff --git a/src/streamed-output-evaluator.test.ts b/src/streamed-output-evaluator.test.ts index bd7ebf1..f644943 100644 --- a/src/streamed-output-evaluator.test.ts +++ b/src/streamed-output-evaluator.test.ts @@ -4,6 +4,7 @@ import type { AgentOutput } from './agent-runner.js'; // ── Mocks ────────────────────────────────────────────────────── vi.mock('./agent-error-detection.js', () => ({ + detectClaudeProviderFailureMessage: vi.fn(() => ''), isClaudeUsageExhaustedMessage: vi.fn(() => false), isClaudeOrgAccessDeniedMessage: vi.fn(() => false), isClaudeAuthExpiredMessage: vi.fn(() => false), @@ -22,6 +23,7 @@ vi.mock('./codex-token-rotation.js', () => ({ })); import { + detectClaudeProviderFailureMessage, isClaudeUsageExhaustedMessage, isClaudeOrgAccessDeniedMessage, isClaudeAuthExpiredMessage, @@ -61,6 +63,7 @@ function errorOutput(error: string): AgentOutput { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(detectClaudeProviderFailureMessage).mockReturnValue(''); vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(false); vi.mocked(isClaudeOrgAccessDeniedMessage).mockReturnValue(false); vi.mocked(isClaudeAuthExpiredMessage).mockReturnValue(false); @@ -167,6 +170,25 @@ describe('evaluateStreamedOutput', () => { }); }); + describe('Claude provider failure banner', () => { + it('suppresses Cloudflare 502 HTML and returns newTrigger', () => { + vi.mocked(detectClaudeProviderFailureMessage).mockReturnValue( + 'overloaded', + ); + + const result = evaluateStreamedOutput( + successOutput('API Error: 502
cloudflare
'), + freshState(), + claudeOpts, + ); + expect(result.shouldForwardOutput).toBe(false); + expect(result.newTrigger).toEqual({ reason: 'overloaded' }); + expect(result.state.streamedTriggerReason).toEqual({ + reason: 'overloaded', + }); + }); + }); + describe('Claude org-access-denied banner', () => { it('suppresses output and returns newTrigger', () => { vi.mocked(isClaudeOrgAccessDeniedMessage).mockReturnValue(true); diff --git a/src/streamed-output-evaluator.ts b/src/streamed-output-evaluator.ts index 3b93fad..9044922 100644 --- a/src/streamed-output-evaluator.ts +++ b/src/streamed-output-evaluator.ts @@ -1,4 +1,5 @@ import { + detectClaudeProviderFailureMessage, isClaudeAuthError, isClaudeAuthExpiredMessage, isClaudeOrgAccessDeniedMessage, @@ -59,7 +60,7 @@ export function evaluateStreamedOutput( ? 'org-access-denied' : isClaudeAuthExpiredMessage(output.result) ? 'auth-expired' - : undefined; + : detectClaudeProviderFailureMessage(output.result) || undefined; if (triggerReason) { const newTrigger = nextState.streamedTriggerReason diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index aa203be..763b50a 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -564,6 +564,104 @@ Check the run. ); }); + it('suppresses Claude 502 HTML for scheduled tasks and falls back without forwarding it', async () => { + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-claude-502-fallback', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'claude-code', + prompt: 'claude task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + + (runAgentProcessMock as any) + .mockImplementationOnce( + async ( + _group: unknown, + _input: unknown, + _onProcess: unknown, + onOutput?: (output: Record) => Promise, + ) => { + await onOutput?.({ + status: 'success', + phase: 'intermediate', + result: + 'API Error: 502 502 Bad Gateway

502 Bad Gateway


cloudflare
', + }); + await onOutput?.({ + status: 'success', + result: + 'API Error: 502 502 Bad Gateway

502 Bad Gateway


cloudflare
', + }); + return { + status: 'success', + result: null, + }; + }, + ) + .mockImplementationOnce( + async ( + _group: unknown, + _input: unknown, + _onProcess: unknown, + onOutput?: (output: Record) => Promise, + ) => { + await onOutput?.({ + status: 'success', + result: 'scheduled fallback response', + }); + return { + status: 'success', + result: null, + }; + }, + ); + + const enqueueTask = vi.fn( + (_groupJid: string, _taskId: string, fn: () => Promise) => { + void fn(); + }, + ); + const sendMessage = vi.fn(async () => {}); + + startSchedulerLoop({ + serviceAgentType: 'claude-code', + registeredGroups: () => ({ + 'shared@g.us': { + name: 'Shared', + folder: 'shared-group', + trigger: '@Claude', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'claude-code', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage, + }); + + await vi.advanceTimersByTimeAsync(10); + + expect(runAgentProcessMock).toHaveBeenCalledTimes(2); + expect(tokenRotation.rotateToken).not.toHaveBeenCalled(); + expect(providerFallback.markPrimaryCooldown).toHaveBeenCalledWith( + 'overloaded', + undefined, + ); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + 'shared@g.us', + 'scheduled fallback response', + ); + }); + it('suppresses Claude org access denied banners for scheduled tasks and retries with a rotated account', async () => { const dueAt = new Date(Date.now() - 60_000).toISOString(); createTask({