added URL fetch error handling

This commit is contained in:
Edward Niewold
2026-04-23 08:53:56 +02:00
parent fe3b19024b
commit d60bcb99a0
5 changed files with 264 additions and 28 deletions
+6 -3
View File
@@ -70,6 +70,11 @@ Restart ComfyUI after installation or after Python changes. Browser-side updates
Use `at execution` set to `reload` when the inner workflow file should be read fresh every run. Use `keep loaded` when the inner workflow should keep its in-memory state between runs.
## Load from URL
Use the `Subworkflow (from URL)` node to load an inner workflow from a URL instead of a local file. The URL must point to a raw JSON workflow file.
Since the SSL certificate handling of ComfyUI's Python environment may not support all HTTPS endpoints, there is an option to ignore SSL errors when loading from URL. Use this option with caution and only for trusted sources.
## Use Cases
- Reuse a prompt, sampler, or decode chain across multiple workflows.
@@ -101,6 +106,4 @@ Use `at execution` set to `reload` when the inner workflow file should be read f
- [x] When linked nodes have a mismatch in expected input type and the value provided by Subworkflow Output, no error is given but passed to the next node. Implement type checking and error handling?
### Wish List
- [ ] For each Subworkflow Input node, add an input field in the Subworkflow node for directly setting its value. This also allows for linking of nodes with same type. Similar as subgraphs.
- [ ] Node with similar functionality as `Subworkflow` but where the inner workflow is loaded from a URL instead of a local file. This allows for sharing of inner workflows without sharing the entire outer workflow, and also allows for easier updating of inner workflows since they can be updated at the URL without needing to update the outer workflow.
- [ ]
- [ ] For each Subworkflow Input node, add an input field in the Subworkflow node for directly setting its value. This also allows for linking of nodes with same type. Similar as subgraphs. Check if this conflicts with unlinked inputs.
+149 -14
View File
@@ -12,10 +12,12 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el, ComfyDialog } from "../../scripts/ui.js";
const NODE_CONFIGS = {
SWF_Subworkflow: {
widgetName: "workflow",
refreshWidgetNames: ["workflow"],
describe: "workflow",
infoPath(value) {
return `/subworkflow/info?source=file&workflow=${encodeURIComponent(value)}`;
@@ -23,14 +25,22 @@ const NODE_CONFIGS = {
},
SWF_SubworkflowFromURL: {
widgetName: "url",
refreshWidgetNames: ["url", "verify_ssl"],
describe: "workflow URL",
infoPath(value) {
return `/subworkflow/info?source=url&url=${encodeURIComponent(value)}`;
infoPath(value, node) {
const verifySsl = node?.widgets?.find(w => w.name === "verify_ssl")?.value !== false;
return `/subworkflow/info?source=url&url=${encodeURIComponent(value)}&verify_ssl=${verifySsl ? "true" : "false"}`;
},
},
};
const MAX_SLOTS = 8;
const LOG = "[SWF]";
let workflowErrorDialog = null;
let lastWorkflowErrorKey = null;
console.log(LOG, "extension module loaded", {
configuredNodeTypes: Object.keys(NODE_CONFIGS),
});
function slotSummary(slots) {
return (slots || []).map((slot, index) => ({
@@ -60,29 +70,118 @@ function setNodeSizeAtLeast(node, minSize) {
]);
}
async function fetchWorkflowInfo(config, value) {
function clearWorkflowError() {
lastWorkflowErrorKey = null;
if (
workflowErrorDialog &&
workflowErrorDialog.element &&
workflowErrorDialog.element.style.display !== "none" &&
typeof workflowErrorDialog.close === "function"
) {
workflowErrorDialog.close();
}
}
function showWorkflowError(config, value, message) {
const text = String(message || `Unable to load ${config.describe}`);
const key = `${config.describe}\n${value || ""}\n${text}`;
if (key === lastWorkflowErrorKey) return;
lastWorkflowErrorKey = key;
if (!workflowErrorDialog) {
workflowErrorDialog = new ComfyDialog();
}
workflowErrorDialog.show(
$el("div", {
style: {
display: "flex",
flexDirection: "column",
gap: "10px",
maxWidth: "720px",
},
}, [
$el("h3", {
textContent: "Subworkflow load failed",
style: {
margin: "0",
color: "#fff",
fontFamily: "sans-serif",
},
}),
$el("div", {
textContent: `Could not load ${config.describe}${value ? `: ${value}` : ""}`,
style: {
color: "#ddd",
fontFamily: "sans-serif",
overflowWrap: "anywhere",
},
}),
$el("pre", {
textContent: text,
style: {
margin: "0",
padding: "10px",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
maxHeight: "320px",
overflow: "auto",
color: "#fff",
background: "#221111",
border: "1px solid #6f2b2b",
borderRadius: "4px",
},
}),
])
);
}
async function fetchWorkflowInfo(config, value, node) {
if (!value) {
console.log(LOG, `fetchWorkflowInfo: skipping empty ${config.describe} value`);
clearWorkflowError();
return null;
}
console.log(LOG, `fetchWorkflowInfo: fetching info for ${config.describe}`, value);
const path = config.infoPath(value, node);
console.log(LOG, `fetchWorkflowInfo: fetching info for ${config.describe}`, {
value,
path,
});
try {
const resp = await api.fetchApi(config.infoPath(value));
console.log(LOG, `fetchWorkflowInfo: response status ${resp.status} for "${value}"`);
const resp = await api.fetchApi(path);
console.log(LOG, `fetchWorkflowInfo: response status ${resp.status} for "${value}"`, {
url: resp.url,
redirected: resp.redirected,
});
let data = null;
try {
data = await resp.json();
console.log(LOG, "fetchWorkflowInfo: response payload", data);
} catch (e) {
console.warn(LOG, `fetchWorkflowInfo: failed to parse JSON response for "${value}"`, e);
}
if (!resp.ok) {
console.warn(LOG, `fetchWorkflowInfo: HTTP ${resp.status} for "${value}"`);
showWorkflowError(config, value, data?.error || `HTTP ${resp.status} while loading ${config.describe}`);
return null;
}
if (!data) {
const message = `Invalid response while loading ${config.describe}: expected JSON`;
console.warn(LOG, `fetchWorkflowInfo: ${message}`);
showWorkflowError(config, value, message);
return null;
}
const data = await resp.json();
console.log(LOG, "fetchWorkflowInfo: response payload", data);
if (data.error) {
console.warn(LOG, `fetchWorkflowInfo: server error for "${value}":`, data.error);
showWorkflowError(config, value, data.error);
return null;
}
clearWorkflowError();
console.log(LOG, `fetchWorkflowInfo: got ${data.inputs.length} input(s), ${data.outputs.length} output(s) for "${value}"`);
return data;
} catch (e) {
console.error(LOG, "fetchWorkflowInfo: fetch failed:", e);
showWorkflowError(config, value, e);
return null;
}
}
@@ -193,13 +292,25 @@ app.registerExtension({
async beforeRegisterNodeDef(nodeType, nodeData) {
const config = NODE_CONFIGS[nodeData.name];
if (!config) return;
if (!config) {
if (String(nodeData.name || "").startsWith("SWF_")) {
console.log(LOG, "beforeRegisterNodeDef: ignoring unconfigured SWF node", nodeData.name);
}
return;
}
console.log(LOG, "beforeRegisterNodeDef: patching", nodeData.name, nodeData);
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (serializedNode) {
console.log(LOG, "onConfigure: restoring node from saved workflow");
console.log(LOG, "onConfigure: restoring node from saved workflow", {
nodeType: nodeData.name,
nodeId: this.id,
serializedWidgetValues: serializedNode?.widgets_values,
serializedProperties: serializedNode?.properties,
serializedInputCount: serializedNode?.inputs?.length,
serializedOutputCount: serializedNode?.outputs?.length,
});
const savedInputCount = (serializedNode.inputs || [])
.filter(i => i.name?.startsWith("swf_in_")).length;
@@ -207,10 +318,21 @@ app.registerExtension({
console.log(LOG, `onConfigure: serialized node has ${savedInputCount} dynamic input(s)`);
if (origOnConfigure) origOnConfigure.call(this, serializedNode);
console.log(LOG, "onConfigure: after original handler", {
nodeType: nodeData.name,
nodeId: this.id,
widgets: (this.widgets || []).map(w => ({
name: w.name,
value: w.value,
type: w.type,
hasCallback: !!w.callback,
})),
});
const widget = this.widgets?.find(w => w.name === config.widgetName);
const sourceValue = widget?.value;
console.log(LOG, `onConfigure: ${config.widgetName} widget value =`, sourceValue, {
nodeType: nodeData.name,
nodeId: this.id,
widgets: (this.widgets || []).map(w => ({ name: w.name, value: w.value, type: w.type })),
inputs: slotSummary(this.inputs),
@@ -218,10 +340,11 @@ app.registerExtension({
});
if (sourceValue) {
fetchWorkflowInfo(config, sourceValue).then(info => {
fetchWorkflowInfo(config, sourceValue, this).then(info => {
applyWorkflowInfoOnLoad(this, info, savedInputCount, savedSize);
});
} else {
clearWorkflowError();
console.warn(LOG, `onConfigure: ${config.widgetName} widget not found or empty`, { nodeId: this.id });
}
};
@@ -229,13 +352,23 @@ app.registerExtension({
const origOnWidgetChanged = nodeType.prototype.onWidgetChanged;
nodeType.prototype.onWidgetChanged = function (name, value, oldValue, widget) {
if (origOnWidgetChanged) origOnWidgetChanged.call(this, name, value, oldValue, widget);
if (name === config.widgetName) {
console.log(LOG, "onWidgetChanged: observed widget change", {
nodeType: nodeData.name,
nodeId: this.id,
name,
value,
oldValue,
watchedWidget: config.widgetName,
widgetType: widget?.type,
});
if (config.refreshWidgetNames.includes(name)) {
const sourceValue = this.widgets?.find(w => w.name === config.widgetName)?.value;
console.log(LOG, `onWidgetChanged: ${config.widgetName} changed from "${oldValue}" to "${value}"`, {
nodeId: this.id,
inputs: slotSummary(this.inputs),
outputs: slotSummary(this.outputs),
});
fetchWorkflowInfo(config, value).then(info => applyWorkflowInfo(this, info));
fetchWorkflowInfo(config, sourceValue, this).then(info => applyWorkflowInfo(this, info));
}
};
@@ -245,14 +378,16 @@ app.registerExtension({
const widget = this.widgets?.find(w => w.name === config.widgetName);
const val = widget?.value;
console.log(LOG, `onAdded: node placed on canvas, ${config.widgetName} =`, val, {
nodeType: nodeData.name,
nodeId: this.id,
widgets: (this.widgets || []).map(w => ({ name: w.name, value: w.value, type: w.type })),
inputs: slotSummary(this.inputs),
outputs: slotSummary(this.outputs),
});
if (val) {
fetchWorkflowInfo(config, val).then(info => applyWorkflowInfo(this, info));
fetchWorkflowInfo(config, val, this).then(info => applyWorkflowInfo(this, info));
} else {
clearWorkflowError();
console.warn(LOG, `onAdded: ${config.widgetName} widget not found or empty`, { nodeId: this.id });
}
};
+33 -2
View File
@@ -17,12 +17,20 @@ from .workflow_utils import (
log = logging.getLogger("ComfyUI-Subworkflow")
def _query_bool(value: str | None, default: bool = True) -> bool:
if value is None:
return default
return str(value).strip().lower() not in {"0", "false", "no", "off"}
def setup_routes():
try:
from server import PromptServer
except ImportError:
log.warning("Subworkflow route setup skipped: PromptServer import failed")
return
routes = PromptServer.instance.routes
log.info("Subworkflow route setup: registering /subworkflow/info and /subworkflow/list")
async def get_workflow_info(request: web.Request):
"""
@@ -32,17 +40,27 @@ def setup_routes():
source = request.rel_url.query.get("source", "file")
workflow_name = request.rel_url.query.get("workflow", "")
url = request.rel_url.query.get("url", "")
verify_ssl = _query_bool(request.rel_url.query.get("verify_ssl"), True)
source_value = url if source == "url" else workflow_name
log.info("Subworkflow route: info requested for source=%r value=%r", source, source_value)
log.info(
"Subworkflow route: info requested path=%r query=%s source=%r value=%r verify_ssl=%s",
str(request.rel_url),
dict(request.rel_url.query),
source,
source_value,
verify_ssl,
)
if source == "url":
if not url:
log.warning("Subworkflow route: URL info request missing URL")
return web.json_response({"inputs": [], "outputs": [], "error": "No workflow URL specified"})
log.info("Subworkflow route: selected URL loader for %r verify_ssl=%s", url, verify_ssl)
loader = load_workflow_url
elif source == "file":
if not workflow_name:
log.warning("Subworkflow route: info request missing workflow name")
return web.json_response({"inputs": [], "outputs": [], "error": "No workflow specified"})
log.info("Subworkflow route: selected file loader for %r", workflow_name)
loader = load_workflow_file
else:
log.warning("Subworkflow route: unsupported source=%r", source)
@@ -50,9 +68,22 @@ def setup_routes():
try:
if source == "url":
data = await asyncio.to_thread(loader, source_value)
log.info(
"Subworkflow route: dispatching URL load to worker thread for %r verify_ssl=%s",
source_value,
verify_ssl,
)
data = await asyncio.to_thread(loader, source_value, verify_ssl)
else:
log.info("Subworkflow route: loading file workflow synchronously for %r", source_value)
data = loader(source_value)
log.info(
"Subworkflow route: loader returned source=%r value=%r type=%s keys=%s",
source,
source_value,
type(data).__name__,
sorted(data.keys())[:12] if isinstance(data, dict) else None,
)
except FileNotFoundError:
log.warning("Subworkflow route: workflow file not found: %r", source_value)
return web.json_response({"inputs": [], "outputs": [], "error": f"File not found: {source_value}"})
+35 -5
View File
@@ -169,6 +169,7 @@ class SubworkflowFromURL(BaseSubworkflow):
@classmethod
def define_schema(cls):
log.info("SubworkflowFromURL: define_schema called")
return io.Schema(
node_id="SWF_SubworkflowFromURL",
display_name="Subworkflow (from URL)",
@@ -180,6 +181,13 @@ class SubworkflowFromURL(BaseSubworkflow):
),
inputs=[
io.String.Input("url", multiline=False, default=""),
io.Boolean.Input(
"verify_ssl",
display_name="verify SSL",
default=True,
label_on="verify",
label_off="skip",
),
_reload_input(),
],
outputs=_subworkflow_outputs(),
@@ -189,22 +197,44 @@ class SubworkflowFromURL(BaseSubworkflow):
@classmethod
def _source_label(cls, url: str, **kwargs) -> str:
log.info("SubworkflowFromURL: _source_label url=%r", url)
return url
@classmethod
def _source_cache_key(cls, url: str, **kwargs) -> str:
return f"url:{url.strip()}"
def _source_cache_key(cls, url: str, verify_ssl: bool = True, **kwargs) -> str:
log.info(
"SubworkflowFromURL: _source_cache_key url=%r stripped=%r verify_ssl=%s",
url,
url.strip(),
verify_ssl,
)
return f"url:{url.strip()}:verify_ssl:{bool(verify_ssl)}"
@classmethod
def _load_source(cls, url: str, **kwargs) -> dict:
def _load_source(cls, url: str, verify_ssl: bool = True, **kwargs) -> dict:
log.info(
"SubworkflowFromURL: _load_source url=%r verify_ssl=%s kwargs_keys=%s",
url,
verify_ssl,
sorted(kwargs.keys()),
)
if not url:
log.warning("SubworkflowFromURL: _load_source missing URL")
raise ValueError("No workflow URL specified.")
return load_workflow_url(url.strip())
return load_workflow_url(url.strip(), verify_ssl=bool(verify_ssl))
@classmethod
def execute(cls, url: str, reload_each_execution=True, **kwargs):
def execute(cls, url: str, verify_ssl=True, reload_each_execution=True, **kwargs):
log.info(
"SubworkflowFromURL: execute called url=%r verify_ssl=%s reload_each_execution=%s dynamic_inputs=%s",
url,
verify_ssl,
reload_each_execution,
sorted(k for k in kwargs if k.startswith("swf_in_")),
)
return cls._execute_source(
url=url,
verify_ssl=verify_ssl,
reload_each_execution=reload_each_execution,
**kwargs,
)
+41 -4
View File
@@ -7,6 +7,7 @@ import logging
import os
import random
import re
import ssl
import urllib.error
import urllib.parse
import urllib.request
@@ -59,44 +60,80 @@ def load_workflow_file(filename: str) -> dict:
return data
def load_workflow_url(url: str) -> dict:
def load_workflow_url(url: str, verify_ssl: bool = True) -> dict:
log.info("Subworkflow URL loader: entered with url=%r verify_ssl=%s", url, verify_ssl)
parsed = urllib.parse.urlparse(url)
log.info(
"Subworkflow URL loader: parsed scheme=%r netloc=%r path=%r query_present=%s",
parsed.scheme,
parsed.netloc,
parsed.path,
bool(parsed.query),
)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
log.warning("Subworkflow URL loader: rejecting invalid absolute URL %r", url)
raise ValueError("Workflow URL must be an absolute http:// or https:// URL.")
log.info("Subworkflow: loading workflow URL %r", url)
log.info(
"Subworkflow URL loader: opening URL %r timeout=%s max_bytes=%s verify_ssl=%s",
url,
URL_WORKFLOW_TIMEOUT,
MAX_URL_WORKFLOW_BYTES,
verify_ssl,
)
request = urllib.request.Request(
url,
headers={"User-Agent": "ComfyUI-Subworkflow/1.0"},
)
context = None if verify_ssl else ssl._create_unverified_context()
if not verify_ssl:
log.warning("Subworkflow URL loader: SSL certificate verification disabled for %r", url)
try:
with urllib.request.urlopen(request, timeout=URL_WORKFLOW_TIMEOUT) as response:
with urllib.request.urlopen(request, timeout=URL_WORKFLOW_TIMEOUT, context=context) as response:
status = getattr(response, "status", 200)
log.info(
"Subworkflow URL loader: response opened status=%s content_type=%r content_length=%r final_url=%r",
status,
response.headers.get("Content-Type"),
response.headers.get("Content-Length"),
response.geturl(),
)
if status < 200 or status >= 300:
log.warning("Subworkflow URL loader: rejecting HTTP status %s for %r", status, url)
raise ValueError(f"Workflow URL returned HTTP {status}.")
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > MAX_URL_WORKFLOW_BYTES:
log.warning(
"Subworkflow URL loader: rejecting oversized Content-Length=%s for %r",
content_length,
url,
)
raise ValueError(
f"Workflow URL response is too large "
f"({content_length} bytes; limit {MAX_URL_WORKFLOW_BYTES})."
)
raw = response.read(MAX_URL_WORKFLOW_BYTES + 1)
log.info("Subworkflow URL loader: read %d byte(s) from %r", len(raw), url)
except urllib.error.HTTPError as e:
log.warning("Subworkflow URL loader: HTTPError for %r: %s", url, e)
raise ValueError(f"Workflow URL returned HTTP {e.code}.") from e
except urllib.error.URLError as e:
log.warning("Subworkflow URL loader: URLError for %r: %s", url, e)
raise ValueError(f"Failed to load workflow URL: {e.reason}") from e
if len(raw) > MAX_URL_WORKFLOW_BYTES:
log.warning("Subworkflow URL loader: rejecting oversized body for %r", url)
raise ValueError(
f"Workflow URL response is too large "
f"(limit {MAX_URL_WORKFLOW_BYTES} bytes)."
)
log.info("Subworkflow URL loader: decoding JSON from %r", url)
data = json.loads(raw.decode("utf-8"))
log.info(
"Subworkflow: loaded workflow URL %r format=%s top_level_keys=%s",
"Subworkflow URL loader: loaded workflow URL %r format=%s top_level_keys=%s",
url,
"UI" if is_ui_format(data) else "API",
sorted(data.keys())[:12],