Files
Comfy-Org-comfy-remote-nodes/proxy_node.py
T
Kosinkadink cf55bd58ae Initial RNP/1 client implementation with Tier 3 features
ComfyUI custom-node package that fetches node descriptors from an
RNP/1 (Remote Node Protocol v1) server and exposes them as native V3
nodes inside ComfyUI.

Includes:
- Manifest + capability handshake, ETag-conditional object_info fetch
- Sync execute_remote and async _run_async_polling flows via
  comfy_api_nodes.util.client.poll_op_raw
- Image / mask / audio / video envelope encode + decode
- Presigned upload helper for >8 MiB inputs
- Tier 3:
  * TASK_LOST detection + idempotent resume — survives an RNP server
    restart mid-task
  * SERVER_BUSY / MAINTENANCE 503 envelopes treated as rate limits
    with Retry-After honoring and friendly per-second progress text
  * Out-of-band url_fetch for URI-only output envelopes per the
    descriptor's remote.url_fetch policy
  * Per-poll auth refresh so long-running tasks pick up rotated tokens

Pairs with Comfy-Org/comfy-rnp-server (private) as the reference
server implementation.
2026-05-11 12:31:05 -07:00

1180 lines
46 KiB
Python

"""Build dynamic V3 ``IO.ComfyNode`` subclasses from RNP NodeDescriptors.
The descriptor wire format is V3 ``Schema.get_v1_info()`` verbatim, so
per-input dicts are handed straight to V3's IO classes without
reinterpretation. ``_parse_input_spec`` knows STRING / INT / FLOAT /
BOOLEAN / COMBO / IMAGE / VIDEO / AUDIO / MASK; an input outside that
set causes the whole node to be skipped with a warning rather than
half-built.
Hidden inputs (``auth_token_comfy_org`` / ``api_key_comfy_org`` /
``unique_id`` / ``prompt`` / ``extra_pnginfo`` / ``dynprompt``) are
declared on the schema so the executor populates ``cls.hidden``; auth
credentials are then lifted into outbound HTTP headers and everything
else flows into the request ``context`` map.
Only ``execute`` is wired today; the validate / fingerprint /
check_lazy_status RNP methods fall through to ComfyUI's defaults until
their server endpoints exist.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
import uuid
from typing import Any
from comfy_api.latest import IO
from server import PromptServer
from comfy_api_nodes.util.client import ApiEndpoint, poll_op_raw
from comfy_api_nodes.util.common_exceptions import ProcessingInterrupted
from . import client as rnp_client
from . import serialization
from .protocol import (
DEFAULT_CANCEL_TIMEOUT_S,
DEFAULT_MAX_POLL_ATTEMPTS,
DEFAULT_MAX_RETRIES_PER_POLL,
DEFAULT_POLL_INTERVAL_S,
DEFAULT_RETRY_BACKOFF_PER_POLL,
DEFAULT_RETRY_DELAY_PER_POLL_S,
DEFAULT_TIMEOUT_PER_POLL_S,
ErrorCode,
ExecutionMode,
Header,
PROTOCOL_VERSION,
RnpProtocolError,
TaskStatus,
is_envelope,
)
# ComfyUI's frontend matches these strings literally on
# ``exception_message.includes(...)`` to fire dedicated dialogs, so the
# wording here must stay byte-identical with
# ``comfy_api_nodes.util.client._friendly_http_message``.
_AUTH_FAILED_MESSAGE = "Unauthorized: Please login first to use this node."
_INSUFFICIENT_CREDITS_MESSAGE = (
"Payment Required: Please add credits to your account to use this node."
)
_ACCOUNT_PROBLEM_MESSAGE = (
"There is a problem with your account. Please contact support@comfy.org."
)
_RATE_LIMIT_AFTER_RETRY_MESSAGE = (
"Rate Limit Exceeded: The server returned 429 after all retry attempts. "
"Please wait and try again."
)
_PROVIDER_UNAVAILABLE_MESSAGE = (
"The remote node provider is temporarily unavailable. Please try again "
"in a few minutes."
)
_SERVER_BUSY_MESSAGE = (
"The remote node server is at capacity. Please try again in a moment."
)
_MAINTENANCE_MESSAGE = (
"The remote node server is in maintenance. Please try again shortly."
)
_TASK_LOST_MESSAGE = (
"The remote node server lost the running task and the resume attempt "
"did not succeed. Please re-run the workflow."
)
def _to_friendly_message(e: RnpProtocolError, node_id: str) -> str:
upstream = (e.details or {}).get("upstream_status") if e.details else None
if e.code == ErrorCode.AUTH_FAILED or upstream in (401, 403):
return _AUTH_FAILED_MESSAGE
if e.code == ErrorCode.INSUFFICIENT_CREDITS or upstream == 402:
return _INSUFFICIENT_CREDITS_MESSAGE
# 409 from the upstream gateway means "account in bad standing".
# comfy_api_nodes.util.client._friendly_http_message uses a dedicated
# support-contact prompt for this; mirror it byte-identically so the
# same frontend dialog fires.
if upstream == 409:
return _ACCOUNT_PROBLEM_MESSAGE
# 429 reaches the client only after the server has exhausted its own
# rate-limit retries — surface the post-retry copy so users don't
# think a single transient 429 leaked through.
if e.code == ErrorCode.RATE_LIMITED or upstream == 429:
return _RATE_LIMIT_AFTER_RETRY_MESSAGE
if e.code == ErrorCode.SERVER_BUSY:
return _SERVER_BUSY_MESSAGE
if e.code == ErrorCode.MAINTENANCE:
# Server-friendly message often embeds an "until" timestamp in
# the wire ``message`` — prefer it over the canned copy.
return str(e) if e.user_facing else _MAINTENANCE_MESSAGE
if e.code == ErrorCode.TASK_LOST:
return _TASK_LOST_MESSAGE
# PROVIDER_UNAVAILABLE with a retryable hint or 5xx upstream
# collapses to the generic "try again" copy.
if e.code == ErrorCode.PROVIDER_UNAVAILABLE and (
upstream in (502, 503, 504) or e.retryable
):
return _PROVIDER_UNAVAILABLE_MESSAGE
if e.user_facing:
return str(e)
return f"Remote node {node_id} failed ({e.code})"
def _coerce_pos(value: Any, default: float) -> float:
"""Best-effort numeric coerce — falls back to ``default`` for anything
unparseable or non-positive (the descriptor came over the wire so we
don't trust it)."""
try:
f = float(value)
except (TypeError, ValueError):
return float(default)
return f if f > 0 else float(default)
def _coerce_pos_int(value: Any, default: int) -> int:
try:
i = int(value)
except (TypeError, ValueError):
return int(default)
return i if i > 0 else int(default)
def _extract_execution_policy(execution: dict[str, Any]) -> dict[str, Any]:
"""Lift ``descriptor.remote.execution`` policy fields into a flat dict
of ``poll_op_raw`` kwargs.
Defaults mirror ``comfy_api_nodes.util.client.poll_op_raw`` so an RNP
node with an empty ``execution`` block behaves identically to a
direct upstream call. The schema is the wire contract from
``comfy_rnp_protocol.constants``: ``poll_interval_s``,
``max_poll_attempts``, ``timeout_per_poll_s``, ``cancel_timeout_s``,
``max_retries_per_poll``, ``retry_delay_per_poll_s``,
``retry_backoff_per_poll``, ``retry: {max, delay_s, backoff,
retry_on}``, ``idempotency``.
"""
retry_block = execution.get("retry") if isinstance(execution.get("retry"), dict) else {}
return {
"poll_interval_s": _coerce_pos(
execution.get("poll_interval_s"), DEFAULT_POLL_INTERVAL_S,
),
"max_poll_attempts": _coerce_pos_int(
execution.get("max_poll_attempts"), DEFAULT_MAX_POLL_ATTEMPTS,
),
"timeout_per_poll_s": _coerce_pos(
execution.get("timeout_per_poll_s"), DEFAULT_TIMEOUT_PER_POLL_S,
),
"cancel_timeout_s": _coerce_pos(
execution.get("cancel_timeout_s"), DEFAULT_CANCEL_TIMEOUT_S,
),
"max_retries_per_poll": _coerce_pos_int(
execution.get("max_retries_per_poll"), DEFAULT_MAX_RETRIES_PER_POLL,
),
"retry_delay_per_poll_s": _coerce_pos(
execution.get("retry_delay_per_poll_s"), DEFAULT_RETRY_DELAY_PER_POLL_S,
),
"retry_backoff_per_poll": _coerce_pos(
execution.get("retry_backoff_per_poll"), DEFAULT_RETRY_BACKOFF_PER_POLL,
),
# Submit-side retry block — currently unused by execute_remote /
# execute_remote_async (they hard-code max_retries=2 in
# rnp_client) but lifted out here so future steps can plumb it
# through without re-parsing the descriptor.
"submit_retry": {
"max": _coerce_pos_int(retry_block.get("max"), 0)
if retry_block.get("max") is not None else 0,
"delay_s": _coerce_pos(retry_block.get("delay_s"), 1.0),
"backoff": _coerce_pos(retry_block.get("backoff"), 2.0),
"retry_on": list(retry_block.get("retry_on") or []),
},
"idempotency": execution.get("idempotency") or "none",
}
def _node_id_for_ticker(cls: type[IO.ComfyNode]) -> str | None:
"""Lift ``cls.hidden.unique_id`` for the per-second progress ticker.
Returns ``None`` if the hidden holder isn't populated (test paths
that build a class but don't run it through the executor).
"""
hidden = getattr(cls, "hidden", None)
if hidden is None:
return None
uid = getattr(hidden, "unique_id", None)
return str(uid) if uid is not None else None
async def _progress_ticker(
node_id: str | None,
estimated_duration_s: int | float | None,
*,
status_override: dict[str, Any] | None = None,
) -> None:
"""Emit one ``send_progress_text`` per second until cancelled.
Format mirrors ``comfy_api_nodes.util.client._display_time_progress``
so the progress badge looks identical to local partner nodes:
``Status: Processing\\nTime elapsed: <N>s`` (with
``(~Ns remaining)`` when the descriptor declared an estimate).
``status_override`` is a shared mutable holder
``{"label": str|None, "expires_at": float|None}`` the caller can
mutate to swap "Processing" for a transient message like
"Server busy, retrying in 30s..." while a backpressure 503 is
being waited out. The ticker auto-clears the override once
``time.monotonic() >= expires_at`` so a stale label doesn't linger
if the post-wait request takes longer than the announced
Retry-After window.
"""
if node_id is None:
return
started = time.monotonic()
while True:
elapsed = int(time.monotonic() - started)
if estimated_duration_s is not None and estimated_duration_s > 0:
remaining = max(0, int(estimated_duration_s) - elapsed)
time_line = f"Time elapsed: {elapsed}s (~{remaining}s remaining)"
else:
time_line = f"Time elapsed: {elapsed}s"
label = "Processing"
if status_override is not None:
expires_at = status_override.get("expires_at")
if expires_at is not None and time.monotonic() >= expires_at:
status_override["label"] = None
status_override["expires_at"] = None
override = status_override.get("label")
if override:
label = override
text = f"Status: {label}\n{time_line}"
with contextlib.suppress(Exception):
instance = PromptServer.instance
if instance is not None:
instance.send_progress_text(text, node_id)
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
return
log = logging.getLogger("comfy_remote_nodes.proxy_node")
def build_node_class(
node_id: str,
descriptor: dict[str, Any],
server_url: str,
*,
max_inline_bytes: int | None = None,
) -> type[IO.ComfyNode] | None:
"""Build an ``IO.ComfyNode`` subclass for one descriptor.
Returns ``None`` if the descriptor can't be parsed (e.g. an input
type ``_parse_input_spec`` doesn't recognise). The caller logs and
skips the node so one bad descriptor doesn't break the whole
extension.
"""
remote = descriptor.get("remote") or {}
if not isinstance(remote, dict):
log.warning("Descriptor %s missing 'remote' block — skipping", node_id)
return None
endpoints = remote.get("endpoints") or {}
execute_endpoint = endpoints.get("execute")
if not isinstance(execute_endpoint, dict) or not execute_endpoint.get("path"):
log.warning("Descriptor %s has no remote.endpoints.execute — skipping", node_id)
return None
inputs, hidden_names = _parse_inputs(descriptor)
if inputs is None:
return None
outputs = _parse_outputs(descriptor)
if outputs is None:
return None
schema_hash = remote.get("schema_hash")
execute_path = execute_endpoint["path"]
execution = remote.get("execution") or {}
timeout_s = float(
execution.get("hard_timeout_s")
or execution.get("soft_timeout_s")
or 600.0
)
estimated_duration_s = execution.get("estimated_duration_s")
if not isinstance(estimated_duration_s, (int, float)):
estimated_duration_s = None
execution_mode = execution.get("mode") or ExecutionMode.REQUEST_RESPONSE
execution_policy = _extract_execution_policy(execution)
# ``descriptor.remote.url_fetch`` declares per-output-type fetch
# posture for envelopes that arrive with ``uri`` instead of inline
# ``data``. Empty / absent → all outputs must inline their bytes.
url_fetch_policy = (
remote.get("url_fetch") if isinstance(remote.get("url_fetch"), dict) else {}
)
display_name = descriptor.get("display_name") or node_id
category = descriptor.get("category") or "remote"
description = descriptor.get("description") or ""
is_api_node = bool(descriptor.get("api_node", False))
is_experimental = bool(descriptor.get("experimental", False))
is_deprecated = bool(descriptor.get("deprecated", False))
schema_inputs = list(inputs)
schema_outputs = list(outputs)
hidden_decls = _hidden_decls_from_names(hidden_names, is_api_node)
# Forward every other V3 NodeInfoV1 field the descriptor surfaces.
# Anything not constructed here is silently dropped; the
# ``_parse_input_spec`` / ``_parse_outputs`` warnings are the
# signal a deployment needs to add a missing type to this builder.
is_input_list = bool(descriptor.get("is_input_list", False))
is_output_node = bool(descriptor.get("output_node", False))
is_dev_only = bool(descriptor.get("dev_only", False))
has_intermediate_output = bool(descriptor.get("has_intermediate_output", False))
search_aliases = descriptor.get("search_aliases") or []
essentials_category = descriptor.get("essentials_category")
price_badge = _build_price_badge(descriptor.get("price_badge"))
class RemoteProxyNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id=node_id,
display_name=display_name,
category=category,
description=description,
inputs=schema_inputs,
outputs=schema_outputs,
hidden=hidden_decls,
is_input_list=is_input_list,
is_output_node=is_output_node,
is_api_node=is_api_node,
is_experimental=is_experimental,
is_deprecated=is_deprecated,
is_dev_only=is_dev_only,
has_intermediate_output=has_intermediate_output,
search_aliases=list(search_aliases),
essentials_category=essentials_category,
price_badge=price_badge,
)
@classmethod
async def execute(cls, **kwargs: Any) -> IO.NodeOutput:
return await _execute_remote(
cls,
node_id=node_id,
server_url=server_url,
execute_path=execute_path,
schema_hash=schema_hash,
timeout_s=timeout_s,
outputs_meta=schema_outputs,
hidden_names=hidden_names,
inputs=kwargs,
max_inline_bytes=max_inline_bytes,
estimated_duration_s=estimated_duration_s,
execution_mode=execution_mode,
execution_policy=execution_policy,
url_fetch_policy=url_fetch_policy,
)
RemoteProxyNode.__name__ = node_id
RemoteProxyNode.__qualname__ = node_id
RemoteProxyNode.RELATIVE_PYTHON_MODULE = "custom_nodes.comfy_remote_nodes"
return RemoteProxyNode
# ---------------------------------------------------------------------------
# Input parsing
# ---------------------------------------------------------------------------
# The hidden input markers V3 emits in get_v1_info() — clients forward
# their values through the request ``context`` instead of treating them
# as user-facing inputs.
_HIDDEN_MARKERS = {
"AUTH_TOKEN_COMFY_ORG": IO.Hidden.auth_token_comfy_org,
"API_KEY_COMFY_ORG": IO.Hidden.api_key_comfy_org,
"UNIQUE_ID": IO.Hidden.unique_id,
"PROMPT": IO.Hidden.prompt,
"EXTRA_PNGINFO": IO.Hidden.extra_pnginfo,
"DYNPROMPT": IO.Hidden.dynprompt,
}
def _parse_inputs(descriptor: dict[str, Any]) -> tuple[list[Any] | None, list[str]]:
"""Return ``(input_objs, hidden_input_names)``.
Returns ``(None, [])`` if any required/optional input can't be
parsed (caller logs and skips).
"""
input_def = descriptor.get("input") or {}
input_order = descriptor.get("input_order") or {}
out: list[Any] = []
for kind, optional in (("required", False), ("optional", True)):
order = input_order.get(kind) or list((input_def.get(kind) or {}).keys())
defs = input_def.get(kind) or {}
for name in order:
spec = defs.get(name)
if spec is None:
continue
inp = _parse_input_spec(name, spec, optional=optional)
if inp is None:
log.warning(
"Skipping descriptor: unsupported input %r (%s)",
name, spec,
)
return None, []
out.append(inp)
hidden_names = list((input_def.get("hidden") or {}).keys())
return out, hidden_names
def _parse_input_spec(name: str, spec: list[Any], optional: bool) -> Any | None:
"""Map one V3-shaped ``[io_type, options_dict]`` entry to a V3 IO Input."""
if not isinstance(spec, (list, tuple)) or len(spec) < 1:
return None
io_type = spec[0]
options: dict[str, Any] = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
common = {
"tooltip": options.get("tooltip"),
"optional": optional,
}
if options.get("advanced") is not None:
common["advanced"] = bool(options.get("advanced"))
if isinstance(io_type, list):
# Legacy V1-style combo: io_type *is* the options list.
return IO.Combo.Input(name, options=list(io_type),
default=options.get("default"), **common)
if io_type == "COMBO":
opts = options.get("options")
if not isinstance(opts, (list, tuple)):
return None
return IO.Combo.Input(name, options=list(opts),
default=options.get("default"), **common)
if io_type == "STRING":
return IO.String.Input(
name,
default=options.get("default", ""),
multiline=bool(options.get("multiline", False)),
**common,
)
if io_type == "INT":
return IO.Int.Input(
name,
default=options.get("default", 0),
min=options.get("min", 0),
max=options.get("max", 2147483647),
step=options.get("step", 1),
**common,
)
if io_type == "FLOAT":
return IO.Float.Input(
name,
default=options.get("default", 0.0),
min=options.get("min", 0.0),
max=options.get("max", 1.0),
step=options.get("step", 0.01),
**common,
)
if io_type == "BOOLEAN":
return IO.Boolean.Input(
name, default=bool(options.get("default", False)), **common,
)
if io_type == "IMAGE":
return IO.Image.Input(name, **common)
if io_type == "VIDEO":
return IO.Video.Input(name, **common)
if io_type == "AUDIO":
return IO.Audio.Input(name, **common)
if io_type == "MASK":
return IO.Mask.Input(name, **common)
return None
def _build_price_badge(wire: Any) -> Any | None:
"""Convert the wire-format ``price_badge`` dict back to an
``IO.PriceBadge`` instance.
The wire shape (from ``PriceBadge.as_dict()``) carries
``depends_on.widgets`` as a list of ``{name, type}`` objects so the
frontend can resolve types; the V3 constructor only needs the
``name`` strings since it re-derives types from the schema's actual
inputs at ``as_dict()`` time. Inputs / input_groups round-trip as-is.
Returns ``None`` if the descriptor has no ``price_badge`` field.
"""
if not isinstance(wire, dict):
return None
expr = wire.get("expr")
if not isinstance(expr, str) or not expr.strip():
return None
dep = wire.get("depends_on") or {}
widgets = [
w["name"] for w in (dep.get("widgets") or []) if isinstance(w, dict) and "name" in w
]
inputs = list(dep.get("inputs") or [])
input_groups = list(dep.get("input_groups") or [])
engine = wire.get("engine", "jsonata")
try:
return IO.PriceBadge(
expr=expr,
depends_on=IO.PriceBadgeDepends(
widgets=widgets, inputs=inputs, input_groups=input_groups,
),
engine=engine,
)
except Exception as e: # noqa: BLE001
log.warning("Skipping price_badge — could not construct: %s", e)
return None
def _hidden_decls_from_names(hidden_names: list[str], is_api_node: bool) -> list[Any]:
"""Build the ``hidden=`` list for IO.Schema.
The descriptor lists each hidden input by name (e.g.
``auth_token_comfy_org``); the ``_HIDDEN_MARKERS`` table above
resolves those names back to V3 ``IO.Hidden`` enum members. Names
that aren't in the table are dropped with a debug log so a server
that adds a new hidden marker doesn't break older clients.
"""
out: list[Any] = []
seen: set[Any] = set()
for name in hidden_names:
marker = getattr(IO.Hidden, name, None)
if marker is None:
log.debug("Unknown hidden input %s — dropping", name)
continue
if marker not in seen:
out.append(marker)
seen.add(marker)
# IO.Schema.finalize() will add auth/api hidden if is_api_node, so
# we don't need to add them again here.
if not is_api_node:
# Non-api-node descriptors that happened to advertise auth
# hidden are forwarded as-is.
pass
return out
# ---------------------------------------------------------------------------
# Output parsing
# ---------------------------------------------------------------------------
def _parse_outputs(descriptor: dict[str, Any]) -> list[Any] | None:
output_types = descriptor.get("output") or []
output_names = descriptor.get("output_name") or output_types
output_is_list = descriptor.get("output_is_list") or [False] * len(output_types)
output_tooltips = descriptor.get("output_tooltips") or [None] * len(output_types)
out: list[Any] = []
for i, io_type in enumerate(output_types):
name = output_names[i] if i < len(output_names) else io_type
is_list = bool(output_is_list[i]) if i < len(output_is_list) else False
tip = output_tooltips[i] if i < len(output_tooltips) else None
cls = _OUTPUT_CLASSES.get(io_type)
if cls is None:
log.warning(
"Skipping descriptor: unsupported output type %r", io_type,
)
return None
out.append(cls(display_name=name, is_output_list=is_list, tooltip=tip))
return out
_OUTPUT_CLASSES = {
"VIDEO": IO.Video.Output,
"IMAGE": IO.Image.Output,
"AUDIO": IO.Audio.Output,
"MASK": IO.Mask.Output,
"STRING": IO.String.Output,
"INT": IO.Int.Output,
"FLOAT": IO.Float.Output,
"BOOLEAN": IO.Boolean.Output,
}
# ---------------------------------------------------------------------------
# Execute dispatch
# ---------------------------------------------------------------------------
def _extract_progress_pct(response: dict[str, Any]) -> int | None:
"""Map the RNP ``progress`` block to the 0-100 int that
``poll_op_raw`` feeds to its (single) ``ProgressBar(100)``.
Returns ``None`` when the server doesn't yet have progress to
report so the bar stays at its current position rather than
snapping back to 0.
"""
progress = response.get("progress")
if not isinstance(progress, dict):
return None
value = progress.get("value")
total = progress.get("max")
if not isinstance(value, (int, float)) or not isinstance(total, (int, float)) or total <= 0:
return None
pct = int(round((float(value) / float(total)) * 100))
return max(0, min(100, pct))
def _rnp_protocol_headers(extra: dict[str, str] | None = None) -> dict[str, str]:
"""Headers every RNP request carries — protocol-version + client
version. Pass ``extra`` to merge per-request headers (e.g. the
Idempotency-Key the server uses to authorize a TASK_LOST resume,
or the freshly-rotated auth token a long-running poll wants to
forward to the server)."""
headers = {
Header.PROTOCOL_VERSION: PROTOCOL_VERSION,
Header.CLIENT_VERSION: rnp_client.CLIENT_VERSION,
}
if extra:
headers.update(extra)
return headers
async def _run_async_polling(
cls: type[IO.ComfyNode],
*,
node_id: str,
server_url: str,
schema_hash: str | None,
payload_inputs: dict[str, Any],
context: dict[str, Any],
auth_headers: dict[str, str],
idempotency_key: str,
poll_interval_s: float,
estimated_duration_s: int | float | None,
max_poll_attempts: int,
timeout_per_poll_s: float,
cancel_timeout_s: float,
max_retries_per_poll: int,
retry_delay_per_poll_s: float,
retry_backoff_per_poll: float,
) -> dict[str, Any]:
"""Drive the ``execute_async`` + ``poll`` flow via ``poll_op_raw``.
Submits the task with ``execute_async``, then hands the polling
loop off to ``comfy_api_nodes.util.client.poll_op_raw`` so RNP
inherits the same per-poll retry/backoff, queued-vs-active
accounting, max-poll-attempts cap, ``ProgressBar`` plumbing,
ticker UI, and cancel-endpoint propagation as local partner
nodes. All of DONE/ERROR/CANCELLED are mapped to poll_op_raw's
"completed" set so the function returns the terminal poll-response
dict and we dispatch on ``status`` here — that preserves
``RnpProtocolError`` typing for ERROR and ``ProcessingInterrupted``
for CANCELLED instead of flattening both to
``Exception("Task failed: …")``.
On ``TASK_LOST`` (the server lost the task store between submit
and poll, typically a restart), the function resubmits exactly
once with the same idempotency key and resumes polling. A second
``TASK_LOST`` from the resume gives up — at that point the failure
is structural rather than transient.
"""
resume_attempted = False
while True:
submit = await rnp_client.execute_remote_async(
server_url,
schema_hash,
node_id,
payload_inputs,
context,
extra_headers=auth_headers or None,
idempotency_key=idempotency_key,
node_cls=cls,
)
task_id = submit["task_id"]
interval = float(submit.get("poll_interval") or poll_interval_s)
try:
response = await _poll_until_terminal(
cls,
node_id=node_id,
server_url=server_url,
task_id=task_id,
auth_headers=auth_headers,
idempotency_key=idempotency_key,
poll_interval_s=interval,
estimated_duration_s=estimated_duration_s,
max_poll_attempts=max_poll_attempts,
timeout_per_poll_s=timeout_per_poll_s,
cancel_timeout_s=cancel_timeout_s,
max_retries_per_poll=max_retries_per_poll,
retry_delay_per_poll_s=retry_delay_per_poll_s,
retry_backoff_per_poll=retry_backoff_per_poll,
)
except RnpProtocolError as e:
if e.code == ErrorCode.TASK_LOST and not resume_attempted:
resume_attempted = True
_send_progress_text(
cls,
"Server restarted, resuming…",
)
log.warning(
"RNP %s: TASK_LOST during poll (task_id=%s); "
"resubmitting with idempotency_key=%s",
node_id, task_id, idempotency_key,
)
continue
raise
return _interpret_terminal_response(task_id, response)
def _send_progress_text(cls: type[IO.ComfyNode], text: str) -> None:
"""Best-effort progress badge update — invisible when the executor
isn't running this class through PromptServer (e.g. unit tests)."""
node_id = _node_id_for_ticker(cls)
if node_id is None:
return
with contextlib.suppress(Exception):
instance = PromptServer.instance
if instance is not None:
instance.send_progress_text(text, node_id)
async def _poll_until_terminal(
cls: type[IO.ComfyNode],
*,
node_id: str,
server_url: str,
task_id: str,
auth_headers: dict[str, str],
idempotency_key: str,
poll_interval_s: float,
estimated_duration_s: int | float | None,
max_poll_attempts: int,
timeout_per_poll_s: float,
cancel_timeout_s: float,
max_retries_per_poll: int,
retry_delay_per_poll_s: float,
retry_backoff_per_poll: float,
) -> dict[str, Any]:
"""Run ``poll_op_raw`` against an existing task_id and return the
terminal poll-response dict. Wraps the per-request header build so
the resume path in ``_run_async_polling`` can reuse it."""
# Forward the idempotency key on every poll/cancel: the server
# uses it to gate TASK_LOST recovery (a typo'd task_id without an
# idempotency key falls through to a generic NOT_FOUND so it
# cannot trigger a phantom resubmit). Auth headers ride alongside
# so a token rotated mid-task picks up on the next upstream call
# the server makes.
per_request_headers = _rnp_protocol_headers({
Header.IDEMPOTENCY_KEY: idempotency_key,
**(auth_headers or {}),
})
poll_endpoint = ApiEndpoint(
f"rnp/v1/tasks/{task_id}", "GET",
headers=per_request_headers,
)
cancel_endpoint = ApiEndpoint(
f"rnp/v1/tasks/{task_id}/cancel", "POST",
headers=per_request_headers,
)
return await poll_op_raw(
cls,
poll_endpoint,
status_extractor=lambda r: r.get("status"),
progress_extractor=_extract_progress_pct,
completed_statuses=[TaskStatus.DONE, TaskStatus.ERROR, TaskStatus.CANCELLED],
failed_statuses=[],
queued_statuses=[TaskStatus.PENDING],
poll_interval=poll_interval_s,
max_poll_attempts=max_poll_attempts,
timeout_per_poll=timeout_per_poll_s,
max_retries_per_poll=max_retries_per_poll,
retry_delay_per_poll=retry_delay_per_poll_s,
retry_backoff_per_poll=retry_backoff_per_poll,
estimated_duration=int(estimated_duration_s) if estimated_duration_s else None,
cancel_endpoint=cancel_endpoint,
cancel_timeout=cancel_timeout_s,
base_url=server_url,
auth_headers={},
error_parser=rnp_client._parse_rnp_error,
is_rate_limited=rnp_client._is_rnp_backpressure,
rate_limit_label=rnp_client._rnp_rate_limit_label,
)
def _interpret_terminal_response(
task_id: str, response: dict[str, Any],
) -> dict[str, Any]:
"""Map a terminal poll-response dict to the same ``{"outputs": [...]}``
shape sync ``execute`` returns, or raise the appropriate typed
exception for ERROR / CANCELLED."""
status = response.get("status")
if status == TaskStatus.DONE:
outputs = response.get("outputs")
if not isinstance(outputs, list):
raise RnpProtocolError(
f"task {task_id} done but no outputs list: {response!r}",
)
return {"outputs": outputs}
if status == TaskStatus.ERROR:
err_body = response.get("exception")
if not isinstance(err_body, dict):
raise RnpProtocolError(
f"task {task_id} errored without exception body",
)
raise RnpProtocolError.from_response_body(err_body, 500)
if status == TaskStatus.CANCELLED:
raise ProcessingInterrupted(f"task {task_id} cancelled")
raise RnpProtocolError(
f"task {task_id} returned unrecognised terminal status: {status!r}",
)
async def _execute_remote(
cls: type[IO.ComfyNode],
*,
node_id: str,
server_url: str,
execute_path: str,
schema_hash: str | None,
timeout_s: float,
outputs_meta: list[Any],
hidden_names: list[str],
inputs: dict[str, Any],
max_inline_bytes: int | None = None,
estimated_duration_s: int | float | None = None,
execution_mode: str = ExecutionMode.REQUEST_RESPONSE,
execution_policy: dict[str, Any] | None = None,
url_fetch_policy: dict[str, Any] | None = None,
) -> IO.NodeOutput:
policy = execution_policy or _extract_execution_policy({})
url_fetch_policy = url_fetch_policy or {}
# Strip hidden inputs from the user-supplied kwargs. Auth credentials
# (``auth_token_comfy_org`` / ``api_key_comfy_org``) travel as standard
# HTTP headers so the server can forward them to api.comfy.org without
# parsing the body; everything else rides in the request ``context``.
context: dict[str, Any] = {}
auth_headers: dict[str, str] = {}
hidden_holder = getattr(cls, "hidden", None)
for hname in hidden_names:
# Prefer cls.hidden (the standard executor path); fall back to a
# value piped directly into kwargs for advanced workflows that
# wire strings into hidden slots manually. Either way, strip the
# name from the user-input kwargs so it doesn't leak into the
# request body.
hidden_value = (
getattr(hidden_holder, hname, None) if hidden_holder is not None else None
)
piped_value = inputs.pop(hname, None)
value = hidden_value if hidden_value is not None else piped_value
if value is None:
continue
if hname == "auth_token_comfy_org":
auth_headers["Authorization"] = f"Bearer {value}"
elif hname == "api_key_comfy_org":
auth_headers["X-API-KEY"] = str(value)
else:
context[hname] = value
# Encode heavy-typed inputs (IMAGE/MASK/AUDIO) as RNP value
# envelopes; scalars pass through. Oversized envelopes are
# uploaded out-of-band and rewritten to ``{..., uri: <url>}``.
payload_inputs = await _encode_inputs(
inputs,
server_url=server_url,
max_inline_bytes=max_inline_bytes,
auth_headers=auth_headers,
)
ticker_node_id = _node_id_for_ticker(cls)
# Stable per-execute() idempotency key — when the underlying HTTP
# client retries the POST after a connection drop, the server sees
# the same key and returns the original task_id rather than
# spawning a duplicate run.
idempotency_key = uuid.uuid4().hex
async def _run_with_ticker(
execute_schema_hash: str | None,
) -> dict[str, Any]:
if execution_mode == ExecutionMode.ASYNC_POLLING:
# poll_op_raw runs its own _display_time_progress ticker,
# so we don't start a parallel one here — the sync path
# below still uses the local ticker since execute_remote
# blocks on a single HTTP request without UI feedback.
return await _run_async_polling(
cls,
node_id=node_id,
server_url=server_url,
schema_hash=execute_schema_hash,
payload_inputs=payload_inputs,
context=context,
auth_headers=auth_headers,
idempotency_key=idempotency_key,
poll_interval_s=policy["poll_interval_s"],
estimated_duration_s=estimated_duration_s,
max_poll_attempts=policy["max_poll_attempts"],
timeout_per_poll_s=policy["timeout_per_poll_s"],
cancel_timeout_s=policy["cancel_timeout_s"],
max_retries_per_poll=policy["max_retries_per_poll"],
retry_delay_per_poll_s=policy["retry_delay_per_poll_s"],
retry_backoff_per_poll=policy["retry_backoff_per_poll"],
)
# Shared mutable holder the local ticker watches so a
# SERVER_BUSY/MAINTENANCE retry inside execute_remote can swap
# the per-second "Status: Processing" line for the friendly
# backpressure copy without restarting the ticker. The ticker
# auto-expires the override once ``expires_at`` passes so a
# stale "Server busy, retrying in 30s..." doesn't linger if the
# post-wait request itself takes longer than the announced
# Retry-After window.
status_override: dict[str, Any] = {"label": None, "expires_at": None}
def _local_rate_limit_label(
status: int, body: Any, retry_after_s: float,
) -> str | None:
label = rnp_client._rnp_rate_limit_label(status, body, retry_after_s)
status_override["label"] = label # may be None for unknown codes
status_override["expires_at"] = (
time.monotonic() + max(1.0, retry_after_s) if label else None
)
return label
ticker = asyncio.create_task(
_progress_ticker(
ticker_node_id,
estimated_duration_s,
status_override=status_override,
),
)
try:
return await rnp_client.execute_remote(
server_url,
execute_schema_hash,
execute_path,
payload_inputs,
context,
timeout_s=timeout_s,
extra_headers=auth_headers or None,
node_cls=cls,
rate_limit_label=_local_rate_limit_label,
)
finally:
ticker.cancel()
with contextlib.suppress(BaseException):
await ticker
try:
body = await _run_with_ticker(schema_hash)
except RnpProtocolError as e:
# Schema-hash mismatch → re-fetch object_info, find the new
# hash for this node, and retry once. This recovers from the
# common deploy ordering of "server updated descriptor ⇒
# client still using cached one" without forcing a ComfyUI
# restart.
if e.code == ErrorCode.SCHEMA_HASH_MISMATCH:
new_hash = await _refresh_schema_hash(server_url, node_id)
if new_hash and new_hash != schema_hash:
log.info(
"RNP %s: schema_hash changed (%s → %s); retrying execute",
node_id, schema_hash, new_hash,
)
try:
body = await _run_with_ticker(new_hash)
except RnpProtocolError as e2:
raise RuntimeError(
_to_friendly_message(e2, node_id),
) from e2
else:
raise RuntimeError(
f"Remote node {node_id}: schema-hash mismatch and no "
f"updated descriptor found upstream"
) from e
else:
log.warning("RNP execute %s failed: %s [%s]", node_id, e, e.code)
raise RuntimeError(_to_friendly_message(e, node_id)) from e
outputs = body.get("outputs")
if not isinstance(outputs, list):
raise RuntimeError(
f"Remote node {node_id} returned no 'outputs' list"
)
if len(outputs) != len(outputs_meta):
raise RuntimeError(
f"Remote node {node_id} returned {len(outputs)} outputs; "
f"schema declared {len(outputs_meta)}"
)
decoded = []
for value, meta in zip(outputs, outputs_meta):
decoded.append(await _deserialize_output(
value,
meta,
url_fetch_policy=url_fetch_policy,
server_url=server_url,
auth_headers=auth_headers,
node_cls=cls,
))
return IO.NodeOutput(*decoded)
async def _refresh_schema_hash(server_url: str, node_id: str) -> str | None:
"""Re-fetch ``/object_info`` and return the freshly published
``remote.schema_hash`` for ``node_id`` (or ``None`` if the node has
been removed upstream).
"""
try:
body, _etag, _max_age = await rnp_client.fetch_object_info(server_url)
except Exception as e: # noqa: BLE001
log.warning("schema-hash refresh failed for %s: %s", node_id, e)
return None
if not isinstance(body, dict):
return None
descriptor = body.get(node_id)
if not isinstance(descriptor, dict):
return None
remote = descriptor.get("remote") or {}
return remote.get("schema_hash") if isinstance(remote, dict) else None
async def _deserialize_output(
value: Any,
meta: Any,
*,
url_fetch_policy: dict[str, Any],
server_url: str,
auth_headers: dict[str, str],
node_cls: type[IO.ComfyNode] | None,
) -> Any:
"""Convert one output value (envelope or scalar) to its native Python form.
Inline envelopes (with ``data``) decode immediately. URI envelopes
look up their fetch policy from ``url_fetch_policy`` (keyed by the
envelope's ``type``) and fetch the bytes via ``sync_op_raw`` so the
GET inherits ``ProcessingInterrupted`` cancellation, retry/backoff
on transient errors, and the per-policy timeout. Scalars pass
through unchanged."""
if not is_envelope(value):
# Scalars pass through unchanged — the schema's output type is
# advisory here, the value already matches the native ComfyUI
# type for STRING / INT / FLOAT / BOOLEAN.
return value
if "data" not in value and "uri" in value:
env_type = value.get("type", "")
policy = url_fetch_policy.get(env_type)
if not isinstance(policy, dict):
raise RnpProtocolError(
f"Server returned a URL envelope for output type {env_type!r} "
"but the descriptor declared no remote.url_fetch policy "
"for that type",
code=ErrorCode.INTERNAL,
)
raw = await _fetch_url_envelope(
value, policy,
auth_headers=auth_headers,
node_cls=node_cls,
)
# Re-stamp the envelope with inline ``data`` so the existing
# decoder dispatch in ``serialization`` sees a homogeneous
# input shape regardless of how the bytes arrived.
import base64 as _b64
value = {k: v for k, v in value.items() if k != "uri"}
value["data"] = _b64.b64encode(raw).decode("ascii")
return serialization.decode_envelope(value)
async def _fetch_url_envelope(
envelope: dict[str, Any],
policy: dict[str, Any],
*,
auth_headers: dict[str, str],
node_cls: type[IO.ComfyNode] | None,
) -> bytes:
"""GET ``envelope["uri"]`` via ``sync_op_raw`` honoring the descriptor's
url_fetch policy (retries, backoff, timeout, optional auth).
``url_kind`` drives the auth posture: ``presigned`` / ``public`` →
no auth header (the URL embeds its own credentials or needs none);
``comfy_api`` → forward the same Authorization / X-API-KEY the
polls use. ``auth_required`` overrides ``url_kind`` when explicitly
set to ``True``.
"""
from comfy_api_nodes.util.client import ApiEndpoint, sync_op_raw
uri = envelope["uri"]
url_kind = policy.get("url_kind") or "presigned"
auth_required = bool(policy.get(
"auth_required",
url_kind == "comfy_api",
))
fetch_auth: dict[str, str] = (
dict(auth_headers) if (auth_required and auth_headers) else {}
)
timeout_s = float(policy.get("timeout_s") or 300)
max_retries = int(policy.get("max_retries") or 3)
retry_delay = float(policy.get("retry_delay_s") or 1.0)
retry_backoff = float(policy.get("retry_backoff") or 2.0)
raw = await sync_op_raw(
node_cls,
ApiEndpoint(uri, "GET"),
timeout=timeout_s,
max_retries=max_retries,
retry_delay=retry_delay,
retry_backoff=retry_backoff,
wait_label="Downloading",
as_binary=True,
final_label_on_success=None,
monitor_progress=False,
auth_headers=fetch_auth,
)
if not isinstance(raw, (bytes, bytearray)):
raise RnpProtocolError(
f"url_fetch GET {uri!r} returned non-bytes payload",
code=ErrorCode.INTERNAL,
)
return bytes(raw)
async def _encode_inputs(
inputs: dict[str, Any],
*,
server_url: str | None = None,
max_inline_bytes: int | None = None,
auth_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Convert in-place: replace heavy-typed input values with the
matching RNP value envelope. Scalars pass through unchanged.
The duck-typing rules:
* ``torch.Tensor`` rank 4 (B,H,W,C) → image envelope.
* ``torch.Tensor`` rank 3 with last-dim != 3/4 → mask envelope (B,H,W).
Rank-2 (H,W) tensors are also treated as masks.
* ``dict`` with ``waveform`` + ``sample_rate`` → audio envelope.
* Everything else passes through as-is (already JSON-serializable).
When ``max_inline_bytes`` is set, any envelope whose inline payload
exceeds the threshold is externalized via ``POST /rnp/v1/uploads``
(see ``serialization.maybe_externalize``).
"""
out: dict[str, Any] = {}
for name, value in inputs.items():
encoded = _encode_one(name, value)
# Only envelope-shaped values can be externalized; scalars pass
# straight through ``maybe_externalize`` unchanged.
if serialization.is_envelope(encoded):
encoded = await serialization.maybe_externalize(
encoded,
server_url=server_url,
max_inline_bytes=max_inline_bytes,
auth_headers=auth_headers,
)
out[name] = encoded
return out
def _encode_one(name: str, value: Any) -> Any:
if serialization.is_audio_input(value):
return serialization.encode_audio_input(value)
if not serialization._is_torch_tensor(value):
return value
rank = value.dim()
last_dim = int(value.shape[-1]) if rank >= 1 else 0
if rank == 4 or (rank == 3 and last_dim in (3, 4)):
# Per-image tensors: a length-1 batch is the common case; longer
# batches send only the first frame (the framework hasn't asked
# for batch-of-envelopes semantics yet).
return serialization.encode_image_tensor(value)
if rank in (2, 3):
return serialization.encode_mask_tensor(value)
log.warning(
"Input %s: unrecognised tensor rank %d — sending as-is",
name, rank,
)
return value