Final pieces of Track A (async-polling mode) on the client side. The proxy_node already routes between ``/execute`` and ``/execute_async`` on the descriptor's ``remote.execution.mode``; this commit makes the contract honest in two ways: - ``client.py``: advertise the new ``Capability.ASYNC_EXECUTE`` token so the server can gate ``async_polling`` descriptors on its presence. An older client that only knows ``/execute`` is now visible to the server and won't be silently routed to a long-poll provider whose lifecycle it can't drive. - ``protocol.py`` + ``proxy_node.py``: drop the ``DEFAULT_MAX_POLL_ATTEMPTS`` wire-default in favour of duration-based ``DEFAULT_SOFT_TIMEOUT_S`` / ``DEFAULT_HARD_TIMEOUT_S`` / ``DEFAULT_POLL_GRACE_S``. The poll-loop cap is now a *derived* value (``ceil((hard_timeout_s + grace) / poll_interval_s)``) so changing the poll cadence can never silently shrink or stretch the contract. Industry precedent: OAuth2 device flow §3.5, Kubernetes ``--timeout``, gRPC deadlines all express bounds as durations. Lockstep with the server side of Track A (comfy-rnp-server feat/async-polling-mode), which now enforces ``mode`` dispatch on ``/execute`` vs ``/execute_async`` and migrates existing providers (BFL/Bria/LTXV/Grok video/OpenAIChatNode/Stability UpscaleCreative → ``async_polling``; DALL-E/Ideogram/Recraft/sync Stability → ``request_response``). Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e28eb-31ae-76a1-bb45-c0922f43bcc3
190 lines
6.4 KiB
Python
190 lines
6.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.
|
|
#
|
|
# Lifetime budget design: the wire format declares ``soft_timeout_s``
|
|
# (server-side stuck threshold) and ``hard_timeout_s`` (server-side
|
|
# hard kill) as durations. The client's poll-loop cap is a derived
|
|
# value — ``max_poll_attempts = ceil((hard_timeout_s + DEFAULT_POLL_GRACE_S)
|
|
# / poll_interval_s)`` — not a separate wire field, so changing the
|
|
# poll cadence can never silently shrink or stretch the contract.
|
|
# Industry precedent: OAuth2 device flow §3.5, Kubernetes
|
|
# ``--timeout``, gRPC deadlines all express bounds as durations.
|
|
DEFAULT_SOFT_TIMEOUT_S = 240.0
|
|
DEFAULT_HARD_TIMEOUT_S = 300.0
|
|
DEFAULT_POLL_GRACE_S = 30.0
|
|
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"
|
|
# Multi-frame IMAGE envelope: a single value carries N PNG-base64
|
|
# frames sharing the same H/W/C, decoded into a [B,H,W,C] tensor on
|
|
# this client. Mirrors the AUDIO sweep precedent (parallel encodings
|
|
# rather than extending ``png_base64``) so legacy server emits still
|
|
# decode correctly when the new token isn't advertised.
|
|
IMAGE_PNG_BASE64_BATCH = "image:png_base64_batch"
|
|
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,
|
|
)
|