Spaces:
Running
Running
| 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<string, string>; | |
| }; | |
| 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<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 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<string, unknown>; | |
| 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<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 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<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; | |
| } 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<AgentErrorBody> { | |
| const headers: Record<string, string> = { | |
| '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<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: { | |
| ...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'; | |
| } | |