diff --git a/client.py b/client.py index fb3a5a6..84a9843 100644 --- a/client.py +++ b/client.py @@ -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. diff --git a/protocol.py b/protocol.py index 66d263d..2ecc8d6 100644 --- a/protocol.py +++ b/protocol.py @@ -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 diff --git a/proxy_node.py b/proxy_node.py index 3e66606..2c0ef9a 100644 --- a/proxy_node.py +++ b/proxy_node.py @@ -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, ),