Merge pull request #5 from Comfy-Org/feat/async-polling-mode

feat(async-polling): advertise ASYNC_EXECUTE capability and derive poll cap from duration
This commit is contained in:
Jedrzej Kosinski
2026-05-14 19:52:01 -07:00
committed by GitHub
3 changed files with 45 additions and 12 deletions
+7
View File
@@ -43,6 +43,13 @@ CLIENT_CAPABILITIES = [
Capability.IMAGE_PNG_BASE64,
Capability.IMAGE_PNG_BASE64_BATCH,
Capability.MASK_PNG_BASE64,
# Advertises that this client can drive the
# ``execute_async`` + poll-loop lifecycle (submit, poll, cancel,
# TASK_LOST replay). Servers gate ``async_polling``-mode
# descriptors on the presence of this token so an older client
# that only knows ``/execute`` doesn't silently submit a
# long-poll node into the sync endpoint.
Capability.ASYNC_EXECUTE,
]
# Reported in the X-RNP-Client-Version header.
+12 -1
View File
@@ -21,7 +21,18 @@ DEFAULT_POLL_INTERVAL_S = 1.5
# 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
#
# 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
+26 -11
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import math
import time
import uuid
from typing import Any
@@ -36,8 +37,9 @@ from . import client as rnp_client
from . import serialization
from .protocol import (
DEFAULT_CANCEL_TIMEOUT_S,
DEFAULT_MAX_POLL_ATTEMPTS,
DEFAULT_HARD_TIMEOUT_S,
DEFAULT_MAX_RETRIES_PER_POLL,
DEFAULT_POLL_GRACE_S,
DEFAULT_POLL_INTERVAL_S,
DEFAULT_RETRY_BACKOFF_PER_POLL,
DEFAULT_RETRY_DELAY_PER_POLL_S,
@@ -146,19 +148,32 @@ def _extract_execution_policy(execution: dict[str, Any]) -> dict[str, Any]:
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``.
``soft_timeout_s``, ``hard_timeout_s``, ``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``.
The client's poll-loop cap (``max_poll_attempts``) is **derived**
from ``hard_timeout_s + DEFAULT_POLL_GRACE_S`` and
``poll_interval_s`` — there is no wire field for it. Coupling a
contract bound to a cadence value silently breaks every time you
tune the cadence; expressing the bound as a duration keeps the
contract invariant under cadence changes (see protocol.py
"Lifetime budget design" comment).
"""
retry_block = execution.get("retry") if isinstance(execution.get("retry"), dict) else {}
poll_interval_s = _coerce_pos(
execution.get("poll_interval_s"), DEFAULT_POLL_INTERVAL_S,
)
hard_timeout_s = _coerce_pos(
execution.get("hard_timeout_s"),
_coerce_pos(execution.get("soft_timeout_s"), DEFAULT_HARD_TIMEOUT_S),
)
poll_budget_s = hard_timeout_s + DEFAULT_POLL_GRACE_S
max_poll_attempts = max(1, math.ceil(poll_budget_s / poll_interval_s))
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,
),
"poll_interval_s": poll_interval_s,
"max_poll_attempts": max_poll_attempts,
"timeout_per_poll_s": _coerce_pos(
execution.get("timeout_per_poll_s"), DEFAULT_TIMEOUT_PER_POLL_S,
),