Compare commits

...

1 Commits

Author SHA1 Message Date
jaeone94
b1e6324d60 feat: add special runtime error messaging 2026-05-27 00:15:20 +09:00
15 changed files with 2181 additions and 535 deletions

View File

@@ -167,7 +167,10 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
screen.getByText('Node threw an error during execution.')
).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
})
@@ -246,9 +249,9 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
})
it('shows missing model Refresh in the section header when no model is downloadable', async () => {

View File

@@ -46,7 +46,23 @@ vi.mock('@/i18n', () => {
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.',
'errorCatalog.runtimeErrors.noCreditsCharged': 'No credits charged.',
'errorCatalog.runtimeErrors.execution_failed.title': 'Execution failed',
'errorCatalog.runtimeErrors.execution_failed.message':
'Node threw an error during execution.',
'errorCatalog.runtimeErrors.execution_failed.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.execution_failed.toastTitle':
'{nodeName} failed',
'errorCatalog.runtimeErrors.execution_failed.toastMessage':
'This node threw an error during execution. Check its inputs or try a different configuration.',
'errorCatalog.runtimeErrors.out_of_memory.title': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.message':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
'errorCatalog.runtimeErrors.out_of_memory.itemLabel': '{nodeName}',
'errorCatalog.runtimeErrors.out_of_memory.toastTitle': 'Generation failed',
'errorCatalog.runtimeErrors.out_of_memory.toastMessage':
'Not enough GPU memory. Try reducing image resolution or batch size and run again.'
}
const interpolate = (
@@ -158,6 +174,7 @@ function createErrorGroups() {
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockIsCloud.value = false
})
describe('missingPackGroups', () => {
@@ -421,7 +438,8 @@ describe('useErrorGroups', () => {
)
})
it('includes execution error from runtime errors', async () => {
it('uses credit-note general display fields for unknown runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
@@ -430,7 +448,7 @@ describe('useErrorGroups', () => {
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
exception_message: 'mat1 and mat2 shapes cannot be multiplied',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
@@ -443,15 +461,53 @@ describe('useErrorGroups', () => {
expect(execGroups.length).toBeGreaterThan(0)
if (execGroups[0].type !== 'execution') return
expect(execGroups[0].cards[0].errors[0]).toMatchObject({
message: 'RuntimeError: CUDA out of memory',
message: 'RuntimeError: mat1 and mat2 shapes cannot be multiplied',
details: 'line 1\nline 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
exceptionType: 'RuntimeError',
catalogId: 'execution_failed',
displayTitle: 'Execution failed',
displayMessage:
'Node threw an error during execution. No credits charged.',
displayItemLabel: 'KSampler',
toastTitle: 'KSampler failed',
toastMessage:
'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
})
// TODO(FE-816 overlay-redesign): Runtime execution errors intentionally
// bypass catalog display fields until targeted runtime handling lands.
expect(execGroups[0].cards[0].errors[0].displayItemLabel).toBeUndefined()
expect(execGroups[0].cards[0].errors[0].toastTitle).toBeUndefined()
})
it('adds display fields for targeted runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'torch.OutOfMemoryError',
exception_message:
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const error = execGroup.cards[0].errors[0]
expect(error.message).toContain('torch.OutOfMemoryError:')
expect(error.catalogId).toBe('out_of_memory')
expect(error.displayMessage).toBe(
'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.'
)
expect(error.displayItemLabel).toBe('KSampler')
expect(error.toastTitle).toBe('Generation failed')
})
it('includes prompt error when present', async () => {

View File

@@ -427,7 +427,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type,
isCloud
})
}
],
filterBySelection

View File

@@ -3774,6 +3774,192 @@
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
}
},
"runtimeErrors": {
"noCreditsCharged": "No credits charged.",
"execution_failed": {
"title": "Execution failed",
"message": "Node threw an error during execution.",
"itemLabel": "{nodeName}",
"toastTitle": "{nodeName} failed",
"toastMessage": "This node threw an error during execution. Check its inputs or try a different configuration."
},
"image_not_loaded": {
"title": "Image not loaded",
"message": "The system couldn't load this image.",
"itemLabel": "{nodeName}",
"toastTitle": "Input image couldn't be loaded",
"toastMessage": "The image for {nodeName} couldn't be loaded. Try adding it again."
},
"out_of_memory": {
"title": "Generation failed",
"message": "Not enough GPU memory. Try reducing image resolution or batch size and run again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation failed",
"toastMessage": "Not enough GPU memory. Try reducing image resolution or batch size and run again."
},
"content_blocked": {
"title": "Content blocked",
"message": "This request was blocked by the content moderation system. Try changing the prompt or inputs.",
"itemLabel": "{nodeName}",
"toastTitle": "Content blocked",
"toastMessage": "This request was blocked by the content moderation system. Try changing the prompt or inputs."
},
"access_required": {
"title": "Access required",
"message": "This run requires access that is not available for the current account.",
"itemLabel": "{nodeName}",
"toastTitle": "Access required",
"toastMessage": "This run requires access that is not available for the current account."
},
"model_access_error": {
"title": "Model access required",
"message": "One or more required models could not be accessed.",
"itemLabel": "{nodeName}",
"toastTitle": "Model access required",
"toastMessage": "One or more required models could not be accessed."
},
"invalid_clip_input": {
"title": "Invalid CLIP input",
"message": "The CLIP input is missing or invalid. Check the connected checkpoint or CLIP loader.",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid CLIP input",
"toastMessage": "{nodeName} has a missing or invalid CLIP input."
},
"invalid_prompt": {
"title": "Prompt is empty",
"message": "Enter a prompt before running this workflow.",
"itemLabel": "{nodeName}",
"toastTitle": "Prompt is empty",
"toastMessage": "Enter a prompt before running this workflow."
},
"invalid_workflow_request": {
"title": "Invalid workflow request",
"message": "The workflow request is invalid. Check the workflow and try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Invalid workflow request",
"toastMessage": "The workflow request is invalid. Check the workflow and try again."
},
"insufficient_credits": {
"title": "Insufficient credits",
"message": "Add credits to your account to use this node.",
"itemLabel": "{nodeName}",
"toastTitle": "Insufficient credits",
"toastMessage": "Add credits to your account to use this node."
},
"workspace_insufficient_credits": {
"title": "Insufficient credits",
"message": "Add credits to your workspace to continue.",
"itemLabel": "{nodeName}",
"toastTitle": "Insufficient credits",
"toastMessage": "Add credits to your workspace to continue."
},
"subscription_required": {
"title": "Subscription required",
"message": "Subscribe to a plan to continue running this workflow.",
"itemLabel": "{nodeName}",
"toastTitle": "Subscription required",
"toastMessage": "Subscribe to a plan to continue running this workflow."
},
"subscription_upgrade_required": {
"title": "Subscription upgrade required",
"message": "Upgrade your subscription to use the private models in this workflow.",
"details": "Private models require a subscription upgrade: {modelNames}",
"itemLabel": "{nodeName}",
"toastTitle": "Subscription upgrade required",
"toastMessage": "Upgrade your subscription to use these private models: {modelNames}."
},
"model_download_failed": {
"title": "Model download failed",
"message": "A model could not be downloaded. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Model download failed",
"toastMessage": "A model could not be downloaded. Try again."
},
"unexpected_service_error": {
"title": "Service error",
"message": "The service encountered an unexpected error. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Service error",
"toastMessage": "The service encountered an unexpected error. Try again."
},
"request_failed": {
"title": "Request failed",
"message": "The request failed before the run could complete. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Request failed",
"toastMessage": "The request failed before the run could complete. Try again."
},
"run_start_failed": {
"title": "Run could not start",
"message": "The run could not be started. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Run could not start",
"toastMessage": "The run could not be started. Try again."
},
"run_ended_unexpectedly": {
"title": "Run ended unexpectedly",
"message": "The run ended unexpectedly. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Run ended unexpectedly",
"toastMessage": "The run ended unexpectedly. Try again."
},
"sign_in_required": {
"title": "Sign in required",
"message": "Partner nodes require a Comfy account. Sign in to continue.",
"itemLabel": "{nodeName}",
"toastTitle": "Sign in to use this node",
"toastMessage": "Partner nodes require a Comfy account. Sign in to continue."
},
"rate_limited": {
"title": "Servers are busy",
"message": "High demand right now. Try again in a moment.",
"itemLabel": "{nodeName}",
"toastTitle": "Servers are busy",
"toastMessage": "High demand right now. Try again in a moment."
},
"timeout": {
"title": "Generation timed out",
"message": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation timed out",
"toastMessage": "This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again."
},
"generation_stalled": {
"title": "Generation stalled",
"message": "This workflow stopped making progress. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Generation stalled",
"toastMessage": "This workflow stopped making progress. Try running it again."
},
"preprocessing_failed": {
"title": "Preparation failed",
"message": "The workflow could not be prepared. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Preparation failed",
"toastMessage": "The workflow could not be prepared. Try running it again."
},
"preprocessing_timeout": {
"title": "Preparation timed out",
"message": "The workflow took too long to prepare. Try running it again.",
"itemLabel": "{nodeName}",
"toastTitle": "Preparation timed out",
"toastMessage": "The workflow took too long to prepare. Try running it again."
},
"server_crashed": {
"title": "Server crashed",
"message": "The server stopped while running this workflow. Try again.",
"itemLabel": "{nodeName}",
"toastTitle": "Server crashed",
"toastMessage": "The server stopped while running this workflow. Try again."
},
"server_busy": {
"title": "Servers are busy",
"message": "The servers are busy right now. Try again in a moment.",
"itemLabel": "{nodeName}",
"toastTitle": "Servers are busy",
"toastMessage": "The servers are busy right now. Try again in a moment."
}
},
"promptErrors": {
"prompt_no_outputs": {
"title": "Prompt has no outputs",
@@ -3798,15 +3984,6 @@
"prompt_outputs_failed_validation": {
"title": "Prompt validation failed",
"desc": "The workflow has invalid node inputs. Fix the highlighted nodes before running it again."
},
"image_not_loaded": {
"title": "Image not loaded",
"desc": "The system couldn't load this image."
},
"out_of_memory": {
"title": "Generation failed",
"descLocal": "Not enough GPU memory. Try reducing complexity and run again.",
"descCloud": "Not enough GPU memory. Try reducing complexity and run again. No credits charged."
}
}
},

View File

@@ -0,0 +1,40 @@
import { t, te } from '@/i18n'
// Shared i18n helpers for error catalog resolvers. These preserve the raw API
// message/details as fallbacks when a catalog key is not available. Keep this
// module folder-internal so UI code only consumes resolved display fields.
export interface ErrorResolveContext {
isCloud?: boolean
nodeDisplayName?: string
}
export type CatalogParams = Record<string, string | number>
export function translateCatalogMessage(
key: string,
fallback: string,
params?: CatalogParams
): string {
if (te(key)) return params ? t(key, params) : t(key)
if (!params) return fallback
return fallback.replace(/\{(\w+)\}/g, (match, paramName) =>
params[paramName] === undefined ? match : String(params[paramName])
)
}
export function translateOptionalCatalogMessage(
key: string,
fallback?: string,
params?: CatalogParams
): string | undefined {
if (te(key)) return params ? t(key, params) : t(key)
return fallback?.trim() ? fallback : undefined
}
export function normalizeNodeName(nodeDisplayName: string | undefined): string {
return (
nodeDisplayName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}

View File

@@ -0,0 +1,32 @@
// FE-resolved catalog IDs that either normalize multiple sources or do not map
// 1:1 to an API error type. Simple validation mappings stay with the validation
// resolver.
export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection'
export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
export const CONTENT_BLOCKED_CATALOG_ID = 'content_blocked'
export const ACCESS_REQUIRED_CATALOG_ID = 'access_required'
export const MODEL_ACCESS_ERROR_CATALOG_ID = 'model_access_error'
export const INVALID_CLIP_INPUT_CATALOG_ID = 'invalid_clip_input'
export const INVALID_PROMPT_CATALOG_ID = 'invalid_prompt'
export const INVALID_WORKFLOW_REQUEST_CATALOG_ID = 'invalid_workflow_request'
export const INSUFFICIENT_CREDITS_CATALOG_ID = 'insufficient_credits'
export const WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID =
'workspace_insufficient_credits'
export const SUBSCRIPTION_REQUIRED_CATALOG_ID = 'subscription_required'
export const SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID =
'subscription_upgrade_required'
export const MODEL_DOWNLOAD_FAILED_CATALOG_ID = 'model_download_failed'
export const UNEXPECTED_SERVICE_ERROR_CATALOG_ID = 'unexpected_service_error'
export const REQUEST_FAILED_CATALOG_ID = 'request_failed'
export const RUN_START_FAILED_CATALOG_ID = 'run_start_failed'
export const RUN_ENDED_UNEXPECTEDLY_CATALOG_ID = 'run_ended_unexpectedly'
export const SIGN_IN_REQUIRED_CATALOG_ID = 'sign_in_required'
export const RATE_LIMITED_CATALOG_ID = 'rate_limited'
export const TIMEOUT_CATALOG_ID = 'timeout'
export const GENERATION_STALLED_CATALOG_ID = 'generation_stalled'
export const PREPROCESSING_FAILED_CATALOG_ID = 'preprocessing_failed'
export const PREPROCESSING_TIMEOUT_CATALOG_ID = 'preprocessing_timeout'
export const SERVER_CRASHED_CATALOG_ID = 'server_crashed'
export const SERVER_BUSY_CATALOG_ID = 'server_busy'

View File

@@ -5,6 +5,7 @@ import {
resolveRunErrorMessage
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { i18n } from '@/i18n'
function nodeValidationError(
@@ -36,6 +37,24 @@ function requiredInputMissing(inputName?: string): NodeValidationError {
}
}
function executionError(
exceptionType: string,
exceptionMessage: string
): ExecutionErrorWsMessage {
return {
prompt_id: 'prompt-1',
timestamp: Date.now(),
node_id: '1',
node_type: 'KSampler',
executed: [],
exception_type: exceptionType,
exception_message: exceptionMessage,
traceback: [],
current_inputs: {},
current_outputs: {}
}
}
describe('errorMessageResolver', () => {
it('resolves required_input_missing to missing connection display copy', () => {
const result = resolveRunErrorMessage({
@@ -533,7 +552,8 @@ describe('errorMessageResolver', () => {
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing complexity and run again. No credits charged.'
'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.',
displayDetails: 'Workflow execution failed'
})
expect(
@@ -550,7 +570,8 @@ describe('errorMessageResolver', () => {
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing complexity and run again.'
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
displayDetails: 'Workflow execution failed'
})
expect(
@@ -566,7 +587,9 @@ describe('errorMessageResolver', () => {
).toEqual({
catalogId: 'image_not_loaded',
displayTitle: 'Image not loaded',
displayMessage: "The system couldn't load this image."
displayMessage:
"The system couldn't load this image. No credits charged.",
displayDetails: 'Failed to validate images'
})
expect(
@@ -586,6 +609,733 @@ describe('errorMessageResolver', () => {
})
})
it('resolves targeted runtime execution errors', () => {
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'KSampler',
error: executionError(
'torch.OutOfMemoryError',
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.'
)
})
).toEqual({
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.',
displayDetails:
'Allocation on device 0 failed.\nThis error means you ran out of memory on your GPU.',
displayItemLabel: 'KSampler',
toastTitle: 'Generation failed',
toastMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'Load Image',
error: executionError('ImageDownloadError', 'Failed to validate images')
})
).toMatchObject({
catalogId: 'image_not_loaded',
displayTitle: 'Image not loaded',
displayMessage:
"The system couldn't load this image. No credits charged.",
displayItemLabel: 'Load Image',
toastTitle: "Input image couldn't be loaded",
toastMessage:
"The image for Load Image couldn't be loaded. Try adding it again. No credits charged."
})
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'Load Image',
error: executionError(
'IsADirectoryError',
"[Errno 21] Is a directory: '/app/comfyui/input'"
)
})
).toMatchObject({
catalogId: 'image_not_loaded',
displayTitle: 'Image not loaded',
displayMessage:
"The system couldn't load this image. No credits charged.",
displayItemLabel: 'Load Image'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'File Reader',
error: executionError(
'RuntimeError',
"[Errno 21] Is a directory: '/tmp/not-an-input-image'"
)
})
).toMatchObject({
catalogId: 'execution_failed',
displayTitle: 'Execution failed'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: false,
nodeDisplayName: 'CLIP Text Encode',
error: executionError(
'RuntimeError',
'ERROR: clip input is invalid: None\n\nIf the clip is from a checkpoint loader node your checkpoint does not contain a valid clip or text encoder model.'
)
})
).toMatchObject({
catalogId: 'invalid_clip_input',
displayTitle: 'Invalid CLIP input',
displayMessage:
'The CLIP input is missing or invalid. Check the connected checkpoint or CLIP loader.',
displayItemLabel: 'CLIP Text Encode',
toastMessage: 'CLIP Text Encode has a missing or invalid CLIP input.'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'KSampler',
error: executionError(
'OOMError',
'Workflow execution failed due to insufficient memory (OOM). Try reducing image resolution or batch size.'
)
})
).toMatchObject({
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again. No credits charged.',
displayItemLabel: 'KSampler'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'KSampler',
error: executionError(
'RuntimeError',
'CUDA out of memory. Tried to allocate 6.00 GiB. GPU 0 has 2.00 GiB free.'
)
})
).toMatchObject({
catalogId: 'out_of_memory',
displayTitle: 'Generation failed',
displayDetails:
'CUDA out of memory. Tried to allocate 6.00 GiB. GPU 0 has 2.00 GiB free.'
})
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: false,
nodeDisplayName: 'KSampler',
error: executionError('RuntimeError', 'GPU out of memory')
})
).toMatchObject({
catalogId: 'out_of_memory',
displayMessage:
'Not enough GPU memory. Try reducing image resolution or batch size and run again.',
displayDetails: 'GPU out of memory'
})
})
it.for([
{
type: 'InsufficientFundsError',
message:
'Payment Required: Please add credits to your account to use this node.',
expected: {
catalogId: 'insufficient_credits',
displayTitle: 'Insufficient credits',
displayMessage:
'Add credits to your account to use this node. No credits charged.'
}
},
{
type: 'InsufficientFundsError',
message:
'Payment Required: Please add credits to your workspace to continue.',
expected: {
catalogId: 'workspace_insufficient_credits',
displayTitle: 'Insufficient credits',
displayMessage:
'Add credits to your workspace to continue. No credits charged.'
}
},
{
type: 'InactiveSubscriptionError',
message:
'User has no active subscription. Please subscribe to a plan to continue.',
expected: {
catalogId: 'subscription_required',
displayTitle: 'Subscription required',
displayMessage:
'Subscribe to a plan to continue running this workflow. No credits charged.'
}
},
{
type: 'RuntimeError',
message:
'the following private models require a subscription upgrade: Skullgirls_Cerebella.safetensors',
expected: {
catalogId: 'subscription_upgrade_required',
displayTitle: 'Subscription upgrade required',
displayMessage:
'Upgrade your subscription to use the private models in this workflow. No credits charged.',
displayDetails:
'Private models require a subscription upgrade: Skullgirls_Cerebella.safetensors',
toastMessage:
'Upgrade your subscription to use these private models: Skullgirls_Cerebella.safetensors. No credits charged.'
}
},
{
type: 'RuntimeError',
message: 'Unauthorized: Please login first to use this node.',
expected: {
catalogId: 'sign_in_required',
displayTitle: 'Sign in required',
displayMessage:
'Partner nodes require a Comfy account. Sign in to continue. No credits charged.'
}
},
{
type: 'RuntimeError',
message:
'Rate Limit Exceeded: The server returned 429 after all retry attempts. Please wait and try again.',
expected: {
catalogId: 'rate_limited',
displayTitle: 'Servers are busy',
displayMessage:
'High demand right now. Try again in a moment. No credits charged.'
}
}
])(
'resolves $type runtime execution errors by stable copy',
({ type, message, expected }) => {
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'API Node',
error: executionError(type, message)
})
).toMatchObject({
...expected,
displayItemLabel: 'API Node'
})
}
)
it.for([
{
type: 'ServiceError',
message: 'ServiceError: Job execution time exceeded maximum limit',
expected: {
catalogId: 'timeout',
displayTitle: 'Generation timed out',
displayMessage:
'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again. No credits charged.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: Job went too long without making any progress',
expected: {
catalogId: 'generation_stalled',
displayTitle: 'Generation stalled',
displayMessage:
'This workflow stopped making progress. Try running it again. No credits charged.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: Job has stagnated',
expected: {
catalogId: 'generation_stalled',
displayTitle: 'Generation stalled',
displayMessage:
'This workflow stopped making progress. Try running it again. No credits charged.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: RIP to the server your workflow was running on.',
expected: {
catalogId: 'server_crashed',
displayTitle: 'Server crashed',
displayMessage:
'The server stopped while running this workflow. Try again. No credits charged.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: Executor is busy with another job',
expected: {
catalogId: 'server_busy',
displayTitle: 'Servers are busy',
displayMessage:
'The servers are busy right now. Try again in a moment. No credits charged.'
}
},
{
type: 'DispatcherError',
message: 'DispatcherError: Preprocessing timed out',
expected: {
catalogId: 'preprocessing_timeout',
displayTitle: 'Preparation timed out',
displayMessage:
'The workflow took too long to prepare. Try running it again. No credits charged.'
}
},
{
type: 'DispatcherError',
message: 'DispatcherError: Preprocessing failed',
expected: {
catalogId: 'preprocessing_failed',
displayTitle: 'Preparation failed',
displayMessage:
'The workflow could not be prepared. Try running it again. No credits charged.'
}
},
{
type: 'DispatcherError',
message: 'DispatcherError: Preprocessing failed: input archive missing',
expected: {
catalogId: 'preprocessing_failed',
displayTitle: 'Preparation failed',
displayMessage:
'The workflow could not be prepared. Try running it again. No credits charged.',
displayDetails: 'Preprocessing failed: input archive missing'
}
},
{
type: 'AccessRequired',
message:
'AccessRequired: This run requires access that is not available for the current account.',
expected: {
catalogId: 'access_required',
displayTitle: 'Access required',
displayMessage:
'This run requires access that is not available for the current account. No credits charged.'
}
},
{
type: 'ModelAccessError',
message:
'ModelAccessError: One or more required models could not be accessed.',
expected: {
catalogId: 'model_access_error',
displayTitle: 'Model access required',
displayMessage:
'One or more required models could not be accessed. No credits charged.'
}
},
{
type: 'ValidationError',
message:
"ValidationError: Field 'prompt' cannot be shorter than 1 characters; was 0 characters long.",
expected: {
catalogId: 'invalid_prompt',
displayTitle: 'Prompt is empty',
displayMessage:
'Enter a prompt before running this workflow. No credits charged.'
}
},
{
type: 'ValidationError',
message: "ValidationError: Field 'prompt' cannot be empty.",
expected: {
catalogId: 'invalid_prompt',
displayTitle: 'Prompt is empty',
displayMessage:
'Enter a prompt before running this workflow. No credits charged.'
}
},
{
type: 'ValidationError',
message: 'ValidationError: The workflow request is invalid.',
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again. No credits charged.'
}
},
{
type: 'ValidationError',
message: 'ValidationError: Invalid job: missing workflow',
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again. No credits charged.'
}
},
{
type: 'ValidationError',
message: "ValidationError: Invalid workflow: missing 'prompt' field",
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again. No credits charged.'
}
},
{
type: 'ValidationError',
message:
"ValidationError: Invalid workflow: 'prompt' field must be an object",
expected: {
catalogId: 'invalid_workflow_request',
displayTitle: 'Invalid workflow request',
displayMessage:
'The workflow request is invalid. Check the workflow and try again. No credits charged.'
}
},
{
type: 'ModelDownloadError',
message:
'ModelDownloadError: the following private models require a subscription upgrade: Skullgirls_Cerebella.safetensors, alex_ahad_style_ponyxl.safetensors',
expected: {
catalogId: 'subscription_upgrade_required',
displayTitle: 'Subscription upgrade required',
displayDetails:
'Private models require a subscription upgrade: Skullgirls_Cerebella.safetensors, alex_ahad_style_ponyxl.safetensors'
}
},
{
type: 'PanicError',
message:
'PanicError: internal error during model download: runtime error: invalid memory address',
expected: {
catalogId: 'model_download_failed',
displayTitle: 'Model download failed',
displayMessage:
'A model could not be downloaded. Try again. No credits charged.'
}
},
{
type: 'PanicError',
message: 'PanicError: internal error during model download: boom',
expected: {
catalogId: 'model_download_failed',
displayTitle: 'Model download failed',
displayMessage:
'A model could not be downloaded. Try again. No credits charged.'
}
},
{
type: 'PanicError',
message: 'PanicError: panic during job execution: boom',
expected: {
catalogId: 'run_ended_unexpectedly',
displayTitle: 'Run ended unexpectedly',
displayMessage:
'The run ended unexpectedly. Try again. No credits charged.'
}
},
{
type: 'UnexpectedServiceError',
message: 'UnexpectedServiceError: Unexpected service error.',
expected: {
catalogId: 'unexpected_service_error',
displayTitle: 'Service error',
displayMessage:
'The service encountered an unexpected error. Try again. No credits charged.'
}
},
{
type: 'RequestError',
message:
'RequestError: The request failed before the run could complete.',
expected: {
catalogId: 'request_failed',
displayTitle: 'Request failed',
displayMessage:
'The request failed before the run could complete. Try again. No credits charged.'
}
},
{
type: 'PreprocessingTimeout',
message: 'PreprocessingTimeout: Preprocessing timed out.',
expected: {
catalogId: 'preprocessing_timeout',
displayTitle: 'Preparation timed out',
displayMessage:
'The workflow took too long to prepare. Try running it again. No credits charged.'
}
},
{
type: 'ServiceError',
message: 'ServiceError: The run could not be started.',
expected: {
catalogId: 'run_start_failed',
displayTitle: 'Run could not start',
displayMessage:
'The run could not be started. Try again. No credits charged.'
}
},
{
type: 'WebSocketError',
message: 'WebSocketError: Failed to start WebSocket client: EOF',
expected: {
catalogId: 'run_start_failed',
displayTitle: 'Run could not start',
displayMessage:
'The run could not be started. Try again. No credits charged.',
displayDetails: 'Failed to start WebSocket client: EOF'
}
},
{
type: 'ServiceError',
message:
'ServiceError: Failed to send prompt request: connection refused',
expected: {
catalogId: 'request_failed',
displayTitle: 'Request failed',
displayMessage:
'The request failed before the run could complete. Try again. No credits charged.',
displayDetails: 'Failed to send prompt request: connection refused'
}
},
{
type: 'ServiceError',
message:
'ServiceError: Failed to complete preparation: transition failed',
expected: {
catalogId: 'preprocessing_failed',
displayTitle: 'Preparation failed',
displayMessage:
'The workflow could not be prepared. Try running it again. No credits charged.',
displayDetails: 'Failed to complete preparation: transition failed'
}
},
{
type: 'ServiceError',
message: 'ServiceError: The run ended unexpectedly.',
expected: {
catalogId: 'run_ended_unexpectedly',
displayTitle: 'Run ended unexpectedly',
displayMessage:
'The run ended unexpectedly. Try again. No credits charged.'
}
},
{
type: 'Exception',
message: 'Exception: Servers are busy. Please try again later.',
expected: {
catalogId: 'server_busy',
displayTitle: 'Servers are busy',
displayMessage:
'The servers are busy right now. Try again in a moment. No credits charged.'
}
},
{
type: 'WebSocketError',
message:
'WebSocketError: Polling aborted due to error: API Error: {"code":"Client specified an invalid argument","error":"Generated video rejected by content moderation."}',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.'
}
},
{
type: 'Exception',
message: 'Exception: Generated video rejected by content moderation.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.'
}
},
{
type: 'Exception',
message: 'Exception: Prompt or Initial Image failed the safety checks.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.'
}
},
{
type: 'ValueError',
message:
'ValueError: The generated image was flagged for content policy violation.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.'
}
},
{
type: 'Exception',
message:
"Exception: Content filtered by Google's Responsible AI practices: safety (1 video filtered.)",
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.'
}
},
{
type: 'Exception',
message:
"Exception: Content blocked by Google's Responsible AI filters (1 video filtered).",
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.'
}
},
{
type: 'Exception',
message: 'Exception: Generated content was rejected by a safety check.',
expected: {
catalogId: 'content_blocked',
displayTitle: 'Content blocked',
displayMessage:
'This request was blocked by the content moderation system. Try changing the prompt or inputs. No credits charged.'
}
}
])(
'resolves non-node-scoped runtime failures',
({ type, message, expected }) => {
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type,
message,
details: ''
}
})
).toMatchObject(expected)
}
)
it('does not append credit copy outside cloud mode', () => {
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: false,
nodeDisplayName: 'KSampler',
error: executionError(
'ServiceError',
'Job execution time exceeded maximum limit'
)
})
).toMatchObject({
catalogId: 'timeout',
displayMessage:
'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.',
toastMessage:
'This workflow reached the maximum run time. Try reducing image resolution, batch size, or workflow length and run again.'
})
})
it('does not over-match runtime error lookalikes', () => {
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'RequestError',
message:
'RequestError: Failed to send prompt request: request returned error status 400: {"error":{"type":"prompt_outputs_failed_validation"}}',
details: ''
}
})
).toEqual({})
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'RequestError',
message:
'RequestError: Failed to send prompt request: renderer template {node}',
details: ''
}
})
).toMatchObject({
catalogId: 'request_failed',
displayTitle: 'Request failed',
displayDetails: 'Failed to send prompt request: renderer template {node}'
})
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'Exception',
message:
'Exception: Debug output mentioned the content moderation system, but no content was blocked.',
details: ''
}
})
).toEqual({})
expect(
resolveRunErrorMessage({
kind: 'prompt',
isCloud: true,
error: {
type: 'ModelDownloadError',
message:
'ModelDownloadError: the following private models require a subscription upgrade:',
details: ''
}
})
).toEqual({})
})
it('resolves unknown node execution errors to the general runtime fallback', () => {
expect(
resolveRunErrorMessage({
kind: 'execution',
isCloud: true,
nodeDisplayName: 'KSampler',
error: executionError(
'RuntimeError',
'mat1 and mat2 shapes cannot be multiplied'
)
})
).toEqual({
catalogId: 'execution_failed',
displayTitle: 'Execution failed',
displayMessage:
'Node threw an error during execution. No credits charged.',
displayItemLabel: 'KSampler',
toastTitle: 'KSampler failed',
toastMessage:
'This node threw an error during execution. Check its inputs or try a different configuration. No credits charged.'
})
})
it('resolves missing error group display copy', () => {
expect(
resolveMissingErrorMessage({

View File

@@ -1,514 +1,13 @@
import type {
MissingErrorMessageSource,
NodeValidationError,
ResolvedErrorMessage,
ResolvedMissingErrorMessage,
RunErrorMessageSource
} from './types'
import { st, t, te } from '@/i18n'
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
const REQUIRED_INPUT_MISSING_CATALOG_ID = 'missing_connection'
const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error',
'missing_node_type',
'prompt_outputs_failed_validation'
])
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
interface ValidationCatalogRule {
catalogId: string
itemLabel: 'node' | 'nodeInput'
copyKeys?: CopyKeys
}
interface ErrorResolveContext {
isCloud?: boolean
nodeDisplayName?: string
}
type CatalogParams = Record<string, string | number>
function translateCatalogMessage(
key: string,
fallback: string,
params?: CatalogParams
): string {
if (te(key)) return params ? t(key, params) : t(key)
if (!params) return fallback
return fallback.replace(/\{(\w+)\}/g, (match, paramName) =>
params[paramName] === undefined ? match : String(params[paramName])
)
}
function translateOptionalCatalogMessage(
key: string,
fallback?: string,
params?: CatalogParams
): string | undefined {
if (te(key)) return params ? t(key, params) : t(key)
return fallback?.trim() ? fallback : undefined
}
function normalizeNodeName(nodeDisplayName: string | undefined): string {
return (
nodeDisplayName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
)
}
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
inputName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
)
}
function getErrorText(error: NodeValidationError) {
return [
'message' in error ? error.message : undefined,
'details' in error ? error.details : undefined
]
.filter(Boolean)
.join('\n')
}
function isImageNotLoadedText(text: string): boolean {
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
}
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
return (
error.type === 'custom_validation_failed' &&
isImageNotLoadedText(getErrorText(error))
)
}
function nodeInputItemLabel(nodeName: string, inputName: string): string {
return `${nodeName} - ${inputName}`
}
function formatDependencyCycleDetails(details: string): string {
// Core reports dependency cycle paths as "node -> node"; catalog copy embeds
// those paths in prose, where "to" reads more naturally.
return details.replace(/\s*->\s*/g, ' to ')
}
function formatCatalogValue(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
try {
return JSON.stringify(value)
} catch {
return undefined
}
}
function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
const config = inputConfig[1]
if (!config || typeof config !== 'object') return undefined
return formatCatalogValue((config as Record<string, unknown>)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
return formatCatalogValue(inputConfig[0])
}
function getValidationParams(
error: NodeValidationError,
nodeName: string,
inputName: string
): CatalogParams {
const params: CatalogParams = { nodeName, inputName }
const receivedValue = formatCatalogValue(error.extra_info?.received_value)
const receivedType = formatCatalogValue(error.extra_info?.received_type)
const expectedType = getInputConfigType(error)
const minValue = getInputConfigValue(error, 'min')
const maxValue = getInputConfigValue(error, 'max')
if (receivedValue !== undefined) params.receivedValue = receivedValue
if (receivedType !== undefined) params.receivedType = receivedType
if (expectedType !== undefined) params.expectedType = expectedType
if (minValue !== undefined) params.minValue = minValue
if (maxValue !== undefined) params.maxValue = maxValue
return params
}
function hasParams(params: CatalogParams, keys: string[]): boolean {
return keys.every((key) => params[key] !== undefined)
}
interface CopyKeys {
detailsKey: string
toastMessageKey: string
}
const DEFAULT_COPY_KEYS: CopyKeys = {
detailsKey: 'details',
toastMessageKey: 'toastMessage'
}
const VALUE_SPECIFIC_COPY_RULES: Record<
string,
{
requiredParams: string[]
suffix: 'WithTypes' | 'WithValue'
}
> = {
return_type_mismatch: {
requiredParams: ['expectedType', 'receivedType'],
suffix: 'WithTypes'
},
invalid_input_type: {
requiredParams: ['receivedValue', 'expectedType'],
suffix: 'WithValue'
},
value_smaller_than_min: {
requiredParams: ['receivedValue', 'minValue'],
suffix: 'WithValue'
},
value_bigger_than_max: {
requiredParams: ['receivedValue', 'maxValue'],
suffix: 'WithValue'
},
value_not_in_list: {
requiredParams: ['receivedValue'],
suffix: 'WithValue'
}
}
function getValueSpecificCopyKeys(
errorType: string,
params: CatalogParams
): CopyKeys {
const rule = VALUE_SPECIFIC_COPY_RULES[errorType]
if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS
return {
detailsKey: `details${rule.suffix}`,
toastMessageKey: `toastMessage${rule.suffix}`
}
}
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
return error.details.trim()
? {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessageWithRawDetails'
}
: DEFAULT_COPY_KEYS
}
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
if (!error.details.trim()) return DEFAULT_COPY_KEYS
return {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessage'
}
}
function getValidationCopyKeys(
error: NodeValidationError,
params: CatalogParams
): CopyKeys {
if (error.type === 'exception_during_validation') {
return getRawDetailsCopyKeys(error)
}
if (error.type === 'exception_during_inner_validation') {
return getRawDetailsCopyKeys(error)
}
if (error.type === 'custom_validation_failed') {
return getRawDetailsOnlyCopyKeys(error)
}
if (error.type === 'dependency_cycle') {
return getRawDetailsOnlyCopyKeys(error)
}
return getValueSpecificCopyKeys(error.type, params)
}
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
[REQUIRED_INPUT_MISSING_TYPE]: {
catalogId: REQUIRED_INPUT_MISSING_CATALOG_ID,
itemLabel: 'nodeInput'
},
bad_linked_input: {
catalogId: 'bad_linked_input',
itemLabel: 'nodeInput'
},
return_type_mismatch: {
catalogId: 'return_type_mismatch',
itemLabel: 'nodeInput'
},
invalid_input_type: {
catalogId: 'invalid_input_type',
itemLabel: 'nodeInput'
},
value_smaller_than_min: {
catalogId: 'value_smaller_than_min',
itemLabel: 'nodeInput'
},
value_bigger_than_max: {
catalogId: 'value_bigger_than_max',
itemLabel: 'nodeInput'
},
value_not_in_list: {
catalogId: 'value_not_in_list',
itemLabel: 'nodeInput'
},
custom_validation_failed: {
catalogId: 'custom_validation_failed',
itemLabel: 'nodeInput'
},
exception_during_inner_validation: {
catalogId: 'exception_during_inner_validation',
itemLabel: 'nodeInput'
},
exception_during_validation: {
catalogId: 'exception_during_validation',
itemLabel: 'node'
},
dependency_cycle: {
catalogId: 'dependency_cycle',
itemLabel: 'node'
}
}
// Image-not-loaded shares the custom_validation_failed type, so it needs a
// predicate override to use image_not_loaded locale copy and default copy keys.
const IMAGE_NOT_LOADED_VALIDATION_RULE = {
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
itemLabel: 'node',
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
function resolveValidationCatalogCopy(
error: NodeValidationError,
context: ErrorResolveContext,
localeKey: string,
rule: ValidationCatalogRule
): ResolvedErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const trimmedDetails = error.details.trim()
const rawDetails =
error.type === 'dependency_cycle'
? formatDependencyCycleDetails(trimmedDetails)
: trimmedDetails
const params = {
...getValidationParams(error, nodeName, inputName),
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
const titleFallback = error.message || error.type
const itemLabelFallback =
rule.itemLabel === 'node'
? nodeName
: nodeInputItemLabel(nodeName, inputName)
const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params)
return {
catalogId: rule.catalogId,
displayTitle: translateCatalogMessage(
`${keyPrefix}.title`,
titleFallback,
params
),
displayMessage: translateCatalogMessage(
`${keyPrefix}.message`,
error.message,
params
),
displayDetails: translateOptionalCatalogMessage(
`${keyPrefix}.${copyKeys.detailsKey}`,
error.details,
params
),
displayItemLabel: translateCatalogMessage(
`${keyPrefix}.itemLabel`,
itemLabelFallback,
params
),
toastTitle: translateCatalogMessage(
`${keyPrefix}.toastTitle`,
titleFallback,
params
),
toastMessage: translateCatalogMessage(
`${keyPrefix}.${copyKeys.toastMessageKey}`,
error.message,
params
)
}
}
function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
context,
'image_not_loaded',
IMAGE_NOT_LOADED_VALIDATION_RULE
)
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}
function resolvePromptErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
context: ErrorResolveContext
): ResolvedErrorMessage {
if (error.type === 'ImageDownloadError') {
return {
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
displayTitle: st(
'errorCatalog.promptErrors.image_not_loaded.title',
error.message
),
displayMessage: st(
'errorCatalog.promptErrors.image_not_loaded.desc',
error.message
)
}
}
if (error.type === 'OOMError') {
const messageKey = context.isCloud
? 'errorCatalog.promptErrors.out_of_memory.descCloud'
: 'errorCatalog.promptErrors.out_of_memory.descLocal'
return {
catalogId: OUT_OF_MEMORY_CATALOG_ID,
displayTitle: st(
'errorCatalog.promptErrors.out_of_memory.title',
error.message
),
displayMessage: st(messageKey, error.message)
}
}
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
const errorTypeKey =
error.type === 'server_error'
? context.isCloud
? 'server_error_cloud'
: 'server_error_local'
: error.type
return {
displayTitle: translateCatalogMessage(
`errorCatalog.promptErrors.${errorTypeKey}.title`,
error.message
),
displayMessage: st(
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
error.message
)
}
}
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
}
export function resolveMissingErrorMessage(
source: MissingErrorMessageSource
): ResolvedMissingErrorMessage {
switch (source.kind) {
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
}
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
}
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
}
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
}
}
}
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export function resolveRunErrorMessage(
source: RunErrorMessageSource
@@ -522,5 +21,10 @@ export function resolveRunErrorMessage(
return resolvePromptErrorMessage(source.error, {
isCloud: source.isCloud
})
case 'execution':
return resolveExecutionErrorMessage(source.error, {
nodeDisplayName: source.nodeDisplayName,
isCloud: source.isCloud
})
}
}

View File

@@ -0,0 +1,37 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
import type { ErrorResolveContext } from './catalogI18n'
import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
// Resolves node-scoped runtime failures while preserving raw API fields.
export function resolveExecutionErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
context: ErrorResolveContext
): ResolvedErrorMessage {
const exceptionMessage = error.exception_message.trim()
const match = resolveRuntimeCatalogMatch({
exceptionType: error.exception_type,
exceptionMessage
})
if (!match) {
return resolveRuntimeCatalogCopy(
EXECUTION_FAILED_CATALOG_ID,
error.exception_message,
context,
{ includeItemLabel: true }
)
}
return resolveRuntimeCatalogCopy(
match.catalogId,
error.exception_message,
context,
{
includeItemLabel: true,
params: match.params,
detailsFallback: match.detailsFallback
}
)
}

View File

@@ -0,0 +1,78 @@
import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { st, t } from '@/i18n'
// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These
// are grouped catalog messages rather than individual execution error items.
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function translateMissingModelOverlayMessage(count: number): string {
const translated = t('errorOverlay.missingModels', { count }, count)
return translated === 'errorOverlay.missingModels'
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
: translated
}
export function resolveMissingErrorMessage(
source: MissingErrorMessageSource
): ResolvedMissingErrorMessage {
switch (source.kind) {
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: st(
'errorOverlay.missingNodes',
'Some nodes are missing and need to be installed'
)
}
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: st(
'errorOverlay.swapNodes',
'Some nodes can be replaced with alternatives'
)
}
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: translateMissingModelOverlayMessage(source.count)
}
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: st(
'errorOverlay.missingMedia',
'Some nodes are missing required inputs'
)
}
}
}

View File

@@ -0,0 +1,72 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type { ErrorResolveContext } from './catalogI18n'
import { translateCatalogMessage } from './catalogI18n'
import { resolveRuntimeCatalogCopy } from './runtimeErrorCopy'
import { resolveRuntimeCatalogMatch } from './runtimeErrorMatcher'
import { st } from '@/i18n'
// Resolves prompt-level errors and non-node-scoped failures before falling
// back to prompt-specific catalog keys.
const KNOWN_PROMPT_ERROR_TYPES = new Set([
'prompt_no_outputs',
'no_prompt',
'server_error',
'missing_node_type',
'prompt_outputs_failed_validation'
])
function getPromptExceptionMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error']
): string {
const message = error.message.trim()
const prefixedType = `${error.type}: `
return message.startsWith(prefixedType)
? message.slice(prefixedType.length).trim()
: message
}
export function resolvePromptErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'prompt' }>['error'],
context: ErrorResolveContext
): ResolvedErrorMessage {
const promptExceptionMessage = getPromptExceptionMessage(error)
const runtimeMatch = resolveRuntimeCatalogMatch({
exceptionType: error.type,
exceptionMessage: promptExceptionMessage
})
if (runtimeMatch) {
// Leave toast copy to node-scoped errors where a node-specific
// action/message is safe.
return resolveRuntimeCatalogCopy(
runtimeMatch.catalogId,
promptExceptionMessage || error.message,
context,
{
includeToast: false,
params: runtimeMatch.params,
detailsFallback: runtimeMatch.detailsFallback
}
)
}
if (!KNOWN_PROMPT_ERROR_TYPES.has(error.type)) return {}
const errorTypeKey =
error.type === 'server_error'
? context.isCloud
? 'server_error_cloud'
: 'server_error_local'
: error.type
return {
displayTitle: translateCatalogMessage(
`errorCatalog.promptErrors.${errorTypeKey}.title`,
error.message
),
displayMessage: st(
`errorCatalog.promptErrors.${errorTypeKey}.desc`,
error.message
)
}
}

View File

@@ -0,0 +1,136 @@
import type { ResolvedErrorMessage } from './types'
import {
ACCESS_REQUIRED_CATALOG_ID,
CONTENT_BLOCKED_CATALOG_ID,
EXECUTION_FAILED_CATALOG_ID,
GENERATION_STALLED_CATALOG_ID,
IMAGE_NOT_LOADED_CATALOG_ID,
INSUFFICIENT_CREDITS_CATALOG_ID,
INVALID_CLIP_INPUT_CATALOG_ID,
INVALID_PROMPT_CATALOG_ID,
INVALID_WORKFLOW_REQUEST_CATALOG_ID,
MODEL_ACCESS_ERROR_CATALOG_ID,
MODEL_DOWNLOAD_FAILED_CATALOG_ID,
OUT_OF_MEMORY_CATALOG_ID,
PREPROCESSING_FAILED_CATALOG_ID,
PREPROCESSING_TIMEOUT_CATALOG_ID,
RATE_LIMITED_CATALOG_ID,
REQUEST_FAILED_CATALOG_ID,
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
RUN_START_FAILED_CATALOG_ID,
SERVER_BUSY_CATALOG_ID,
SERVER_CRASHED_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
TIMEOUT_CATALOG_ID,
UNEXPECTED_SERVICE_ERROR_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
translateCatalogMessage,
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
const NO_CREDITS_CHARGED_KEY = 'errorCatalog.runtimeErrors.noCreditsCharged'
const NO_CREDITS_CHARGED_FALLBACK = 'No credits charged.'
// Keep this opt-in so the credit note is only shown for catalog IDs whose
// product copy explicitly supports it.
const NO_CREDITS_CHARGED_RUNTIME_CATALOG_IDS = new Set([
ACCESS_REQUIRED_CATALOG_ID,
CONTENT_BLOCKED_CATALOG_ID,
EXECUTION_FAILED_CATALOG_ID,
GENERATION_STALLED_CATALOG_ID,
IMAGE_NOT_LOADED_CATALOG_ID,
INSUFFICIENT_CREDITS_CATALOG_ID,
INVALID_CLIP_INPUT_CATALOG_ID,
INVALID_PROMPT_CATALOG_ID,
INVALID_WORKFLOW_REQUEST_CATALOG_ID,
MODEL_ACCESS_ERROR_CATALOG_ID,
MODEL_DOWNLOAD_FAILED_CATALOG_ID,
OUT_OF_MEMORY_CATALOG_ID,
PREPROCESSING_FAILED_CATALOG_ID,
PREPROCESSING_TIMEOUT_CATALOG_ID,
RATE_LIMITED_CATALOG_ID,
REQUEST_FAILED_CATALOG_ID,
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
RUN_START_FAILED_CATALOG_ID,
SERVER_BUSY_CATALOG_ID,
SERVER_CRASHED_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
TIMEOUT_CATALOG_ID,
UNEXPECTED_SERVICE_ERROR_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
])
function appendNoCreditsChargedIfNeeded(
catalogId: string,
message: string,
context: ErrorResolveContext
): string {
if (
!context.isCloud ||
!NO_CREDITS_CHARGED_RUNTIME_CATALOG_IDS.has(catalogId)
)
return message
const note = translateCatalogMessage(
NO_CREDITS_CHARGED_KEY,
NO_CREDITS_CHARGED_FALLBACK
)
return message.includes(note) ? message : `${message} ${note}`
}
// Builds resolved display fields while callers keep the raw API message/details
// on the ErrorItem.
export function resolveRuntimeCatalogCopy(
catalogId: string,
fallbackMessage: string,
context: ErrorResolveContext,
options: {
includeItemLabel?: boolean
includeToast?: boolean
params?: CatalogParams
detailsFallback?: string
} = {}
): ResolvedErrorMessage {
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
const nodeName = normalizeNodeName(context.nodeDisplayName)
const params = { nodeName, ...options.params }
const resolveMessage = (suffix: string, fallback = fallbackMessage) =>
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
const addCloudCreditNote = (message: string) =>
appendNoCreditsChargedIfNeeded(catalogId, message, context)
const displayMessage = resolveMessage('message')
const result: ResolvedErrorMessage = {
catalogId,
displayTitle: resolveMessage('title'),
displayMessage: addCloudCreditNote(displayMessage)
}
if (options.includeToast !== false) {
const toastMessage = resolveMessage('toastMessage')
result.toastTitle = resolveMessage('toastTitle')
result.toastMessage = addCloudCreditNote(toastMessage)
}
const displayDetails = translateOptionalCatalogMessage(
`${keyPrefix}.details`,
options.detailsFallback,
params
)
if (displayDetails) result.displayDetails = displayDetails
if (options.includeItemLabel) {
result.displayItemLabel = resolveMessage('itemLabel', nodeName)
}
return result
}

View File

@@ -0,0 +1,397 @@
import {
ACCESS_REQUIRED_CATALOG_ID,
CONTENT_BLOCKED_CATALOG_ID,
GENERATION_STALLED_CATALOG_ID,
IMAGE_NOT_LOADED_CATALOG_ID,
INSUFFICIENT_CREDITS_CATALOG_ID,
INVALID_CLIP_INPUT_CATALOG_ID,
INVALID_PROMPT_CATALOG_ID,
INVALID_WORKFLOW_REQUEST_CATALOG_ID,
MODEL_ACCESS_ERROR_CATALOG_ID,
MODEL_DOWNLOAD_FAILED_CATALOG_ID,
OUT_OF_MEMORY_CATALOG_ID,
PREPROCESSING_FAILED_CATALOG_ID,
PREPROCESSING_TIMEOUT_CATALOG_ID,
RATE_LIMITED_CATALOG_ID,
REQUEST_FAILED_CATALOG_ID,
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
RUN_START_FAILED_CATALOG_ID,
SERVER_BUSY_CATALOG_ID,
SERVER_CRASHED_CATALOG_ID,
SIGN_IN_REQUIRED_CATALOG_ID,
SUBSCRIPTION_REQUIRED_CATALOG_ID,
SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID,
TIMEOUT_CATALOG_ID,
UNEXPECTED_SERVICE_ERROR_CATALOG_ID,
WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID
} from './catalogIds'
import type { CatalogParams } from './catalogI18n'
// Runtime errors can share generic exception labels, so targeted cataloging
// relies on narrow stable messages. Keep these matches exact or prefix-based.
const INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your account to use this node.'
])
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your workspace to continue.'
])
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
'Workspace has no active subscription. Please subscribe to a plan to continue.',
'User has no active subscription. Please subscribe to a plan to continue.'
])
const SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX =
'the following private models require a subscription upgrade:'
const TIMEOUT_MESSAGES = new Set(['Job execution time exceeded maximum limit'])
const GENERATION_STALLED_MESSAGES = new Set([
'Job went too long without making any progress',
'Job has stagnated'
])
const SERVER_CRASHED_MESSAGES = new Set([
'RIP to the server your workflow was running on.',
'Inference service restarted, terminating job',
'Job stuck in erroring state, forcing terminal transition',
'Job was previously marked as lost and has now been acknowledged by inference service'
])
const SERVER_BUSY_MESSAGES = new Set([
'Failed to enqueue job for processing',
'Executor is busy with another job',
'Servers are busy. Please try again later.'
])
const INVALID_WORKFLOW_REQUEST_MESSAGES = new Set([
'The workflow request is invalid.',
'Invalid job: missing workflow',
"Invalid workflow: missing 'prompt' field",
"Invalid workflow: 'prompt' field must be an object"
])
const ACCESS_REQUIRED_MESSAGE =
'This run requires access that is not available for the current account.'
const MODEL_ACCESS_ERROR_MESSAGE =
'One or more required models could not be accessed.'
const UNEXPECTED_SERVICE_ERROR_MESSAGE = 'Unexpected service error.'
const REQUEST_FAILED_MESSAGE =
'The request failed before the run could complete.'
const RUN_START_FAILED_MESSAGE = 'The run could not be started.'
const RUN_ENDED_UNEXPECTEDLY_MESSAGE = 'The run ended unexpectedly.'
const SIGN_IN_REQUIRED_MESSAGE =
'Unauthorized: Please login first to use this node.'
const RATE_LIMITED_PREFIX = 'Rate Limit Exceeded:'
const CORE_OOM_TIP = 'This error means you ran out of memory on your GPU.'
const CORE_OOM_ALLOCATION_PREFIX = 'Allocation on device'
const CLOUD_OOM_PREFIX =
'Workflow execution failed due to insufficient memory (OOM).'
const ERRNO_DIRECTORY_MESSAGE = '[Errno 21] Is a directory:'
const INVALID_CLIP_INPUT_PREFIX = 'ERROR: clip input is invalid: None'
const PROMPT_TOO_SHORT_MESSAGE =
"Field 'prompt' cannot be shorter than 1 characters; was 0 characters long."
const PROMPT_EMPTY_MESSAGE = "Field 'prompt' cannot be empty."
const PREPROCESSING_FAILED_MESSAGE = 'Preprocessing failed'
const PREPROCESSING_TIMEOUT_MESSAGES = new Set([
'Preprocessing timed out',
'Preprocessing timed out.'
])
const MODEL_DOWNLOAD_PANIC_PREFIX = 'internal error during model download:'
const GENERATED_VIDEO_REJECTED_MESSAGE =
'Generated video rejected by content moderation.'
const GENERATED_CONTENT_REJECTED_MESSAGE =
'Generated content was rejected by a safety check.'
const SAFETY_CHECK_MESSAGE = 'Prompt or Initial Image failed the safety checks.'
const CONTENT_POLICY_VIOLATION_MESSAGE =
'The generated image was flagged for content policy violation.'
const CONTENT_MODERATION_FLAGGED_PREFIX =
'Your request was flagged by our content moderation system'
const GOOGLE_RAI_FILTERED_PREFIX =
"Content filtered by Google's Responsible AI practices"
const GOOGLE_RAI_BLOCKED_PREFIX =
"Content blocked by Google's Responsible AI filters"
const START_FAILED_PREFIXES = [
'Failed to start WebSocket client:',
'Failed to get ComfyUI generation ID:'
]
const REQUEST_FAILED_PREFIXES = ['Failed to send prompt request:']
const SERVER_CRASHED_PREFIXES = [
'Workflow execution was interrupted due to ComfyUI process restart.',
'Job execution interrupted: server shutdown.',
'Failed to clear queue and restart failed:',
'WebSocket failed to reconnect after restart:'
]
const PREPROCESSING_FAILED_PREFIXES = [
'Preprocessing failed:',
'Failed to complete preparation:'
]
interface RuntimeErrorInfo {
exceptionType: string
exceptionMessage: string
}
interface RuntimeCatalogMatch {
catalogId: string
params?: CatalogParams
detailsFallback?: string
}
interface RuntimeMatchRule {
matches: (info: RuntimeErrorInfo, message: string) => boolean
resolve: (info: RuntimeErrorInfo, message: string) => RuntimeCatalogMatch
}
function catalogMatch(
catalogId: string,
options: Omit<RuntimeCatalogMatch, 'catalogId'> = {}
): RuntimeCatalogMatch {
return { catalogId, ...options }
}
function catalogMatchWithMessageFallback(
catalogId: string,
message: string
): RuntimeCatalogMatch {
return catalogMatch(catalogId, { detailsFallback: message })
}
function isOutOfMemoryError(info: RuntimeErrorInfo): boolean {
const message = info.exceptionMessage
return (
info.exceptionType === 'OOMError' ||
message.includes(CORE_OOM_TIP) ||
message.includes(CORE_OOM_ALLOCATION_PREFIX) ||
message.includes(CLOUD_OOM_PREFIX) ||
message.includes('CUDA out of memory') ||
message.includes('GPU out of memory')
)
}
function isImageNotLoadedError(
info: RuntimeErrorInfo,
message: string
): boolean {
return (
info.exceptionType === 'ImageDownloadError' ||
(info.exceptionType === 'IsADirectoryError' &&
message.includes(ERRNO_DIRECTORY_MESSAGE))
)
}
function getSubscriptionUpgradeDetails(message: string): string {
return message.slice(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX.length).trim()
}
function isContentBlockedError(message: string): boolean {
return (
message.includes(GENERATED_VIDEO_REJECTED_MESSAGE) ||
message.includes(GENERATED_CONTENT_REJECTED_MESSAGE) ||
message.includes(SAFETY_CHECK_MESSAGE) ||
message.includes(CONTENT_POLICY_VIOLATION_MESSAGE) ||
message.startsWith(CONTENT_MODERATION_FLAGGED_PREFIX) ||
message.startsWith(GOOGLE_RAI_FILTERED_PREFIX) ||
message.startsWith(GOOGLE_RAI_BLOCKED_PREFIX)
)
}
function startsWithAny(message: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => message.startsWith(prefix))
}
function hasEmbeddedApiErrorPayload(message: string): boolean {
// Embedded validation responses are parsed by a more specific path, so do not
// catalog them as a generic request failure here.
return /request returned error status \d{3}:\s*\{/.test(message)
}
function isSubscriptionUpgradeMessage(message: string): boolean {
return (
message.toLowerCase().startsWith(SUBSCRIPTION_UPGRADE_REQUIRED_PREFIX) &&
getSubscriptionUpgradeDetails(message).length > 0
)
}
// Order matters: the first matching rule wins. Keep narrow user-actionable
// signatures before broader fallbacks.
const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
{
matches: isImageNotLoadedError,
resolve: (_info, message) =>
catalogMatchWithMessageFallback(IMAGE_NOT_LOADED_CATALOG_ID, message)
},
{
matches: isOutOfMemoryError,
resolve: (_info, message) =>
catalogMatchWithMessageFallback(OUT_OF_MEMORY_CATALOG_ID, message)
},
{
matches: (_info, message) => message.startsWith(INVALID_CLIP_INPUT_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(INVALID_CLIP_INPUT_CATALOG_ID, message)
},
{
matches: (_info, message) =>
message.includes(PROMPT_TOO_SHORT_MESSAGE) ||
message.includes(PROMPT_EMPTY_MESSAGE),
resolve: () => catalogMatch(INVALID_PROMPT_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'ValidationError' &&
INVALID_WORKFLOW_REQUEST_MESSAGES.has(message),
resolve: () => catalogMatch(INVALID_WORKFLOW_REQUEST_CATALOG_ID)
},
{
matches: (_info, message) =>
WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'InsufficientFundsError' ||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'InactiveSubscriptionError' ||
SUBSCRIPTION_REQUIRED_MESSAGES.has(message),
resolve: () => catalogMatch(SUBSCRIPTION_REQUIRED_CATALOG_ID)
},
{
matches: (_info, message) => isSubscriptionUpgradeMessage(message),
resolve: (_info, message) => {
const modelNames = getSubscriptionUpgradeDetails(message)
return catalogMatch(SUBSCRIPTION_UPGRADE_REQUIRED_CATALOG_ID, {
params: { modelNames },
detailsFallback: message
})
}
},
{
matches: (info, message) =>
info.exceptionType === 'AccessRequired' ||
message === ACCESS_REQUIRED_MESSAGE,
resolve: () => catalogMatch(ACCESS_REQUIRED_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'ModelAccessError' ||
message === MODEL_ACCESS_ERROR_MESSAGE,
resolve: () => catalogMatch(MODEL_ACCESS_ERROR_CATALOG_ID)
},
{
matches: (_info, message) => message.includes(SIGN_IN_REQUIRED_MESSAGE),
resolve: () => catalogMatch(SIGN_IN_REQUIRED_CATALOG_ID)
},
{
matches: (_info, message) => message.startsWith(RATE_LIMITED_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(RATE_LIMITED_CATALOG_ID, message)
},
{
matches: (info, message) =>
info.exceptionType === 'PreprocessingTimeout' ||
PREPROCESSING_TIMEOUT_MESSAGES.has(message),
resolve: () => catalogMatch(PREPROCESSING_TIMEOUT_CATALOG_ID)
},
{
matches: (_info, message) =>
message === PREPROCESSING_FAILED_MESSAGE ||
startsWithAny(message, PREPROCESSING_FAILED_PREFIXES),
resolve: (_info, message) =>
message === PREPROCESSING_FAILED_MESSAGE
? catalogMatch(PREPROCESSING_FAILED_CATALOG_ID)
: catalogMatchWithMessageFallback(
PREPROCESSING_FAILED_CATALOG_ID,
message
)
},
{
matches: (info, message) =>
info.exceptionType === 'PanicError' &&
message.startsWith(MODEL_DOWNLOAD_PANIC_PREFIX),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(MODEL_DOWNLOAD_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) => isContentBlockedError(message),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(CONTENT_BLOCKED_CATALOG_ID, message)
},
{
matches: (_info, message) => startsWithAny(message, START_FAILED_PREFIXES),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(RUN_START_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) =>
startsWithAny(message, REQUEST_FAILED_PREFIXES) &&
!hasEmbeddedApiErrorPayload(message),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message)
},
{
matches: (_info, message) => message === RUN_START_FAILED_MESSAGE,
resolve: () => catalogMatch(RUN_START_FAILED_CATALOG_ID)
},
{
matches: (_info, message) => message === RUN_ENDED_UNEXPECTEDLY_MESSAGE,
resolve: () => catalogMatch(RUN_ENDED_UNEXPECTEDLY_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'PanicError' &&
message.startsWith('panic during job execution:'),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(
RUN_ENDED_UNEXPECTEDLY_CATALOG_ID,
message
)
},
{
matches: (_info, message) => TIMEOUT_MESSAGES.has(message),
resolve: () => catalogMatch(TIMEOUT_CATALOG_ID)
},
{
matches: (_info, message) => GENERATION_STALLED_MESSAGES.has(message),
resolve: () => catalogMatch(GENERATION_STALLED_CATALOG_ID)
},
{
matches: (_info, message) => SERVER_CRASHED_MESSAGES.has(message),
resolve: () => catalogMatch(SERVER_CRASHED_CATALOG_ID)
},
{
matches: (_info, message) =>
startsWithAny(message, SERVER_CRASHED_PREFIXES),
resolve: (_info, message) =>
catalogMatchWithMessageFallback(SERVER_CRASHED_CATALOG_ID, message)
},
{
matches: (_info, message) => SERVER_BUSY_MESSAGES.has(message),
resolve: () => catalogMatch(SERVER_BUSY_CATALOG_ID)
},
{
matches: (info, message) =>
info.exceptionType === 'UnexpectedServiceError' ||
message === UNEXPECTED_SERVICE_ERROR_MESSAGE,
resolve: () => catalogMatch(UNEXPECTED_SERVICE_ERROR_CATALOG_ID)
},
{
matches: (info, message) =>
message === REQUEST_FAILED_MESSAGE ||
(info.exceptionType === 'RequestError' &&
!hasEmbeddedApiErrorPayload(message)),
resolve: (_info, message) =>
message === REQUEST_FAILED_MESSAGE
? catalogMatch(REQUEST_FAILED_CATALOG_ID)
: catalogMatchWithMessageFallback(REQUEST_FAILED_CATALOG_ID, message)
}
]
export function resolveRuntimeCatalogMatch(
info: RuntimeErrorInfo
): RuntimeCatalogMatch | undefined {
const message = info.exceptionMessage.trim()
for (const rule of RUNTIME_MATCH_RULES) {
if (rule.matches(info, message)) return rule.resolve(info, message)
}
return undefined
}

View File

@@ -1,4 +1,8 @@
import type { NodeError, PromptError } from '@/schemas/apiSchema'
import type {
ExecutionErrorWsMessage,
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type {
MissingMediaGroup,
MediaType
@@ -40,6 +44,12 @@ export type RunErrorMessageSource =
error: PromptError
isCloud: boolean
}
| {
kind: 'execution'
error: ExecutionErrorWsMessage
nodeDisplayName: string
isCloud: boolean
}
export type MissingErrorMessageSource =
| {

View File

@@ -0,0 +1,347 @@
import type { NodeValidationError, ResolvedErrorMessage } from './types'
import {
IMAGE_NOT_LOADED_CATALOG_ID,
MISSING_CONNECTION_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
translateCatalogMessage,
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
// Resolves node validation errors. Most validation types map 1:1 to their
// catalog/locale keys; FE-specific recategorization uses a separate catalogId,
// such as required_input_missing -> missing_connection.
interface ValidationCatalogRule {
catalogId: string
itemLabel: 'node' | 'nodeInput'
copyKeys?: CopyKeys
}
interface CopyKeys {
detailsKey: string
toastMessageKey: string
}
const DEFAULT_COPY_KEYS: CopyKeys = {
detailsKey: 'details',
toastMessageKey: 'toastMessage'
}
const VALUE_SPECIFIC_COPY_RULES: Record<
string,
{
requiredParams: string[]
suffix: 'WithTypes' | 'WithValue'
}
> = {
return_type_mismatch: {
requiredParams: ['expectedType', 'receivedType'],
suffix: 'WithTypes'
},
invalid_input_type: {
requiredParams: ['receivedValue', 'expectedType'],
suffix: 'WithValue'
},
value_smaller_than_min: {
requiredParams: ['receivedValue', 'minValue'],
suffix: 'WithValue'
},
value_bigger_than_max: {
requiredParams: ['receivedValue', 'maxValue'],
suffix: 'WithValue'
},
value_not_in_list: {
requiredParams: ['receivedValue'],
suffix: 'WithValue'
}
}
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
[REQUIRED_INPUT_MISSING_TYPE]: {
catalogId: MISSING_CONNECTION_CATALOG_ID,
itemLabel: 'nodeInput'
},
bad_linked_input: {
catalogId: 'bad_linked_input',
itemLabel: 'nodeInput'
},
return_type_mismatch: {
catalogId: 'return_type_mismatch',
itemLabel: 'nodeInput'
},
invalid_input_type: {
catalogId: 'invalid_input_type',
itemLabel: 'nodeInput'
},
value_smaller_than_min: {
catalogId: 'value_smaller_than_min',
itemLabel: 'nodeInput'
},
value_bigger_than_max: {
catalogId: 'value_bigger_than_max',
itemLabel: 'nodeInput'
},
value_not_in_list: {
catalogId: 'value_not_in_list',
itemLabel: 'nodeInput'
},
custom_validation_failed: {
catalogId: 'custom_validation_failed',
itemLabel: 'nodeInput'
},
exception_during_inner_validation: {
catalogId: 'exception_during_inner_validation',
itemLabel: 'nodeInput'
},
exception_during_validation: {
catalogId: 'exception_during_validation',
itemLabel: 'node'
},
dependency_cycle: {
catalogId: 'dependency_cycle',
itemLabel: 'node'
}
}
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
// dispatch cannot distinguish it. The override also keeps it on default copy
// keys instead of custom_validation_failed's raw-details variant.
const IMAGE_NOT_LOADED_VALIDATION_RULE = {
catalogId: IMAGE_NOT_LOADED_CATALOG_ID,
itemLabel: 'node',
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
inputName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
)
}
function getErrorText(error: NodeValidationError) {
return [
'message' in error ? error.message : undefined,
'details' in error ? error.details : undefined
]
.filter(Boolean)
.join('\n')
}
function isImageNotLoadedText(text: string): boolean {
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
}
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
return (
error.type === 'custom_validation_failed' &&
isImageNotLoadedText(getErrorText(error))
)
}
function nodeInputItemLabel(nodeName: string, inputName: string): string {
return `${nodeName} - ${inputName}`
}
function formatDependencyCycleDetails(details: string): string {
// Dependency cycle paths may be reported as "node -> node"; catalog copy
// embeds those paths in prose, where "to" reads more naturally.
return details.replace(/\s*->\s*/g, ' to ')
}
function formatCatalogValue(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
try {
return JSON.stringify(value)
} catch {
return undefined
}
}
function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
const config = inputConfig[1]
if (!config || typeof config !== 'object') return undefined
return formatCatalogValue((config as Record<string, unknown>)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
return formatCatalogValue(inputConfig[0])
}
function getValidationParams(
error: NodeValidationError,
nodeName: string,
inputName: string
): CatalogParams {
const params: CatalogParams = { nodeName, inputName }
const receivedValue = formatCatalogValue(error.extra_info?.received_value)
const receivedType = formatCatalogValue(error.extra_info?.received_type)
const expectedType = getInputConfigType(error)
const minValue = getInputConfigValue(error, 'min')
const maxValue = getInputConfigValue(error, 'max')
if (receivedValue !== undefined) params.receivedValue = receivedValue
if (receivedType !== undefined) params.receivedType = receivedType
if (expectedType !== undefined) params.expectedType = expectedType
if (minValue !== undefined) params.minValue = minValue
if (maxValue !== undefined) params.maxValue = maxValue
return params
}
function hasParams(params: CatalogParams, keys: string[]): boolean {
return keys.every((key) => params[key] !== undefined)
}
function getValueSpecificCopyKeys(
errorType: string,
params: CatalogParams
): CopyKeys {
const rule = VALUE_SPECIFIC_COPY_RULES[errorType]
if (!rule || !hasParams(params, rule.requiredParams)) return DEFAULT_COPY_KEYS
return {
detailsKey: `details${rule.suffix}`,
toastMessageKey: `toastMessage${rule.suffix}`
}
}
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
return error.details.trim()
? {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessageWithRawDetails'
}
: DEFAULT_COPY_KEYS
}
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
if (!error.details.trim()) return DEFAULT_COPY_KEYS
return {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessage'
}
}
function getValidationCopyKeys(
error: NodeValidationError,
params: CatalogParams
): CopyKeys {
if (
error.type === 'exception_during_validation' ||
error.type === 'exception_during_inner_validation'
) {
return getRawDetailsCopyKeys(error)
}
if (error.type === 'custom_validation_failed') {
return getRawDetailsOnlyCopyKeys(error)
}
if (error.type === 'dependency_cycle') {
return getRawDetailsOnlyCopyKeys(error)
}
return getValueSpecificCopyKeys(error.type, params)
}
function resolveValidationCatalogCopy(
error: NodeValidationError,
context: ErrorResolveContext,
localeKey: string,
rule: ValidationCatalogRule
): ResolvedErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const trimmedDetails = error.details.trim()
const rawDetails =
error.type === 'dependency_cycle'
? formatDependencyCycleDetails(trimmedDetails)
: trimmedDetails
const params = {
...getValidationParams(error, nodeName, inputName),
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
const titleFallback = error.message || error.type
const itemLabelFallback =
rule.itemLabel === 'node'
? nodeName
: nodeInputItemLabel(nodeName, inputName)
const copyKeys = rule.copyKeys ?? getValidationCopyKeys(error, params)
return {
catalogId: rule.catalogId,
displayTitle: translateCatalogMessage(
`${keyPrefix}.title`,
titleFallback,
params
),
displayMessage: translateCatalogMessage(
`${keyPrefix}.message`,
error.message,
params
),
displayDetails: translateOptionalCatalogMessage(
`${keyPrefix}.${copyKeys.detailsKey}`,
error.details,
params
),
displayItemLabel: translateCatalogMessage(
`${keyPrefix}.itemLabel`,
itemLabelFallback,
params
),
toastTitle: translateCatalogMessage(
`${keyPrefix}.toastTitle`,
titleFallback,
params
),
toastMessage: translateCatalogMessage(
`${keyPrefix}.${copyKeys.toastMessageKey}`,
error.message,
params
)
}
}
export function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
context,
'image_not_loaded',
IMAGE_NOT_LOADED_VALIDATION_RULE
)
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}