Files
Ivor StrelarandClaude Opus 5.5 17964f3017 fix: retry dropped connections when polling, uploading and downloading on WaveSpeed
A GPT Image run failed with "Connection reset by peer" while polling an
already-submitted task, losing an image that WaveSpeed went on to bill. One
transient network error was enough to abort the node.

Polling, image uploads and result downloads are safe to repeat, so they now
retry connection errors and timeouts up to five times (2, 4, 8, 15, 30 s
apart) before failing with a message that the task may still finish on
WaveSpeed. Submitting a task is deliberately not retried: an unclear failure
there could otherwise create a second billed task.

Applies to every node on the shared v3 helpers (GPT Image, Nano Banana,
Seedream, Seedance, Tripo, Rodin, Meshy). Verified with a stubbed API that
drops connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:39:21 +01:00

275 lines
11 KiB
Python

# ABOUTME: Shared async helpers for nodes that call WaveSpeed's v3 prediction API directly
# ABOUTME: Lets those nodes run in parallel in ComfyUI: every blocking call runs off the event loop
import asyncio
import io
import os
import time
from urllib.parse import urlparse
import numpy as np
import requests
import torch
from PIL import Image
from .utils import tensor2images
try:
from comfy_api.latest import Types
File3D = Types.File3D
except ImportError: # older ComfyUI without 3D file types
File3D = None
RESULT_PATH = "/api/v3/predictions/{id}/result"
TERMINAL_FAILURES = {"failed", "cancelled", "timeout", "deleted"}
# The partner image nodes downscale references to this many pixels before upload.
MAX_UPLOAD_PIXELS = 2048 * 2048
# ComfyUI runs an `async def` node as a background task and moves on to other
# ready nodes while it waits, which is how partner nodes overlap. That only
# helps if the coroutine actually yields: WaveSpeedClient uses blocking
# `requests`, so every call below goes through asyncio.to_thread.
def check_interrupted():
"""Raise if the user pressed Cancel in ComfyUI; a no-op outside ComfyUI."""
try:
import comfy.model_management as mm
except ImportError:
return
mm.throw_exception_if_processing_interrupted()
# Network failures worth retrying: a dropped or reset connection, or a request
# that timed out. Only used for requests that are safe to repeat (polling,
# uploads, downloads), never for submitting a task, which WaveSpeed bills.
TRANSIENT_ERRORS = (requests.exceptions.ConnectionError, requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError)
RETRY_DELAYS = (2, 4, 8, 15, 30)
async def with_retries(fn, *args, label="WaveSpeed"):
"""Run a blocking, repeatable request in a worker thread, retrying network
hiccups with growing pauses before giving up."""
for attempt, delay in enumerate(RETRY_DELAYS + (None,), start=1):
try:
return await asyncio.to_thread(fn, *args)
except TRANSIENT_ERRORS as e:
if delay is None:
raise RuntimeError(
f"{label}: lost the connection to WaveSpeed {attempt} times in a row ({e}). "
f"Check your internet connection; any task already submitted may still finish "
f"on WaveSpeed's side."
) from e
print(f"[SF {label}] Network error ({type(e).__name__}); retrying in {delay}s "
f"({attempt}/{len(RETRY_DELAYS)}).")
check_interrupted()
await asyncio.sleep(delay)
async def call(client, fn, *args, retry=False):
"""Run a blocking API call in a worker thread, turning a 401 into an error
that says which key was used. `retry` is only for calls that are safe to repeat."""
try:
if retry:
return await with_retries(fn, *args)
return await asyncio.to_thread(fn, *args)
except Exception as e:
if "401" in str(e) or "Unauthorized" in str(e):
source = client.get("source", "the SF WaveSpeed Client node")
raise RuntimeError(
f"WaveSpeed rejected the API key, which came from {source}. "
f"Check it on wavespeed.ai under API Keys, or clear the api_key field "
f"on SF WaveSpeed Client to fall back to a key saved elsewhere."
) from e
raise
async def wait_for_result(api, task_id, timeout_seconds, label, interval=2):
"""Poll a prediction until it completes, yielding between polls. Returns the result."""
loop = asyncio.get_running_loop()
started = loop.time()
while True:
check_interrupted()
result = await with_retries(api.get, RESULT_PATH.format(id=task_id), label=label)
status = result.get("status")
if status == "completed":
return result
if status in TERMINAL_FAILURES:
raise RuntimeError(f"{label} task {status}: {result.get('error') or result}")
if loop.time() - started > timeout_seconds:
raise RuntimeError(f"{label} task {task_id} still '{status}' after {timeout_seconds}s.")
await asyncio.sleep(interval)
async def submit_and_collect(api, client, endpoint, payload, count, timeout_seconds, label):
"""Submit `count` identical predictions at once and wait for all of them.
Returns every output URL, in submission order."""
async def one():
task = await call(client, api.post, endpoint, payload)
if not task.get("id"):
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
result = await wait_for_result(api, task["id"], timeout_seconds, label)
outputs = result.get("outputs") or []
return outputs if isinstance(outputs, list) else [outputs]
batches = await asyncio.gather(*(one() for _ in range(count)))
return [url for batch in batches for url in batch]
def flatten_images(tensors):
"""Every frame of every IMAGE tensor, as PIL images."""
frames = []
for tensor in tensors:
if tensor is None:
continue
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
frames.extend(tensor2images(batch))
return frames
def downscale(image, max_pixels=MAX_UPLOAD_PIXELS):
w, h = image.size
if w * h <= max_pixels:
return image
scale = (max_pixels / (w * h)) ** 0.5
return image.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
async def upload_images(api, client, images):
"""Upload references concurrently, downscaled first. Returns their URLs in order."""
return list(await asyncio.gather(*(call(client, api.upload_file, downscale(img), retry=True)
for img in images)))
async def fetch_images(urls, timeout=120):
"""Download output images concurrently."""
def get(url):
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return Image.open(io.BytesIO(response.content))
return list(await asyncio.gather(*(with_retries(get, url, label="download") for url in urls)))
def _has_alpha(image):
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
def to_image_batch(frames):
"""One IMAGE tensor from PIL frames. Keeps alpha when any frame is transparent;
resizes stragglers to the first frame, since a batch must share one size."""
if not frames:
raise RuntimeError("WaveSpeed returned no images.")
mode = "RGBA" if any(_has_alpha(f) for f in frames) else "RGB"
width, height = frames[0].size
tensors = []
for frame in frames:
if frame.size != (width, height):
frame = frame.resize((width, height), Image.LANCZOS)
tensors.append(torch.from_numpy(np.asarray(frame.convert(mode), dtype=np.float32) / 255.0)[None,])
return torch.cat(tensors, dim=0)
# --- 3D models -----------------------------------------------------------------
def pick_model_url(outputs, prefer=None):
"""The model file URL out of a result's `outputs`, which may be a list or a string.
`prefer` (e.g. ".fbx") wins when a result carries several model files."""
candidates = outputs if isinstance(outputs, list) else [outputs]
urls = [u for u in candidates if isinstance(u, str) and u.startswith("http")]
for url in urls:
if prefer and urlparse(url).path.lower().endswith(prefer):
return url
for url in urls:
if urlparse(url).path.lower().endswith((".glb", ".fbx")):
return url
if urls:
return urls[0]
raise RuntimeError(f"No model URL in the result: {outputs!r}")
def output_dir():
"""ComfyUI's output/3d folder, created if missing."""
try:
import folder_paths
base = folder_paths.get_output_directory()
except ImportError:
base = os.getcwd()
path = os.path.join(base, "3d")
os.makedirs(path, exist_ok=True)
return path
async def submit_and_wait(api, client, endpoint, payload, timeout_seconds, label, interval=3):
"""Submit one prediction and wait for it. Returns (task_id, result)."""
task = await call(client, api.post, endpoint, payload)
task_id = task.get("id")
if not task_id:
raise RuntimeError(f"WaveSpeed did not return a task id: {task}")
print(f"[SF {label}] Submitted {task_id}.")
return task_id, await wait_for_result(api, task_id, timeout_seconds, label, interval=interval)
async def save_3d_model(url, name, default_ext=".glb"):
"""Download a model into output/3d as `name` plus the URL's extension.
Returns (glb, fbx, path); the File3D goes in whichever slot matches the file."""
ext = os.path.splitext(urlparse(url).path)[1].lower() or default_ext
path = os.path.join(output_dir(), f"{name}{ext}")
def download():
response = requests.get(url, timeout=300)
response.raise_for_status()
with open(path, "wb") as f:
f.write(response.content)
await with_retries(download, label="download")
glb = fbx = None
if File3D is not None:
model = File3D(path)
glb = model if ext == ".glb" else None
fbx = model if ext == ".fbx" else None
return glb, fbx, path
async def generate_3d_model(api, client, image, endpoint, build_payload, quad, file_prefix,
timeout_seconds, label):
"""Image-to-3D from upload to File3D: uploads the first frame, submits the
job, waits for it, saves the model to output/3d and returns
(glb, fbx, path, url). `build_payload` receives the uploaded image URL.
Tripo returns FBX when quad topology is requested and GLB otherwise, so only
the output matching the returned file is populated, as on ComfyUI's own
Tripo nodes.
"""
image_url = await call(client, api.upload_file, tensor2images(image)[0], retry=True)
started = time.time()
task_id, result = await submit_and_wait(api, client, endpoint, build_payload(image_url), timeout_seconds, label)
url = pick_model_url(result.get("outputs"))
glb, fbx, path = await save_3d_model(url, f"{file_prefix}_{task_id}", ".fbx" if quad else ".glb")
print(f"[SF {label}] Done in {time.time() - started:.0f}s -> {path}")
return (glb, fbx, path, url)
# --- Video ---------------------------------------------------------------------
async def fetch_video(url, timeout=600):
"""Download an output video into memory as a ComfyUI VIDEO."""
from comfy_api.latest import InputImpl
def get():
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return io.BytesIO(response.content)
return InputImpl.VideoFromFile(await with_retries(get, label="download"))