visual-journal / src /lib /api-error-response.ts
misonL's picture
Deploy 574ece8 to Docker Space
32151b6 verified
Raw
History Blame
16.5 kB
import { RequestValidationError } from './image-request-utils';
import { isChannelFailure } from './channel-router';
import { NextResponse } from 'next/server';
export type AgentErrorCode =
| 'validation_error'
| 'unauthorized'
| 'configuration_error'
| 'idempotency_key_required'
| 'idempotency_conflict'
| 'request_in_progress'
| 'artifact_not_found'
| 'job_not_found'
| 'job_expired'
| 'upstream_rate_limited'
| 'upstream_auth_failed'
| 'upstream_unavailable'
| 'unexpected_error';
export type AgentErrorDiagnostics = {
elapsed_ms?: number;
selected_channel_id?: string;
upstream_host?: string;
upstream_status?: number;
transport_error?: boolean;
retry_after_seconds?: number;
channel_cooldown_scope?: 'credential' | 'channel';
response_headers?: Record<string, string>;
};
export type AgentErrorBody = {
error: {
code: AgentErrorCode;
message: string;
retryable: boolean;
details?: Record<string, unknown>;
upstream_status?: number;
diagnostics?: AgentErrorDiagnostics;
request_id: string;
};
};
export function statusForAgentErrorCode(code: AgentErrorCode, upstreamStatus?: number): number {
if (code === 'validation_error') return 422;
if (code === 'unauthorized') return 401;
if (code === 'idempotency_key_required') return 400;
if (code === 'idempotency_conflict' || code === 'request_in_progress') return 409;
if (code === 'artifact_not_found') return 404;
if (code === 'job_not_found') return 404;
if (code === 'job_expired') return 410;
if (code === 'upstream_rate_limited') return 429;
if (code === 'upstream_auth_failed') return upstreamStatus === 403 ? 403 : 401;
if (code === 'upstream_unavailable') return 502;
return 500;
}
export class AgentApiError extends Error {
readonly code: AgentErrorCode;
readonly status: number;
readonly retryable: boolean;
readonly details?: Record<string, unknown>;
readonly upstreamStatus?: number;
readonly retryAfterSeconds?: number;
readonly diagnostics?: AgentErrorDiagnostics;
constructor(options: {
code: AgentErrorCode;
message: string;
status: number;
retryable?: boolean;
details?: Record<string, unknown>;
upstreamStatus?: number;
retryAfterSeconds?: number;
diagnostics?: AgentErrorDiagnostics;
}) {
super(options.message);
this.name = 'AgentApiError';
this.code = options.code;
this.status = options.status;
this.retryable = options.retryable ?? false;
this.details = options.details;
this.upstreamStatus = options.upstreamStatus;
this.retryAfterSeconds = options.retryAfterSeconds;
this.diagnostics = cleanDiagnostics(options.diagnostics);
}
}
type ErrorDiagnosticsInput = AgentErrorDiagnostics & {
upstreamStatus?: number;
retryAfterSeconds?: number;
};
const DIAGNOSTIC_HEADER_ALLOWLIST = new Set([
'content-type',
'date',
'retry-after',
'server',
'cf-ray',
'x-request-id',
'x-ratelimit-reset-requests',
'x-ratelimit-reset-tokens'
]);
const MAX_UPSTREAM_RETRY_AFTER_SECONDS = 3600;
function buildDiagnostics(error: unknown, input: ErrorDiagnosticsInput = {}): AgentErrorDiagnostics | undefined {
const { upstreamStatus, retryAfterSeconds, ...base } = input;
const upstreamStatusValue = base.upstream_status ?? upstreamStatus;
const retryAfterSecondsValue = base.retry_after_seconds ?? retryAfterSeconds ?? readRetryAfterSeconds(error);
const transportError = base.transport_error ?? (isTransportError(error) || undefined);
const responseHeaders = base.response_headers ?? readWhitelistedHeaders(error);
return cleanDiagnostics({
...base,
...(upstreamStatusValue !== undefined ? { upstream_status: upstreamStatusValue } : {}),
...(retryAfterSecondsValue !== undefined ? { retry_after_seconds: retryAfterSecondsValue } : {}),
...(transportError !== undefined ? { transport_error: transportError } : {}),
...(responseHeaders ? { response_headers: responseHeaders } : {})
});
}
function cleanDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): AgentErrorDiagnostics | undefined {
if (!diagnostics) return undefined;
const responseHeaders = readWhitelistedHeaderSource(diagnostics.response_headers);
const retryAfterSeconds =
diagnostics.retry_after_seconds !== undefined
? normalizeRetryAfterSeconds(diagnostics.retry_after_seconds)
: undefined;
const cleaned: AgentErrorDiagnostics = {
...(diagnostics.elapsed_ms !== undefined ? { elapsed_ms: Math.max(0, Math.round(diagnostics.elapsed_ms)) } : {}),
...(diagnostics.selected_channel_id ? { selected_channel_id: diagnostics.selected_channel_id } : {}),
...(diagnostics.upstream_host ? { upstream_host: diagnostics.upstream_host } : {}),
...(diagnostics.upstream_status !== undefined ? { upstream_status: diagnostics.upstream_status } : {}),
...(diagnostics.transport_error !== undefined ? { transport_error: diagnostics.transport_error } : {}),
...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
...(diagnostics.channel_cooldown_scope ? { channel_cooldown_scope: diagnostics.channel_cooldown_scope } : {}),
...(responseHeaders ? { response_headers: responseHeaders } : {})
};
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
}
function readNumberField(error: unknown, field: string): number | undefined {
if (typeof error !== 'object' || error === null || !(field in error)) return undefined;
const value = (error as Record<string, unknown>)[field];
return typeof value === 'number' ? value : undefined;
}
function readStringField(error: unknown, field: string): string | undefined {
if (typeof error !== 'object' || error === null || !(field in error)) return undefined;
const value = (error as Record<string, unknown>)[field];
return typeof value === 'string' ? value : undefined;
}
function readRetryAfterSeconds(error: unknown): number | undefined {
const fromField = readNumberField(error, 'retryAfterSeconds');
const normalizedField = fromField !== undefined ? normalizeRetryAfterSeconds(fromField) : undefined;
if (normalizedField !== undefined) return normalizedField;
const headers = readWhitelistedHeaders(error);
const retryAfter = headers?.['retry-after'];
if (!retryAfter || !/^\d+$/.test(retryAfter)) return undefined;
return normalizeRetryAfterSeconds(Number(retryAfter));
}
function normalizeRetryAfterSeconds(value: number): number | undefined {
if (!Number.isFinite(value)) return undefined;
const rounded = Math.round(value);
if (!Number.isSafeInteger(rounded) || rounded < 1 || rounded > MAX_UPSTREAM_RETRY_AFTER_SECONDS) return undefined;
return rounded;
}
function readWhitelistedHeaders(error: unknown): Record<string, string> | undefined {
const headers = readHeadersSource(error);
return readWhitelistedHeaderSource(headers);
}
function readWhitelistedHeaderSource(headers: unknown): Record<string, string> | undefined {
if (!headers) return undefined;
const result: Record<string, string> = {};
for (const name of DIAGNOSTIC_HEADER_ALLOWLIST) {
const value = readHeaderValue(headers, name);
if (value) {
result[name] = value;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function readHeadersSource(error: unknown): unknown {
if (typeof error !== 'object' || error === null) return undefined;
const object = error as Record<string, unknown>;
if (object.headers) return object.headers;
const response = object.response;
if (typeof response !== 'object' || response === null) return undefined;
return (response as Record<string, unknown>).headers;
}
function readHeaderValue(headers: unknown, name: string): string | undefined {
if (headers instanceof Headers) {
return headers.get(name) || undefined;
}
if (typeof headers !== 'object' || headers === null) return undefined;
const entries = Object.entries(headers as Record<string, unknown>);
const match = entries.find(([key]) => key.toLowerCase() === name);
const value = match?.[1];
return typeof value === 'string' && value ? value : undefined;
}
function parseValidationDetails(message: string): Record<string, unknown> | undefined {
try {
const parsed = JSON.parse(message);
if (typeof parsed === 'object' && parsed !== null) {
return parsed as Record<string, unknown>;
}
} catch {
return undefined;
}
return undefined;
}
function inferValidationDetails(message: string): Record<string, unknown> | undefined {
const fields: Record<string, string> = {};
const lowerMessage = message.toLowerCase();
if (lowerMessage.includes('response_mode')) {
fields.response_mode = message;
} else if (lowerMessage.includes('missing required parameter: prompt') || lowerMessage.includes('prompt')) {
fields.prompt = message;
} else if (lowerMessage.startsWith('n ')) {
fields.n = message;
} else if (lowerMessage.includes('model')) {
fields.model = message;
} else if (lowerMessage.includes('image file') || lowerMessage.includes('image data')) {
fields.image_0 = message;
} else if (lowerMessage.includes('mask')) {
fields.mask = message;
} else if (lowerMessage.includes('size')) {
fields.size = message;
} else if (lowerMessage.includes('quality')) {
fields.quality = message;
}
return Object.keys(fields).length > 0 ? { fields } : undefined;
}
function isTransportError(error: unknown): boolean {
const name = readStringField(error, 'name') || readConstructorName(error);
if (name === 'APIConnectionError' || name === 'APIConnectionTimeoutError') return true;
const code = readStringField(error, 'code') || readCauseChainString(error, 'code');
if (code === 'ENOTFOUND' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EAI_AGAIN') {
return true;
}
const message = (readStringField(error, 'message') || '')
.trim()
.toLowerCase()
.replace(/[.!?]+$/, '');
return message.includes('connection error') || message.includes('request timed out') || message.includes('fetch failed');
}
function readConstructorName(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null) return undefined;
const constructorValue = (error as { constructor?: unknown }).constructor;
return typeof constructorValue === 'function' ? constructorValue.name : undefined;
}
function readCauseChainString(error: unknown, fieldName: string, depth = 0): string | undefined {
if (depth > 4 || typeof error !== 'object' || error === null || !('cause' in error)) return undefined;
const cause = (error as { cause?: unknown }).cause;
return readStringField(cause, fieldName) || readCauseChainString(cause, fieldName, depth + 1);
}
export function createAgentErrorBody(error: AgentApiError, requestId: string): AgentErrorBody {
return {
error: {
code: error.code,
message: error.message,
retryable: error.retryable,
...(error.details ? { details: error.details } : {}),
...(error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {}),
...(error.diagnostics ? { diagnostics: error.diagnostics } : {}),
request_id: requestId
}
};
}
export function agentErrorResponse(error: AgentApiError, requestId: string): NextResponse<AgentErrorBody> {
const headers: Record<string, string> = {
'X-Request-Id': requestId
};
if (error.retryAfterSeconds) {
headers['Retry-After'] = String(error.retryAfterSeconds);
}
return NextResponse.json(createAgentErrorBody(error, requestId), { status: error.status, headers });
}
export function storedAgentErrorResponse(
errorBody: AgentErrorBody,
headers: Record<string, string> = {}
): NextResponse<AgentErrorBody> {
const terminalError = toTerminalAgentErrorBody(errorBody);
return NextResponse.json(terminalError, {
status: statusForAgentErrorCode(terminalError.error.code, terminalError.error.upstream_status),
headers: { ...headers, 'X-Request-Id': terminalError.error.request_id }
});
}
export function toTerminalAgentErrorBody(errorBody: AgentErrorBody): AgentErrorBody {
return {
error: {
...errorBody.error,
retryable: false
}
};
}
export function normalizeAgentError(error: unknown, diagnostics: AgentErrorDiagnostics = {}): AgentApiError {
if (error instanceof AgentApiError) return error;
if (error instanceof RequestValidationError) {
const details = parseValidationDetails(error.message) ?? inferValidationDetails(error.message);
return new AgentApiError({
code: error.status >= 500 ? 'configuration_error' : 'validation_error',
message: details ? '请求校验失败。' : error.message,
status: error.status === 400 ? 422 : error.status,
retryable: false,
...(details ? { details } : {}),
diagnostics: buildDiagnostics(error, diagnostics)
});
}
const status = readNumberField(error, 'status') ?? readNumberField(error, 'statusCode');
const message = error instanceof Error ? error.message : (readStringField(error, 'message') ?? '发生未知错误。');
if (status === 401 || status === 403) {
return new AgentApiError({
code: 'upstream_auth_failed',
message,
status,
retryable: false,
upstreamStatus: status,
diagnostics: buildDiagnostics(error, {
...diagnostics,
upstreamStatus: status,
channel_cooldown_scope: 'credential'
})
});
}
if (status === 400) {
return new AgentApiError({
code: 'validation_error',
message: '上游拒绝了请求参数。',
status: 422,
retryable: false,
details: inferValidationDetails(message),
upstreamStatus: status,
diagnostics: buildDiagnostics(error, { ...diagnostics, upstreamStatus: status })
});
}
if (status === 429) {
const retryAfterSeconds = readRetryAfterSeconds(error) ?? 30;
return new AgentApiError({
code: 'upstream_rate_limited',
message,
status,
retryable: true,
upstreamStatus: status,
retryAfterSeconds,
diagnostics: buildDiagnostics(error, {
...diagnostics,
upstreamStatus: status,
retryAfterSeconds,
channel_cooldown_scope: 'credential'
})
});
}
if (status && [500, 502, 503, 504, 520, 522, 523, 524].includes(status)) {
const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15;
return new AgentApiError({
code: 'upstream_unavailable',
message,
status: 502,
retryable: true,
upstreamStatus: status,
retryAfterSeconds,
diagnostics: buildDiagnostics(error, {
...diagnostics,
upstreamStatus: status,
retryAfterSeconds,
channel_cooldown_scope: 'channel'
})
});
}
if (isChannelFailure(error)) {
const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15;
return new AgentApiError({
code: 'upstream_unavailable',
message,
status: 502,
retryable: true,
retryAfterSeconds,
diagnostics: buildDiagnostics(error, {
...diagnostics,
retryAfterSeconds,
channel_cooldown_scope: 'channel',
transport_error: true
})
});
}
return new AgentApiError({
code: 'unexpected_error',
message,
status: 500,
retryable: false,
diagnostics: buildDiagnostics(error, diagnostics)
});
}