import { ChannelCapacityQueueError } from './channel-capacity-queue'; import { CHANNEL_REQUEST_MODES, type ChannelRequestMode, type ChannelRequestModeDecision } from './channel-request-mode'; import { isChannelFailure, isChannelRequestModeFailure } from './channel-router'; import { RequestValidationError } from './image-request-utils'; 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; channel_request_mode?: ChannelRequestMode; channel_request_mode_fallback_applied?: boolean; route_decision?: ChannelRequestModeDecision; selected_channel_id?: string; upstream_host?: string; upstream_status?: number; upstream_event_type?: string; partial_image_count?: number; transport_error?: boolean; transport_error_kind?: AgentTransportErrorKind; retry_after_seconds?: number; retry_after_ms?: number; cooldown_until?: string; cooldown_target?: { channel_id: string; credential_id?: string; request_mode?: ChannelRequestMode; }; channel_cooldown_scope?: 'credential' | 'channel'; response_headers?: Record; }; export type AgentTransportErrorKind = | 'dns' | 'tls' | 'connect_timeout' | 'connection_refused' | 'socket_closed' | 'upstream_timeout' | 'sse_final_missing' | 'fetch_failed' | 'unknown_transport'; export type AgentErrorBody = { error: { code: AgentErrorCode; message: string; retryable: boolean; details?: Record; 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; readonly upstreamStatus?: number; readonly retryAfterSeconds?: number; readonly diagnostics?: AgentErrorDiagnostics; constructor(options: { code: AgentErrorCode; message: string; status: number; retryable?: boolean; details?: Record; 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 upstreamEventType = base.upstream_event_type ?? readStringField(error, 'upstreamEventType'); const partialImageCount = base.partial_image_count ?? readNumberField(error, 'partialImageCount'); const transportError = base.transport_error ?? (isTransportError(error) || undefined); const transportErrorKind = base.transport_error_kind ?? classifyTransportErrorKind(error); const responseHeaders = base.response_headers ?? readWhitelistedHeaders(error); return cleanDiagnostics({ ...base, ...(upstreamStatusValue !== undefined ? { upstream_status: upstreamStatusValue } : {}), ...(upstreamEventType !== undefined ? { upstream_event_type: upstreamEventType } : {}), ...(partialImageCount !== undefined ? { partial_image_count: partialImageCount } : {}), ...(retryAfterSecondsValue !== undefined ? { retry_after_seconds: retryAfterSecondsValue } : {}), ...(transportError !== undefined ? { transport_error: transportError } : {}), ...(transportErrorKind !== undefined ? { transport_error_kind: transportErrorKind } : {}), ...(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 partialImageCount = diagnostics.partial_image_count !== undefined ? normalizeNonNegativeInteger(diagnostics.partial_image_count) : undefined; const retryAfterMs = diagnostics.retry_after_ms !== undefined ? normalizeNonNegativeInteger(diagnostics.retry_after_ms) : undefined; const cooldownTarget = cleanCooldownTarget(diagnostics.cooldown_target); const cleaned: AgentErrorDiagnostics = { ...(diagnostics.elapsed_ms !== undefined ? { elapsed_ms: Math.max(0, Math.round(diagnostics.elapsed_ms)) } : {}), ...(diagnostics.channel_request_mode ? { channel_request_mode: diagnostics.channel_request_mode } : {}), ...(diagnostics.channel_request_mode_fallback_applied !== undefined ? { channel_request_mode_fallback_applied: diagnostics.channel_request_mode_fallback_applied } : {}), ...(diagnostics.route_decision ? { route_decision: diagnostics.route_decision } : {}), ...(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.upstream_event_type ? { upstream_event_type: diagnostics.upstream_event_type } : {}), ...(partialImageCount !== undefined ? { partial_image_count: partialImageCount } : {}), ...(diagnostics.transport_error !== undefined ? { transport_error: diagnostics.transport_error } : {}), ...(diagnostics.transport_error_kind ? { transport_error_kind: diagnostics.transport_error_kind } : {}), ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}), ...(retryAfterMs !== undefined ? { retry_after_ms: retryAfterMs } : {}), ...(diagnostics.cooldown_until ? { cooldown_until: diagnostics.cooldown_until } : {}), ...(cooldownTarget ? { cooldown_target: cooldownTarget } : {}), ...(diagnostics.channel_cooldown_scope ? { channel_cooldown_scope: diagnostics.channel_cooldown_scope } : {}), ...(responseHeaders ? { response_headers: responseHeaders } : {}) }; return Object.keys(cleaned).length > 0 ? cleaned : undefined; } function cleanCooldownTarget(target: unknown): AgentErrorDiagnostics['cooldown_target'] | undefined { if (typeof target !== 'object' || target === null) return undefined; const source = target as Record; const channelId = normalizeNonEmptyString(source.channel_id); if (!channelId) return undefined; const credentialId = normalizeNonEmptyString(source.credential_id); const requestMode = normalizeDiagnosticRequestMode(source.request_mode); return { channel_id: channelId, ...(credentialId ? { credential_id: credentialId } : {}), ...(requestMode ? { request_mode: requestMode } : {}) }; } function normalizeDiagnosticRequestMode(value: unknown): ChannelRequestMode | undefined { if (typeof value !== 'string') return undefined; return CHANNEL_REQUEST_MODES.includes(value as ChannelRequestMode) ? (value as ChannelRequestMode) : undefined; } function normalizeNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : 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)[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)[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 normalizeNonNegativeInteger(value: number): number | undefined { if (!Number.isFinite(value)) return undefined; const rounded = Math.round(value); if (!Number.isSafeInteger(rounded) || rounded < 0) return undefined; return rounded; } function readWhitelistedHeaders(error: unknown): Record | undefined { const headers = readHeadersSource(error); return readWhitelistedHeaderSource(headers); } function readWhitelistedHeaderSource(headers: unknown): Record | undefined { if (!headers) return undefined; const result: Record = {}; 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; if (object.headers) return object.headers; const response = object.response; if (typeof response !== 'object' || response === null) return undefined; return (response as Record).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); const match = entries.find(([key]) => key.toLowerCase() === name); const value = match?.[1]; return typeof value === 'string' && value ? value : undefined; } function parseValidationDetails(message: string): Record | undefined { try { const parsed = JSON.parse(message); if (typeof parsed === 'object' && parsed !== null) { return parsed as Record; } } catch { return undefined; } return undefined; } function inferValidationDetails(message: string): Record | undefined { const fields: Record = {}; 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; } else if (lowerMessage.includes('partial_images')) { fields.partial_images = 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 classifyTransportErrorKind(error: unknown): AgentTransportErrorKind | undefined { const name = readStringField(error, 'name') || readConstructorName(error); const code = readStringField(error, 'code') || readCauseChainString(error, 'code'); const message = (readStringField(error, 'message') || '').trim().toLowerCase(); if ( name === 'MissingFinalImageStreamResultError' || message.includes('未返回最终图片') || message.includes('missing final') || message.includes('final image') ) { return 'sse_final_missing'; } if (isAcceptedImageTaskError(error)) { return 'upstream_timeout'; } if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'dns'; if ( code === 'CERT_HAS_EXPIRED' || code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'SELF_SIGNED_CERT_IN_CHAIN' || message.includes('certificate') || message.includes('tls') ) { return 'tls'; } if (code === 'ECONNREFUSED') return 'connection_refused'; if (code === 'ECONNRESET' || message.includes('socket hang up') || message.includes('other side closed')) { return 'socket_closed'; } if (name === 'APIConnectionTimeoutError' || code === 'ETIMEDOUT' || message.includes('connect timeout')) { return 'connect_timeout'; } if (message.includes('request timed out') || message.includes('upstream timeout') || message.includes('timeout')) { return 'upstream_timeout'; } if (message.includes('fetch failed')) return 'fetch_failed'; if (isTransportError(error)) return 'unknown_transport'; return undefined; } 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 { const diagnostics = error.retryable ? error.diagnostics : stripRetryDiagnostics(error.diagnostics); return { error: { code: error.code, message: error.message, retryable: error.retryable, ...(error.details ? { details: error.details } : {}), ...(error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {}), ...(diagnostics ? { diagnostics } : {}), request_id: requestId } }; } export function agentErrorResponse(error: AgentApiError, requestId: string): NextResponse { const headers: Record = { 'X-Request-Id': requestId }; if (error.retryable && 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 = {} ): NextResponse { 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: { ...stripTerminalRetryDiagnostics(errorBody.error), retryable: false } }; } function stripTerminalRetryDiagnostics(error: AgentErrorBody['error']): AgentErrorBody['error'] { if (!error.diagnostics?.retry_after_seconds) return error; const diagnostics = stripRetryDiagnostics(error.diagnostics); return { ...error, diagnostics }; } function stripRetryDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): AgentErrorDiagnostics | undefined { if (!diagnostics?.retry_after_seconds) return diagnostics; const stripped = { ...diagnostics }; delete stripped.retry_after_seconds; return Object.keys(stripped).length > 0 ? stripped : undefined; } export function normalizeAgentError(error: unknown, diagnostics: AgentErrorDiagnostics = {}): AgentApiError { if (error instanceof AgentApiError) return error; if (error instanceof RequestValidationError) { const details = error.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 (isAcceptedImageTaskError(error)) { return new AgentApiError({ code: 'upstream_unavailable', message, status: 502, retryable: false, upstreamStatus: status, diagnostics: buildDiagnostics(error, { ...diagnostics, ...(status !== undefined ? { upstreamStatus: status } : {}) }) }); } if (isChannelRequestModeFailure(error, diagnostics.channel_request_mode)) { const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15; return new AgentApiError({ code: 'upstream_unavailable', message, status: 502, retryable: true, upstreamStatus: 403, retryAfterSeconds, diagnostics: buildDiagnostics(error, { ...diagnostics, upstreamStatus: 403, retryAfterSeconds, channel_cooldown_scope: 'channel' }) }); } if (error instanceof ChannelCapacityQueueError) { const retryAfterSeconds = Math.max(1, Math.ceil(Number(error.details.max_wait_ms ?? 30_000) / 1000)); return new AgentApiError({ code: 'upstream_rate_limited', message, status: error.status === 499 ? 499 : 429, retryable: error.retryable, details: { code: error.code, ...error.details }, retryAfterSeconds, diagnostics: buildDiagnostics(error, { ...diagnostics, retryAfterSeconds }) }); } 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) }); } function isAcceptedImageTaskError(error: unknown): boolean { const name = readStringField(error, 'name') || readConstructorName(error); return name === 'AcceptedImageTaskResponseError' || name === 'AcceptedImageTaskStreamResultError'; }