""" lib/http.py — 统一 HTTP 入口(禁用系统代理 + 自动重试) ## 为什么需要(2026-10-04 定位的坑) macOS 开启系统代理(如 127.0.0.1:7892)后,Python 的 `urllib.request.urlopen` 会**自动读取系统代理设置**。而系统代理对国内 API(可灵 / 百炼 / 火山)经常 时通时不通,表现为: [SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol → 用户看到的是「生成随机失败」「分镜生成失败」这类难以复现的问题。 实测:直连(禁代理)3/3 次 HTTP 200、0.2~0.3 秒;走代理则间歇性 SSL 错误。 ## 用法 from lib.http import urlopen, request_json req = urllib.request.Request(url, headers={...}) with urlopen(req, timeout=60) as r: # 已禁代理 + 自动重试 data = r.read() # 或一步到位: data = request_json(url, method="POST", body={...}, headers={...}) """ from __future__ import annotations import json import time import urllib.error import urllib.request from typing import Any, Optional # 禁代理 opener(进程内复用) _NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) DEFAULT_RETRIES = 3 DEFAULT_BACKOFF = 1.5 # 秒;第 n 次失败后等待 backoff * n # 值得重试的 HTTP 状态码: # · 429 限流(等一会儿可能就通) # · 5xx 服务端临时故障(可灵/百炼偶尔返回 502/503) # 不重试:其余 4xx(参数错/鉴权失败/余额不足)—— 重试只是浪费时间 _RETRYABLE_HTTP = frozenset({429, 500, 502, 503, 504}) def urlopen(req, timeout: int = 60, retries: int = DEFAULT_RETRIES): """禁用代理地发起请求,失败自动重试(指数退避) 返回的是可作上下文管理器使用的 response 对象。 重试覆盖:URLError / SSLError / OSError / 超时 / 连接中断。 ⚠️ HTTP 4xx/5xx 不重试(那是业务错误,重试无意义)—— 本函数只对**网络层异常**重试。 """ last_exc: Optional[BaseException] = None for attempt in range(max(1, retries)): try: return _NO_PROXY_OPENER.open(req, timeout=timeout) except urllib.error.HTTPError as e: # 5xx(服务端临时故障)与 429(限流)值得重试; # 其余 4xx(参数错/鉴权失败/余额不足)重试无意义 → 直接抛 if e.code in _RETRYABLE_HTTP: last_exc = e if attempt < retries - 1: time.sleep(DEFAULT_BACKOFF * (attempt + 1)) continue raise except Exception as e: last_exc = e if attempt < retries - 1: time.sleep(DEFAULT_BACKOFF * (attempt + 1)) raise last_exc # type: ignore[misc] def request_json(url: str, method: str = "GET", body: Any = None, headers: Optional[dict] = None, timeout: int = 60, retries: int = DEFAULT_RETRIES) -> dict: """发起 JSON 请求并解析响应(禁用代理 + 自动重试) · body 非 None 时自动 JSON 序列化并加 Content-Type · 返回值:解析后的 dict(解析失败返回 {}) """ hdr = dict(headers or {}) data = None if body is not None: data = json.dumps(body).encode("utf-8") hdr.setdefault("Content-Type", "application/json") req = urllib.request.Request(url, data=data, headers=hdr, method=method) with urlopen(req, timeout=timeout, retries=retries) as r: raw = r.read() try: return json.loads(raw.decode("utf-8")) except Exception: return {}