Files
Comfy-Org-comfy-remote-nodes/protocol.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

173 lines
5.4 KiB
Python

"""Vendored copy of the parts of ``comfy_rnp_protocol`` the client needs.
The full protocol package lives in ``comfy-rnp-server/comfy_rnp_protocol``
and is the source of truth for these constants and helpers — this file
is a thin mirror so the ComfyUI extension can ship without depending on
the server package being on ``PYTHONPATH``. Keep it byte-compatible with
the upstream module.
"""
from __future__ import annotations
import base64
from typing import Any
PROTOCOL_NAME = "comfy-rnp"
PROTOCOL_VERSION = "1.0"
SCHEMA_HASH_ALGORITHM = "sha256"
DEFAULT_MAX_INLINE_PAYLOAD_BYTES = 8 * 1024 * 1024
DEFAULT_POLL_INTERVAL_S = 1.5
# Defaults for descriptor.remote.execution fields the client must honour.
# Values mirror comfy_api_nodes.util.client.poll_op_raw so RNP-routed
# nodes behave identically to a direct upstream call when the descriptor
# leaves a field unset.
DEFAULT_MAX_POLL_ATTEMPTS = 160
DEFAULT_TIMEOUT_PER_POLL_S = 120.0
DEFAULT_MAX_RETRIES_PER_POLL = 10
DEFAULT_RETRY_DELAY_PER_POLL_S = 1.0
DEFAULT_RETRY_BACKOFF_PER_POLL = 1.4
DEFAULT_CANCEL_TIMEOUT_S = 10.0
DEFAULT_SUBMIT_MAX_RETRIES = 2
DEFAULT_SUBMIT_RETRY_BACKOFF = 2.0
DEFAULT_SUBMIT_RETRY_DELAY_S = 1.0
class IdempotencyMode:
CLIENT_KEY = "client_key"
NONE = "none"
class ExecutionMode:
REQUEST_RESPONSE = "request_response"
ASYNC_POLLING = "async_polling"
class TaskStatus:
PENDING = "pending"
RUNNING = "running"
DONE = "done"
ERROR = "error"
CANCELLED = "cancelled"
TERMINAL_TASK_STATES = frozenset({
TaskStatus.DONE,
TaskStatus.ERROR,
TaskStatus.CANCELLED,
})
class ErrorCode:
INPUT_INVALID = "INPUT_INVALID"
AUTH_FAILED = "AUTH_FAILED"
INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS"
RATE_LIMITED = "RATE_LIMITED"
PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE"
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
VERSION_INCOMPATIBLE = "VERSION_INCOMPATIBLE"
SCHEMA_HASH_MISMATCH = "SCHEMA_HASH_MISMATCH"
TIMEOUT = "TIMEOUT"
INTERNAL = "INTERNAL"
NOT_FOUND = "NOT_FOUND"
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
# 404 from a poll/cancel that carried X-RNP-Idempotency-Key — the
# server lost the task (typically a restart). The client replays
# the original submit with the same key to spawn a fresh task.
TASK_LOST = "TASK_LOST"
# Server-side back-pressure / operator drain — both ride 503 with
# Retry-After. SERVER_BUSY = transient saturation, MAINTENANCE =
# operator action.
SERVER_BUSY = "SERVER_BUSY"
MAINTENANCE = "MAINTENANCE"
class Header:
PROTOCOL_VERSION = "X-RNP-Protocol-Version"
CLIENT_VERSION = "X-RNP-Client-Version"
CLIENT_CAPABILITIES = "X-RNP-Client-Capabilities"
SCHEMA_HASH = "X-RNP-Schema-Hash"
IDEMPOTENCY_KEY = "X-RNP-Idempotency-Key"
class Capability:
IMAGE_PNG_BASE64 = "image:png_base64"
IMAGE_JPEG_BASE64 = "image:jpeg_base64"
IMAGE_RAW_CHW_UINT8 = "image:raw_b64_chw_uint8"
VIDEO_MP4_INLINE = "video:mp4_inline"
AUDIO_MP3_INLINE = "audio:mp3_inline"
MASK_PNG_BASE64 = "mask:png_base64"
SCHEMA_V3 = "schema:v3"
ASYNC_EXECUTE = "execute:async"
HEAVY_TYPES = frozenset({"image", "video", "audio", "mask"})
def is_envelope(value: Any) -> bool:
return (
isinstance(value, dict)
and isinstance(value.get("type"), str)
and value.get("type") in HEAVY_TYPES
and isinstance(value.get("encoding"), str)
)
def decode_envelope_data(envelope: dict[str, Any]) -> bytes:
"""Decode the inline base64 ``data`` of an envelope.
URI envelopes are resolved out-of-band by the caller (see
``proxy_node._deserialize_output``); by the time they reach this
function the bytes have already been fetched and re-stamped onto
``data``.
"""
if "data" not in envelope:
raise ValueError(
"envelope has no inline 'data' — caller must resolve "
f"{sorted(k for k in envelope.keys() if k != 'data')!r} first"
)
return base64.b64decode(envelope["data"])
class RnpProtocolError(Exception):
"""Raised when a server response can't be parsed as RNP/1, or when
a structured-error response is returned. Carries the parsed error
envelope so the caller can surface ``user_facing`` messages.
"""
def __init__(
self,
message: str,
*,
code: str = ErrorCode.INTERNAL,
status: int | None = None,
user_facing: bool = False,
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.status = status
self.user_facing = user_facing
self.retryable = retryable
self.details = details
@classmethod
def from_response_body(
cls, body: dict[str, Any] | None, status: int
) -> "RnpProtocolError":
err = (body or {}).get("error") if isinstance(body, dict) else None
if not isinstance(err, dict):
return cls(
f"Unstructured error response (HTTP {status})",
code=ErrorCode.INTERNAL,
status=status,
)
return cls(
err.get("message") or f"HTTP {status}",
code=err.get("code") or ErrorCode.INTERNAL,
status=status,
user_facing=bool(err.get("user_facing")),
retryable=bool(err.get("retryable")),
details=err.get("details") if isinstance(err.get("details"), dict) else None,
)