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 Gateway502 Bad Gateway
cloudflare',
+ });
+ await onOutput?.({
+ status: 'success',
+ phase: 'final',
+ result:
+ 'API Error: 502 502 Bad Gateway502 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 Gateway502 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 Gateway502 Bad Gateway
cloudflare',
+ });
+ await onOutput?.({
+ status: 'success',
+ result:
+ 'API Error: 502 502 Bad Gateway502 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({