visual-journal / src /lib /api-error-response.test.ts
gpt-image-playground deploy
Deploy 17eadcc to Docker Space
96bdf6c
Raw
History Blame Contribute Delete
15 kB
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);
});
});