越光 / ZenMux 节点新增网络自动重连,应对 SSL 断连等链路抖动

线上跑批时最常见的失败不是参数错,而是链路抖动,典型报错是
SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol'))。
此前这两个节点只有针对 HTTP 400 的「自适应参数重试」(业务层),
网络异常直接落到 except ConnectionError 返回错误字符串——而 SSLError
正是 ConnectionError 的子类,等于一次失败就放弃。

新增 max_retries(默认 3)与 timeout(默认 180)两个参数:

会重连:SSL 握手被打断、连接被重置、连接/读超时、响应体截断
(ChunkedEncodingError / JSONDecodeError),以及 429 限流与 5xx 临时故障。
不重连:参数类 400、鉴权类 401/403、404——重试结果相同,只会拖慢报错。

退避 1s→2s→4s→8s→16s 指数增长并叠加 ±25% 抖动。抖动是必要的:一条
工作流里常有多个 API 节点同时失败,无抖动会让它们在同一毫秒一起重连,
把刚缓过来的服务端再打垮一次。服务端给了 Retry-After 时以它为准。

与既有的自适应参数重试分属两层、互不消耗额度;参数被剔除后的 payload
在后续重连中保留,不会重蹈覆辙。HTTPError 报错信息补上已重连次数,
以免用户误以为没有重试。

mock 验证 24 项全通过:失败序列后成功、重试耗尽、max_retries=0/5、
退避递增与抖动、Retry-After、429/5xx/401/404 分流、读超时、响应体截断、
连接重置、参数自适应不受影响、旧工作流不传新参数的向后兼容、ZenMux 同样生效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rui40000
2026-08-19 09:47:47 +08:00
co-authored by Claude Opus 5
parent a513f5a66c
commit 205a7c0e31
3 changed files with 364 additions and 101 deletions
+66
View File
@@ -769,6 +769,39 @@ ComfyUI-SDMatte 传 `aux_input="trimap"`,把模型推到了没训练过的输
- 按预算选型:价格就写在下拉列表里,直接挑便宜的
- 调用 Claude / GPT / Gemini / DeepSeek 等做文本生成或图像理解
**网络自动重连**:
线上跑批时最常见的失败不是参数错,而是链路抖动 —— 典型报错是
`SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol'))`,
即 TLS 握手/传输被中途打断。这类失败重连一次往往就好了,因此节点内置了重连机制。
| 参数 | 默认 | 说明 |
|---|---|---|
| `max_retries` | 3 | 网络失败后的自动重连次数,0 = 不重连 |
| `timeout` | 180 | 单次请求超时秒数,超时计入重连次数 |
**会重连**:SSL 握手被打断(`SSLEOFError`)、连接被重置、连接/读超时、
响应体传到一半截断(`ChunkedEncodingError` / JSON 解析失败),
以及 `429` 限流与 `5xx` 服务端临时故障。
**不重连**:参数类 `400`、鉴权类 `401/403`、`404` —— 这些重试多少次都是同样结果,
重连只会拖慢报错。
**退避策略**:`1s → 2s → 4s → 8s → 16s` 指数增长,每次叠加 ±25% 随机抖动。
抖动不是可有可无的装饰 —— 一条工作流里常有多个 API 节点同时失败,没有抖动它们会在
同一毫秒一起重连,把刚缓过来的服务端再打垮一次。服务端返回 `Retry-After` 头时以它为准。
**与「自适应参数重试」的关系**:两者是不同层次,互不消耗额度。参数自适应处理的是
「这个模型不认这个参数」(HTTP 400),网络重连处理的是「没拿到完整响应」。
参数被剔除后的 payload 会在后续重连中保留,不会重蹈覆辙。
重连过程会打印到控制台,例如:
```
[Rui-Node] 越光: 连接失败(SSLError),1.1s 后重连(第 1/3 次)
[Rui-Node] 越光: 第 2 次重连后成功
```
若重连耗尽仍失败,错误信息会注明已重连次数,便于区分「网络确实不通」和「压根没重试」。
---
### 19. Markdown转图片 / Markdown To Image
@@ -1190,6 +1223,39 @@ Unity/Godot 归一化 pivot(左下为原点):(0.5411, 0.0699)
- **自适应参数重试**:部分模型弃用 `temperature`、或要求用 `max_completion_tokens` 取代 `max_tokens`,命中这类 400 时会剔除/改名后自动重试,正常请求零额外开销
- `VALIDATE_INPUTS` 宽松放行:价格表更新后旧工作流里保存的标签不再逐字匹配,但只要能解析出 model id 就放行,不会让整个工作流失效
**网络自动重连**:
线上跑批时最常见的失败不是参数错,而是链路抖动 —— 典型报错是
`SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol'))`,
即 TLS 握手/传输被中途打断。这类失败重连一次往往就好了,因此节点内置了重连机制。
| 参数 | 默认 | 说明 |
|---|---|---|
| `max_retries` | 3 | 网络失败后的自动重连次数,0 = 不重连 |
| `timeout` | 180 | 单次请求超时秒数,超时计入重连次数 |
**会重连**:SSL 握手被打断(`SSLEOFError`)、连接被重置、连接/读超时、
响应体传到一半截断(`ChunkedEncodingError` / JSON 解析失败),
以及 `429` 限流与 `5xx` 服务端临时故障。
**不重连**:参数类 `400`、鉴权类 `401/403`、`404` —— 这些重试多少次都是同样结果,
重连只会拖慢报错。
**退避策略**:`1s → 2s → 4s → 8s → 16s` 指数增长,每次叠加 ±25% 随机抖动。
抖动不是可有可无的装饰 —— 一条工作流里常有多个 API 节点同时失败,没有抖动它们会在
同一毫秒一起重连,把刚缓过来的服务端再打垮一次。服务端返回 `Retry-After` 头时以它为准。
**与「自适应参数重试」的关系**:两者是不同层次,互不消耗额度。参数自适应处理的是
「这个模型不认这个参数」(HTTP 400),网络重连处理的是「没拿到完整响应」。
参数被剔除后的 payload 会在后续重连中保留,不会重蹈覆辙。
重连过程会打印到控制台,例如:
```
[Rui-Node] 越光: 连接失败(SSLError),1.1s 后重连(第 1/3 次)
[Rui-Node] 越光: 第 2 次重连后成功
```
若重连耗尽仍失败,错误信息会注明已重连次数,便于区分「网络确实不通」和「压根没重试」。
---
### 27. 半透明抠图 / Unmult Matting
+149 -51
View File
@@ -20,7 +20,9 @@ import base64
import io
import json
import os
import random
import re
import time
import numpy as np
import requests
@@ -65,6 +67,41 @@ def _build_chat_url(base_url: str) -> str:
return s + "/chat/completions"
# ── 网络层自动重连 ──
# 可重试的 HTTP 状态:限流与服务端临时故障,换个时间再来往往就好了。
# 4xx(除 408/429)是参数或鉴权问题,重试纯属浪费,不列入。
_RETRIABLE_STATUS = {408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524}
# 这几类异常的共同点是「没拿到完整响应」,重连通常能恢复:
# ConnectionError —— 含 SSLError/SSLEOFError(握手被打断)、连接被重置
# Timeout —— 连接超时与读超时
# ChunkedEncodingError / JSONDecodeError —— 响应体传到一半断了
_RETRIABLE_EXC = (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
json.JSONDecodeError,
)
def _retry_wait(attempt: int, resp=None) -> float:
"""指数退避 + 随机抖动,返回本次该等几秒。attempt 从 0 起。
抖动不是可有可无的装饰:一条工作流里常有多个 API 节点同时失败,
没有抖动它们会在同一毫秒一起重连,把刚缓过来的服务端再打垮一次。
服务端明确给了 Retry-After 时以它为准。
"""
if resp is not None:
ra = resp.headers.get("Retry-After")
if ra:
try:
return max(0.5, min(float(ra), 60.0))
except (TypeError, ValueError):
pass
base = min(2.0 ** attempt, 16.0) # 1s → 2s → 4s → 8s → 16s 封顶
return round(base * random.uniform(0.75, 1.25), 2)
def _build_proxy_url(raw_proxy: str) -> str:
"""把 '127.0.0.1:7890' 或 'http://127.0.0.1:7890' 统一成带协议的地址。"""
s = (raw_proxy or "").strip()
@@ -217,6 +254,30 @@ class YueGuangNode:
"tooltip": "HTTP 代理,同样不要带协议前缀,只填 IP:端口,\n"
"例如 127.0.0.1:7890。留空表示直连。"
}),
"max_retries": ("INT", {
"default": 3,
"min": 0,
"max": 10,
"step": 1,
"tooltip": "网络失败后的自动重连次数,0 表示不重连。\n\n"
"会触发重连的情况:SSL 握手被打断\n"
"(SSLEOFError)、连接被重置、读超时、响应体\n"
"截断,以及 429 限流和 5xx 服务端临时故障。\n\n"
"不会重连的情况:参数类 400、鉴权类 401/403 ——\n"
"这些重试多少次都是同样的结果。\n\n"
"退避按 1s→2s→4s 指数增长并带随机抖动,避免\n"
"多个节点同时重连再次压垮服务端;服务端给了\n"
"Retry-After 时以它为准。"
}),
"timeout": ("INT", {
"default": 180,
"min": 10,
"max": 1800,
"step": 10,
"tooltip": "单次请求的超时秒数。\n"
"长文本或多图推理较慢时可调大。\n"
"超时会计入上面的重连次数。"
}),
"usd_to_cny": ("FLOAT", {
"default": 7.2,
"min": 0.1,
@@ -321,6 +382,8 @@ class YueGuangNode:
base_url=DEFAULT_BASE_URL_INPUT,
proxy_url="",
usd_to_cny=7.2,
max_retries=3,
timeout=180,
):
model_id = label_to_model_id(model) or DEFAULT_MODEL_ID
chat_url = _build_chat_url(base_url)
@@ -382,57 +445,92 @@ class YueGuangNode:
resp = None
dropped = []
try:
# 自适应参数重试:部分模型弃用/不支持某些采样参数,
# 命中即剔除或改名后重试;正常请求不受影响、零额外开销。
for _ in range(len(_ADJUSTABLE_PARAMS) + 2):
resp = requests.post(chat_url, headers=headers, json=payload,
proxies=proxies, timeout=180)
if resp.status_code == 400:
fix = _diagnose_param(resp, payload)
if fix:
action, param = fix
if action == "rename_mct":
payload["max_completion_tokens"] = payload.pop("max_tokens")
dropped.append("max_tokens→max_completion_tokens")
print("[Rui-Node] 越光: 该模型要求 max_completion_tokens,"
"已改名重试")
else:
payload.pop(param, None)
dropped.append(param)
print(f"[Rui-Node] 越光: 该模型不支持参数 '{param}',"
"已剔除后重试")
continue
break
resp.raise_for_status()
data = resp.json()
if data.get("choices"):
msg = data["choices"][0].get("message", {})
content = msg.get("content", "")
if isinstance(content, list): # 少数模型返回分段内容
content = "".join(
seg.get("text", "") for seg in content
if isinstance(seg, dict)
)
stats = self._build_usage_stats(model_id, data.get("usage"),
usd_to_cny, content or "")
return (content or "", model_id, stats)
stats = self._build_usage_stats(model_id, data.get("usage"), usd_to_cny)
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}",
model_id, stats)
except requests.exceptions.ConnectionError as e:
return (f"连接失败(请检查网络/代理): {e}", model_id, zero_stats)
except requests.exceptions.Timeout:
return ("请求超时(180s),请检查网络或越光服务状态。",
model_id, zero_stats)
except requests.exceptions.HTTPError:
code = resp.status_code if resp is not None else "?"
body = resp.text[:600] if resp is not None else ""
hint = (f"(已尝试剔除参数 {', '.join(dropped)} 仍失败)"
if (code == 400 and dropped) else "")
return (f"HTTP 错误 {code}: {body}{hint}", model_id, zero_stats)
except Exception as e:
return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats)
last_net_err = ""
total_tries = max(1, int(max_retries) + 1)
for attempt in range(total_tries):
try:
# 自适应参数重试:部分模型弃用/不支持某些采样参数,
# 命中即剔除或改名后重试;正常请求不受影响、零额外开销。
# 这层不消耗网络重连次数——两者是性质不同的问题。
for _ in range(len(_ADJUSTABLE_PARAMS) + 2):
resp = requests.post(chat_url, headers=headers, json=payload,
proxies=proxies, timeout=timeout)
if resp.status_code == 400:
fix = _diagnose_param(resp, payload)
if fix:
action, param = fix
if action == "rename_mct":
payload["max_completion_tokens"] = payload.pop("max_tokens")
dropped.append("max_tokens→max_completion_tokens")
print("[Rui-Node] 越光: 该模型要求 max_completion_tokens,"
"已改名重试")
else:
payload.pop(param, None)
dropped.append(param)
print(f"[Rui-Node] 越光: 该模型不支持参数 '{param}',"
"已剔除后重试")
continue
break
# 限流 / 服务端临时故障:计入网络重连
if resp.status_code in _RETRIABLE_STATUS and attempt < total_tries - 1:
wait = _retry_wait(attempt, resp)
print(f"[Rui-Node] 越光: 服务端返回 {resp.status_code},"
f"{wait:.1f}s 后重试"
f"(第 {attempt + 1}/{total_tries - 1} 次重连)")
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
if data.get("choices"):
msg = data["choices"][0].get("message", {})
content = msg.get("content", "")
if isinstance(content, list): # 少数模型返回分段内容
content = "".join(
seg.get("text", "") for seg in content
if isinstance(seg, dict)
)
stats = self._build_usage_stats(model_id, data.get("usage"),
usd_to_cny, content or "")
if attempt:
print(f"[Rui-Node] 越光: 第 {attempt} 次重连后成功")
return (content or "", model_id, stats)
stats = self._build_usage_stats(model_id, data.get("usage"), usd_to_cny)
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}",
model_id, stats)
except _RETRIABLE_EXC as e:
last_net_err = f"{type(e).__name__}: {e}"
if attempt < total_tries - 1:
wait = _retry_wait(attempt)
print(f"[Rui-Node] 越光: 连接失败({type(e).__name__}),"
f"{wait:.1f}s 后重连"
f"(第 {attempt + 1}/{total_tries - 1} 次)")
time.sleep(wait)
continue
tail = (f"(已自动重连 {max_retries} 次仍失败;"
f"可调大 max_retries,或检查网络/代理)"
if max_retries else "(max_retries=0,未重连)")
return (f"连接失败(请检查网络/代理): {last_net_err}{tail}",
model_id, zero_stats)
except requests.exceptions.HTTPError:
# 参数与鉴权类错误重试无意义,直接报错
code = resp.status_code if resp is not None else "?"
body = resp.text[:600] if resp is not None else ""
hint = (f"(已尝试剔除参数 {', '.join(dropped)} 仍失败)"
if (code == 400 and dropped) else "")
if attempt: # 重连过仍是这个状态,明说,省得用户以为没重试
hint += f"(已自动重连 {attempt} 次仍返回该状态)"
return (f"HTTP 错误 {code}: {body}{hint}", model_id, zero_stats)
except Exception as e:
return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats)
# 循环内必定 return;这条仅作兜底,避免异常路径返回 None
return ("请求未能完成,请重试或检查服务状态。", model_id, zero_stats)
NODE_CLASS_MAPPINGS = {
+149 -50
View File
@@ -23,7 +23,9 @@ import base64
import io
import json
import os
import random
import re
import time
import numpy as np
import requests
@@ -69,6 +71,41 @@ def _build_chat_url(base_url: str) -> str:
return s + '/chat/completions'
# ── 网络层自动重连 ──
# 可重试的 HTTP 状态:限流与服务端临时故障,换个时间再来往往就好了。
# 4xx(除 408/429)是参数或鉴权问题,重试纯属浪费,不列入。
_RETRIABLE_STATUS = {408, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524}
# 这几类异常的共同点是「没拿到完整响应」,重连通常能恢复:
# ConnectionError —— 含 SSLError/SSLEOFError(握手被打断)、连接被重置
# Timeout —— 连接超时与读超时
# ChunkedEncodingError / JSONDecodeError —— 响应体传到一半断了
_RETRIABLE_EXC = (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
json.JSONDecodeError,
)
def _retry_wait(attempt: int, resp=None) -> float:
"""指数退避 + 随机抖动,返回本次该等几秒。attempt 从 0 起。
抖动不是可有可无的装饰:一条工作流里常有多个 API 节点同时失败,
没有抖动它们会在同一毫秒一起重连,把刚缓过来的服务端再打垮一次。
服务端明确给了 Retry-After 时以它为准。
"""
if resp is not None:
ra = resp.headers.get("Retry-After")
if ra:
try:
return max(0.5, min(float(ra), 60.0))
except (TypeError, ValueError):
pass
base = min(2.0 ** attempt, 16.0) # 1s → 2s → 4s → 8s → 16s 封顶
return round(base * random.uniform(0.75, 1.25), 2)
def _build_proxy_url(raw_proxy: str) -> str:
"""把 '127.0.0.1:7890' 或 'http://127.0.0.1:7890' 统一成带协议的地址。"""
s = (raw_proxy or "").strip()
@@ -229,6 +266,30 @@ class ZenMuxNode:
"例如 127.0.0.1:7890。留空表示直连。"
}),
# usage_stats 里人民币换算用的汇率,可按当日牌价自行调整
"max_retries": ("INT", {
"default": 3,
"min": 0,
"max": 10,
"step": 1,
"tooltip": "网络失败后的自动重连次数,0 表示不重连。\n\n"
"会触发重连的情况:SSL 握手被打断\n"
"(SSLEOFError)、连接被重置、读超时、响应体\n"
"截断,以及 429 限流和 5xx 服务端临时故障。\n\n"
"不会重连的情况:参数类 400、鉴权类 401/403 ——\n"
"这些重试多少次都是同样的结果。\n\n"
"退避按 1s→2s→4s 指数增长并带随机抖动,避免\n"
"多个节点同时重连再次压垮服务端;服务端给了\n"
"Retry-After 时以它为准。"
}),
"timeout": ("INT", {
"default": 180,
"min": 10,
"max": 1800,
"step": 10,
"tooltip": "单次请求的超时秒数。\n"
"长文本或多图推理较慢时可调大。\n"
"超时会计入上面的重连次数。"
}),
"usd_to_cny": ("FLOAT", {
"default": 7.2,
"min": 0.1,
@@ -334,6 +395,8 @@ class ZenMuxNode:
base_url=DEFAULT_BASE_URL,
proxy_url="",
usd_to_cny=7.2,
max_retries=3,
timeout=180,
):
# ---- 1. 从下拉标签解析真实 model id ----
model_id = label_to_model_id(model) or DEFAULT_MODEL_ID
@@ -402,56 +465,92 @@ class ZenMuxNode:
resp = None
dropped = [] # 记录被剔除/改名的参数,供最终报错时提示
try:
# 自适应参数重试:ZenMux 聚合的部分模型弃用/不支持某些采样参数
# (claude-sonnet-5 弃用 temperature、gpt-5 reasoning 系要 max_completion_tokens
# 等),命中即剔除/改名后重试,正常请求不受影响、零额外开销。
for _ in range(len(_ADJUSTABLE_PARAMS) + 2):
resp = requests.post(chat_url, headers=headers, json=payload,
proxies=proxies, timeout=180)
if resp.status_code == 400:
fix = _diagnose_param(resp, payload)
if fix:
action, param = fix
if action == "rename_mct":
payload["max_completion_tokens"] = payload.pop("max_tokens")
dropped.append("max_tokens→max_completion_tokens")
print("[Rui-Node] ZenMux: 该模型要求 max_completion_tokens,"
"已改名重试")
else:
payload.pop(param, None)
dropped.append(param)
print(f"[Rui-Node] ZenMux: 该模型不支持参数 '{param}',"
"已剔除后重试")
continue
break
resp.raise_for_status()
data = resp.json()
if data.get("choices"):
msg = data["choices"][0].get("message", {})
content = msg.get("content", "")
if isinstance(content, list): # 少数模型返回分段内容
content = "".join(
seg.get("text", "") for seg in content if isinstance(seg, dict)
)
stats = self._build_usage_stats(model_id, data.get("usage"),
usd_to_cny, content or "")
return (content or "", model_id, stats)
# 格式异常:无正文可计字数,但 usage 仍尽量取真实值
stats = self._build_usage_stats(model_id, data.get("usage"), usd_to_cny)
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}",
model_id, stats)
except requests.exceptions.ConnectionError as e:
return (f"连接失败(请检查网络/代理): {e}", model_id, zero_stats)
except requests.exceptions.Timeout:
return ("请求超时(180s),请检查网络或 ZenMux 服务状态。", model_id, zero_stats)
except requests.exceptions.HTTPError:
code = resp.status_code if resp is not None else "?"
body = resp.text[:600] if resp is not None else ""
hint = f"(已尝试剔除参数 {', '.join(dropped)} 仍失败)" if (code == 400 and dropped) else ""
return (f"HTTP 错误 {code}: {body}{hint}", model_id, zero_stats)
except Exception as e:
return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats)
last_net_err = ""
total_tries = max(1, int(max_retries) + 1)
for attempt in range(total_tries):
try:
# 自适应参数重试:ZenMux 聚合的部分模型弃用/不支持某些采样参数
# (claude-sonnet-5 弃用 temperature、gpt-5 reasoning 系要 max_completion_tokens
# 等),命中即剔除/改名后重试,正常请求不受影响、零额外开销。
# 这层不消耗网络重连次数——两者是性质不同的问题。
for _ in range(len(_ADJUSTABLE_PARAMS) + 2):
resp = requests.post(chat_url, headers=headers, json=payload,
proxies=proxies, timeout=timeout)
if resp.status_code == 400:
fix = _diagnose_param(resp, payload)
if fix:
action, param = fix
if action == "rename_mct":
payload["max_completion_tokens"] = payload.pop("max_tokens")
dropped.append("max_tokens→max_completion_tokens")
print("[Rui-Node] ZenMux: 该模型要求 max_completion_tokens,"
"已改名重试")
else:
payload.pop(param, None)
dropped.append(param)
print(f"[Rui-Node] ZenMux: 该模型不支持参数 '{param}',"
"已剔除后重试")
continue
break
# 限流 / 服务端临时故障:计入网络重连
if resp.status_code in _RETRIABLE_STATUS and attempt < total_tries - 1:
wait = _retry_wait(attempt, resp)
print(f"[Rui-Node] ZenMux: 服务端返回 {resp.status_code},"
f"{wait:.1f}s 后重试"
f"(第 {attempt + 1}/{total_tries - 1} 次重连)")
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
if data.get("choices"):
msg = data["choices"][0].get("message", {})
content = msg.get("content", "")
if isinstance(content, list): # 少数模型返回分段内容
content = "".join(
seg.get("text", "") for seg in content if isinstance(seg, dict)
)
stats = self._build_usage_stats(model_id, data.get("usage"),
usd_to_cny, content or "")
if attempt:
print(f"[Rui-Node] ZenMux: 第 {attempt} 次重连后成功")
return (content or "", model_id, stats)
# 格式异常:无正文可计字数,但 usage 仍尽量取真实值
stats = self._build_usage_stats(model_id, data.get("usage"), usd_to_cny)
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}",
model_id, stats)
except _RETRIABLE_EXC as e:
last_net_err = f"{type(e).__name__}: {e}"
if attempt < total_tries - 1:
wait = _retry_wait(attempt)
print(f"[Rui-Node] ZenMux: 连接失败({type(e).__name__}),"
f"{wait:.1f}s 后重连"
f"(第 {attempt + 1}/{total_tries - 1} 次)")
time.sleep(wait)
continue
tail = (f"(已自动重连 {max_retries} 次仍失败;"
f"可调大 max_retries,或检查网络/代理)"
if max_retries else "(max_retries=0,未重连)")
return (f"连接失败(请检查网络/代理): {last_net_err}{tail}",
model_id, zero_stats)
except requests.exceptions.HTTPError:
# 参数与鉴权类错误重试无意义,直接报错
code = resp.status_code if resp is not None else "?"
body = resp.text[:600] if resp is not None else ""
hint = f"(已尝试剔除参数 {', '.join(dropped)} 仍失败)" if (code == 400 and dropped) else ""
if attempt: # 重连过仍是这个状态,明说,省得用户以为没重试
hint += f"(已自动重连 {attempt} 次仍返回该状态)"
return (f"HTTP 错误 {code}: {body}{hint}", model_id, zero_stats)
except Exception as e:
return (f"请求异常: {type(e).__name__}: {e}", model_id, zero_stats)
# 循环内必定 return;这条仅作兜底,避免异常路径返回 None
return ("请求未能完成,请重试或检查服务状态。", model_id, zero_stats)
# ────────── ComfyUI 注册 ──────────