Spaces:
Running
Running
| import { | |
| AgentApiError, | |
| type AgentErrorDiagnostics, | |
| agentErrorResponse, | |
| createAgentErrorBody, | |
| normalizeAgentError, | |
| toTerminalAgentErrorBody | |
| } from './api-error-response'; | |
| import { type ChannelRequestMode } from './channel-request-mode'; | |
| import { RequestValidationError } from './image-request-utils'; | |
| import { AcceptedImageTaskResponseError } from './image-service'; | |
| import assert from 'node:assert/strict'; | |
| import { describe, it } from 'node:test'; | |
| describe('normalizeAgentError', () => { | |
| it('maps field validation errors into structured Agent errors', () => { | |
| const error = normalizeAgentError(new RequestValidationError(JSON.stringify({ fields: { n: 'bad' } }), 422)); | |
| assert.equal(error.code, 'validation_error'); | |
| assert.equal(error.status, 422); | |
| assert.equal(error.retryable, false); | |
| assert.deepEqual(error.details, { fields: { n: 'bad' } }); | |
| }); | |
| it('uses explicit RequestValidationError details without encoding them in the message', () => { | |
| const error = normalizeAgentError( | |
| new RequestValidationError('Agent edit 请求包含不支持的字段。', 422, { | |
| fields: { imageBackend: 'Agent edit 不接受该字段。' } | |
| }) | |
| ); | |
| assert.equal(error.code, 'validation_error'); | |
| assert.equal(error.message, '请求校验失败。'); | |
| assert.deepEqual(error.details, { | |
| fields: { imageBackend: 'Agent edit 不接受该字段。' } | |
| }); | |
| }); | |
| it('marks rate limits as retryable with upstream status', () => { | |
| const error = normalizeAgentError({ status: 429, message: 'rate limit' }); | |
| assert.equal(error.code, 'upstream_rate_limited'); | |
| assert.equal(error.retryable, true); | |
| assert.equal(error.upstreamStatus, 429); | |
| }); | |
| it('maps upstream image input errors to agent-correctable validation errors', () => { | |
| const error = normalizeAgentError({ | |
| status: 400, | |
| message: 'The image data you provided does not represent a valid image.' | |
| }); | |
| assert.equal(error.code, 'validation_error'); | |
| assert.equal(error.status, 422); | |
| assert.equal(error.retryable, false); | |
| assert.equal(error.upstreamStatus, 400); | |
| assert.deepEqual(error.details, { | |
| fields: { | |
| image_0: 'The image data you provided does not represent a valid image.' | |
| } | |
| }); | |
| }); | |
| it('maps upstream connection errors to retryable unavailable errors', () => { | |
| const error = normalizeAgentError( | |
| Object.assign(new Error('Connection error.'), { | |
| name: 'APIConnectionError' | |
| }) | |
| ); | |
| assert.equal(error.code, 'upstream_unavailable'); | |
| assert.equal(error.status, 502); | |
| assert.equal(error.retryable, true); | |
| assert.equal(error.retryAfterSeconds, 15); | |
| assert.equal(error.upstreamStatus, undefined); | |
| assert.equal(error.diagnostics?.transport_error, true); | |
| assert.equal(error.diagnostics?.transport_error_kind, 'unknown_transport'); | |
| assert.equal(error.diagnostics?.channel_cooldown_scope, 'channel'); | |
| }); | |
| it('maps Responses image generation disabled 403s to unavailable channel failures', () => { | |
| const error = normalizeAgentError( | |
| Object.assign(new Error('status_code=403, Image generation is not enabled for this group'), { | |
| status: 403 | |
| }), | |
| { | |
| channel_request_mode: 'responses-sse' as ChannelRequestMode | |
| } | |
| ); | |
| assert.equal(error.code, 'upstream_unavailable'); | |
| assert.equal(error.status, 502); | |
| assert.equal(error.retryable, true); | |
| assert.equal(error.upstreamStatus, 403); | |
| assert.equal(error.diagnostics?.channel_cooldown_scope, 'channel'); | |
| }); | |
| it('classifies transport failures into machine-readable kinds', () => { | |
| assert.equal( | |
| normalizeAgentError(Object.assign(new Error('fetch failed'), { code: 'ENOTFOUND' })).diagnostics | |
| ?.transport_error_kind, | |
| 'dns' | |
| ); | |
| assert.equal( | |
| normalizeAgentError(Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })).diagnostics | |
| ?.transport_error_kind, | |
| 'socket_closed' | |
| ); | |
| assert.equal( | |
| normalizeAgentError( | |
| Object.assign(new Error('流式图片响应未返回最终图片 b64_json。'), { | |
| name: 'MissingFinalImageStreamResultError', | |
| status: 502 | |
| }) | |
| ).diagnostics?.transport_error_kind, | |
| 'sse_final_missing' | |
| ); | |
| assert.equal( | |
| normalizeAgentError( | |
| Object.assign(new Error('上游返回了异步图片任务,但当前服务不支持该任务态的自动轮询。'), { | |
| name: 'AcceptedImageTaskStreamResultError', | |
| status: 502 | |
| }) | |
| ).diagnostics?.transport_error_kind, | |
| 'upstream_timeout' | |
| ); | |
| assert.equal( | |
| normalizeAgentError(new AcceptedImageTaskResponseError({ taskId: 'accepted-non-stream-task' })).diagnostics | |
| ?.transport_error_kind, | |
| 'upstream_timeout' | |
| ); | |
| }); | |
| it('maps accepted image task exhaustion to non-retryable upstream errors', () => { | |
| const error = normalizeAgentError(new AcceptedImageTaskResponseError({ taskId: 'accepted-non-stream-task' })); | |
| assert.equal(error.code, 'upstream_unavailable'); | |
| assert.equal(error.status, 502); | |
| assert.equal(error.retryable, false); | |
| assert.equal(error.retryAfterSeconds, undefined); | |
| assert.equal(error.upstreamStatus, 502); | |
| assert.equal(error.diagnostics?.transport_error_kind, 'upstream_timeout'); | |
| assert.equal(error.diagnostics?.retry_after_seconds, undefined); | |
| }); | |
| it('keeps generic upstream failures mentioning async image tasks retryable', () => { | |
| const error = normalizeAgentError( | |
| Object.assign(new Error('代理层异步图片任务日志写入失败'), { | |
| status: 502 | |
| }) | |
| ); | |
| assert.equal(error.code, 'upstream_unavailable'); | |
| assert.equal(error.status, 502); | |
| assert.equal(error.retryable, true); | |
| assert.equal(error.retryAfterSeconds, 15); | |
| assert.equal(error.upstreamStatus, 502); | |
| assert.equal(error.diagnostics?.transport_error_kind, undefined); | |
| assert.equal(error.diagnostics?.retry_after_seconds, 15); | |
| }); | |
| it('adds sanitized upstream diagnostics without inventing an HTTP status', () => { | |
| const unsafeCooldownTarget = { | |
| channel_id: ' channel-a ', | |
| credential_id: ' credential-a ', | |
| request_mode: 'images-non-stream', | |
| api_key: 'secret' | |
| } as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']>; | |
| const error = normalizeAgentError( | |
| Object.assign(new Error('Connection error.'), { | |
| name: 'APIConnectionError', | |
| headers: { | |
| 'cf-ray': 'abc-SJC', | |
| authorization: 'Bearer secret' | |
| } | |
| }), | |
| { | |
| elapsed_ms: 1234, | |
| selected_channel_id: 'channel-a', | |
| upstream_host: 'api.example.test', | |
| retry_after_ms: 15000, | |
| cooldown_until: '2026-06-11T00:00:15.000Z', | |
| cooldown_target: unsafeCooldownTarget | |
| } | |
| ); | |
| const body = createAgentErrorBody(error, 'request-2'); | |
| assert.equal(body.error.upstream_status, undefined); | |
| assert.equal(body.error.diagnostics?.elapsed_ms, 1234); | |
| assert.equal(body.error.diagnostics?.selected_channel_id, 'channel-a'); | |
| assert.equal(body.error.diagnostics?.upstream_host, 'api.example.test'); | |
| assert.equal(body.error.diagnostics?.transport_error, true); | |
| assert.equal(body.error.diagnostics?.retry_after_ms, 15000); | |
| assert.equal(body.error.diagnostics?.cooldown_until, '2026-06-11T00:00:15.000Z'); | |
| assert.deepEqual(body.error.diagnostics?.cooldown_target, { | |
| channel_id: 'channel-a', | |
| credential_id: 'credential-a', | |
| request_mode: 'images-non-stream' | |
| }); | |
| assert.deepEqual(body.error.diagnostics?.response_headers, { 'cf-ray': 'abc-SJC' }); | |
| assert.equal(JSON.stringify(body).includes('secret'), false); | |
| }); | |
| it('drops invalid cooldown target diagnostics', () => { | |
| const error = normalizeAgentError(new Error('diagnostics'), { | |
| retry_after_ms: 15000, | |
| cooldown_target: { | |
| channel_id: 'channel-a', | |
| request_mode: 'invalid-mode' | |
| } as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']> | |
| }); | |
| const body = createAgentErrorBody(error, 'request-invalid-cooldown-target'); | |
| assert.equal(body.error.diagnostics?.retry_after_ms, 15000); | |
| assert.deepEqual(body.error.diagnostics?.cooldown_target, { channel_id: 'channel-a' }); | |
| }); | |
| it('drops cooldown targets without a valid channel id', () => { | |
| const error = normalizeAgentError(new Error('diagnostics'), { | |
| retry_after_ms: 15000, | |
| cooldown_target: { | |
| channel_id: ' ', | |
| request_mode: 'images-non-stream' | |
| } as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']> | |
| }); | |
| const body = createAgentErrorBody(error, 'request-blank-cooldown-target'); | |
| assert.equal(body.error.diagnostics?.retry_after_ms, 15000); | |
| assert.equal(body.error.diagnostics?.cooldown_target, undefined); | |
| }); | |
| it('drops cooldown target diagnostics that are not objects', () => { | |
| const error = normalizeAgentError(new Error('diagnostics'), { | |
| retry_after_ms: 15000, | |
| cooldown_target: 'channel-a' as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']> | |
| }); | |
| const body = createAgentErrorBody(error, 'request-string-cooldown-target'); | |
| assert.equal(body.error.diagnostics?.retry_after_ms, 15000); | |
| assert.equal(body.error.diagnostics?.cooldown_target, undefined); | |
| }); | |
| it('filters caller-provided diagnostic response headers through the allowlist', () => { | |
| const error = normalizeAgentError(new Error('diagnostics'), { | |
| response_headers: { | |
| 'cf-ray': 'abc-SJC', | |
| authorization: 'Bearer secret', | |
| 'x-api-key': 'secret' | |
| } | |
| }); | |
| const body = createAgentErrorBody(error, 'request-3'); | |
| assert.deepEqual(body.error.diagnostics?.response_headers, { 'cf-ray': 'abc-SJC' }); | |
| assert.equal(JSON.stringify(body).includes('secret'), false); | |
| }); | |
| it('copies upstream status and retry timing into diagnostics', () => { | |
| const error = normalizeAgentError({ | |
| status: 524, | |
| message: 'timeout', | |
| headers: { | |
| 'retry-after': '7', | |
| server: 'cloudflare' | |
| } | |
| }); | |
| assert.equal(error.code, 'upstream_unavailable'); | |
| assert.equal(error.upstreamStatus, 524); | |
| assert.equal(error.retryAfterSeconds, 7); | |
| assert.equal(error.diagnostics?.upstream_status, 524); | |
| assert.equal(error.diagnostics?.retry_after_seconds, 7); | |
| assert.equal(error.diagnostics?.channel_cooldown_scope, 'channel'); | |
| assert.deepEqual(error.diagnostics?.response_headers, { | |
| 'retry-after': '7', | |
| server: 'cloudflare' | |
| }); | |
| }); | |
| it('falls back when upstream retry-after headers are unsafe', () => { | |
| const error = normalizeAgentError({ | |
| status: 429, | |
| message: 'rate limit', | |
| headers: { | |
| 'retry-after': '999999999999999999999' | |
| } | |
| }); | |
| assert.equal(error.code, 'upstream_rate_limited'); | |
| assert.equal(error.retryAfterSeconds, 30); | |
| assert.equal(error.diagnostics?.retry_after_seconds, 30); | |
| }); | |
| }); | |
| describe('createAgentErrorBody', () => { | |
| it('keeps stable error shape for agents', () => { | |
| const body = createAgentErrorBody( | |
| new AgentApiError({ | |
| code: 'idempotency_conflict', | |
| message: 'conflict', | |
| status: 409 | |
| }), | |
| 'request-1' | |
| ); | |
| assert.deepEqual(body, { | |
| error: { | |
| code: 'idempotency_conflict', | |
| message: 'conflict', | |
| retryable: false, | |
| request_id: 'request-1' | |
| } | |
| }); | |
| }); | |
| it('removes retry timing from terminal failed replays', () => { | |
| const terminal = toTerminalAgentErrorBody({ | |
| error: { | |
| code: 'upstream_unavailable', | |
| message: 'Connection error.', | |
| retryable: true, | |
| diagnostics: { | |
| retry_after_seconds: 15, | |
| transport_error: true, | |
| channel_cooldown_scope: 'channel' | |
| }, | |
| request_id: 'request-terminal' | |
| } | |
| }); | |
| assert.equal(terminal.error.retryable, false); | |
| assert.equal(terminal.error.diagnostics?.retry_after_seconds, undefined); | |
| assert.equal(terminal.error.diagnostics?.transport_error, true); | |
| assert.equal(terminal.error.diagnostics?.channel_cooldown_scope, 'channel'); | |
| }); | |
| it('removes retry timing from any non-retryable error body', () => { | |
| const body = createAgentErrorBody( | |
| new AgentApiError({ | |
| code: 'unexpected_error', | |
| message: 'terminal', | |
| status: 500, | |
| retryable: false, | |
| diagnostics: { | |
| retry_after_seconds: 10, | |
| upstream_event_type: 'image_generation.partial_image', | |
| partial_image_count: 1 | |
| } | |
| }), | |
| 'request-non-retryable' | |
| ); | |
| assert.equal(body.error.retryable, false); | |
| assert.equal(body.error.diagnostics?.retry_after_seconds, undefined); | |
| assert.equal(body.error.diagnostics?.upstream_event_type, 'image_generation.partial_image'); | |
| assert.equal(body.error.diagnostics?.partial_image_count, 1); | |
| }); | |
| it('does not emit Retry-After headers for non-retryable errors', () => { | |
| const response = agentErrorResponse( | |
| new AgentApiError({ | |
| code: 'unexpected_error', | |
| message: 'terminal', | |
| status: 500, | |
| retryable: false, | |
| retryAfterSeconds: 10 | |
| }), | |
| 'request-non-retryable-header' | |
| ); | |
| assert.equal(response.headers.get('Retry-After'), null); | |
| }); | |
| }); | |