diff --git a/src/outbound/__tests__/errors.test.ts b/src/outbound/__tests__/errors.test.ts index 8bfd806..66f4612 100644 --- a/src/outbound/__tests__/errors.test.ts +++ b/src/outbound/__tests__/errors.test.ts @@ -28,6 +28,27 @@ describe('classifyError', () => { expect(err.code).toBe('target_revoked'); }); + test('infers target_revoked from a withdrawn-message response without a Feishu code', () => { + const err = classifyError({ + response: { + status: 400, + data: { message: 'The message was withdrawn.' }, + }, + }); + expect(err.code).toBe('target_revoked'); + expect(err.message).toBe('The message was withdrawn.'); + }); + + test('does not classify an unrelated HTTP 400 as target_revoked', () => { + const err = classifyError({ + response: { + status: 400, + data: { message: 'Invalid message format.' }, + }, + }); + expect(err.code).toBe('format_error'); + }); + test('detects ssrf_blocked from error message prefix', () => { const err = classifyError(new Error('ssrf_blocked: 10.0.0.1')); expect(err.code).toBe('ssrf_blocked'); diff --git a/src/outbound/errors.ts b/src/outbound/errors.ts index 29e52d6..42e2582 100644 --- a/src/outbound/errors.ts +++ b/src/outbound/errors.ts @@ -19,7 +19,7 @@ function inferCode(err: unknown): LarkChannelErrorCode { const raw = err as any; const status = raw?.response?.status ?? raw?.status; const feishuCode = raw?.response?.data?.code ?? raw?.data?.code ?? raw?.code; - const msg = String(raw?.message ?? '').toLowerCase(); + const msg = extractMessage(err).toLowerCase(); if (typeof feishuCode === 'number') { if (feishuCode === 230020 || feishuCode === 230017) return 'target_revoked'; @@ -27,6 +27,11 @@ function inferCode(err: unknown): LarkChannelErrorCode { if (feishuCode === 230002 || feishuCode === 230001) return 'format_error'; } + // Feishu can return HTTP 400 without a numeric platform code when the + // message targeted by a reply has already been withdrawn. Classify the + // platform message before the generic HTTP 400 fallback. + if (/\bmessage\b.*\b(withdrawn|recalled)\b/.test(msg)) return 'target_revoked'; + if (status === 429) return 'rate_limited'; if (status === 401 || status === 403) return 'permission_denied'; if (status === 400) return 'format_error';