Spaces:
Running
Running
File size: 13,708 Bytes
d646dd7 af37f5b d646dd7 af37f5b d646dd7 af37f5b d646dd7 af37f5b d646dd7 af37f5b d646dd7 af37f5b d646dd7 af37f5b d646dd7 af37f5b d646dd7 af37f5b d646dd7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | """
HTTP API 客户端 —— 对接 Data Acquisition 服务器。
真实 API:
POST /api/v1/session/upload — 上传整个 session 的 zip 包
GET /api/v1/sessions — 查询 sessions 列表
GET /api/v1/session/{id} — session 详情
DELETE /api/v1/session/{id} — 删除 session
GET /api/v1/video/{id}/{cam}/stream — 视频流
GET /health — 健康检查
"""
from __future__ import annotations
import os
import socket
import time
from typing import Optional, Callable
import requests
import urllib3
from urllib3.util import Timeout as _Urllib3Timeout
from urllib3.util.retry import Retry as _Retry
from requests.adapters import HTTPAdapter
CONNECT_TIMEOUT = 10
READ_TIMEOUT = 1800 # 大会话(数 GB)上传 + 服务器解包入库可能耗时数分钟,设 30 分钟
# TCP 保活:对端(服务器/中间网络)静默消失时,阻塞的 recv 不会自己醒。
# 没有保活时 Linux 默认 2 小时才报错——上传线程会静默挂死(2026-09-09
# episode-012 事故)。60+15×4 ≈ 120s 内探测到死连接并抛 ConnectionError。
_KEEPALIVE_IDLE_S, _KEEPALIVE_INTVL_S, _KEEPALIVE_CNT = 60, 15, 4
_TCP_LEVEL = getattr(socket, "IPPROTO_TCP", 6)
_KEEPALIVE_OPTS = [
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
(_TCP_LEVEL, getattr(socket, "TCP_KEEPIDLE", None), _KEEPALIVE_IDLE_S), # Linux
(_TCP_LEVEL, getattr(socket, "TCP_KEEPALIVE", None), _KEEPALIVE_IDLE_S), # macOS 别名
(_TCP_LEVEL, getattr(socket, "TCP_KEEPINTVL", None), _KEEPALIVE_INTVL_S),
(_TCP_LEVEL, getattr(socket, "TCP_KEEPCNT", None), _KEEPALIVE_CNT),
]
# 常量在本平台不存在的项直接丢掉:Windows 旧版 SDK 无 TCP_KEEPIDLE/
# TCP_KEEPINTVL/TCP_KEEPCNT,退化为仅 SO_KEEPALIVE(系统默认 2 小时)。
_SOCKET_OPTIONS = [o for o in _KEEPALIVE_OPTS if o[1] is not None]
# 只重试「连接阶段」错误:DNS 解析失败/连接被拒/建连超时——这些都在
# 请求体写出任何字节之前抛出,重试不会重复发送(POST 不在 urllib3 的
# 默认重试方法里,必须显式放行)。read=0/status=0:一旦开始发送或收到
# 响应,绝不自动重试,交由 UploadManager 的「查服务器再决定」逻辑处理。
_UPLOAD_RETRY = _Retry(
total=2, connect=2, read=0, status=0, redirect=0,
allowed_methods={"POST"}, backoff_factor=0,
respect_retry_after_header=False, raise_on_status=False,
)
class _PatientSendConnection(urllib3.connection.HTTPConnection):
"""连接子类:请求体发送阶段用读超时窗口,而不是连接超时。
urllib3 的 connectionpool 在发请求体前把 conn.timeout 设为
connect_timeout(本工程 10s),request() 顶层随即用该值对 socket
做 settimeout —— 即发送大请求体时 socket 超时实际是"连接超时"。
服务器收到大 zip 后一边入库一边慢读(导入队列繁忙时单次 sendall
可停顿数秒~数分钟),停顿一超 10s 上传就被误杀为
ConnectionError(('Connection aborted.', TimeoutError('timed out')))。
这里把发送阶段的 socket 超时换成读超时窗口,发送完即恢复原值。
"""
def request(self, method, url, body=None, headers=None, *args, **kwargs):
saved = self.timeout
# conn.timeout 此时是 urlopen 写入的 connect_timeout(数值)。
# 非数值(如 Timeout 对象)说明不是这个路径,交给基类处理。
if saved is not None and not isinstance(saved, _Urllib3Timeout):
self.timeout = READ_TIMEOUT
try:
return super().request(method, url, body=body, headers=headers,
*args, **kwargs)
finally:
self.timeout = saved
def connect(self):
"""建连阶段固定用 CONNECT_TIMEOUT。
request() 已把 self.timeout 抬到 READ_TIMEOUT,新建连接若照它
建 TCP,则"连接超时"实际是 30 分钟(服务器不响应 SYN 时线程
静默挂死)。这里临时换回连接窗口,建连完成即恢复,发送阶段
的宽窗口不受影响。
"""
saved = self.timeout
if isinstance(saved, (int, float)) and saved != CONNECT_TIMEOUT:
self.timeout = CONNECT_TIMEOUT
try:
super().connect()
finally:
self.timeout = saved
class _PatientSendHTTPSConnection(_PatientSendConnection,
urllib3.connection.HTTPSConnection):
pass
class _PatientSendHTTPConnectionPool(urllib3.HTTPConnectionPool):
ConnectionCls = _PatientSendConnection
class _PatientSendHTTPSConnectionPool(urllib3.HTTPSConnectionPool):
ConnectionCls = _PatientSendHTTPSConnection
class _PatientSendAdapter(HTTPAdapter):
"""把发送超时加长的连接池装入 session。"""
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
# socket_options 只能经连接池的 **conn_kw 传到 HTTPConnection,
# 由这里统一注入(http/https 共用本类,一处生效)。
pool_kwargs.setdefault("socket_options", _SOCKET_OPTIONS)
super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
# PoolManager 默认指向模块级 dict(多实例共享),须整体替换实例属性
self.poolmanager.pool_classes_by_scheme = {
"http": _PatientSendHTTPConnectionPool,
"https": _PatientSendHTTPSConnectionPool,
}
class APIClient:
"""服务器 REST API 客户端。
session: 可选复用已有 requests.Session(如 TaskService 的已认证 session)。
"""
def __init__(self, base_url: str, session: requests.Session = None):
self.base_url = base_url.rstrip("/")
self._own_session = session is None
self._session = session if session is not None else requests.Session()
self._session.headers.update({"User-Agent": "DAQ-SDK/1.0"})
# 大请求体上传时发送阶段的 socket 超时改为读超时窗口,
# 否则服务器慢读停顿 >10s 会误杀上传(见 _PatientSendConnection)。
# max_retries 只放行建连阶段错误(见 _UPLOAD_RETRY)。
self._session.mount("http://", _PatientSendAdapter(max_retries=_UPLOAD_RETRY))
self._session.mount("https://", _PatientSendAdapter(max_retries=_UPLOAD_RETRY))
def close(self):
if self._own_session:
self._session.close()
# ── 健康检查 ──────────────────────────────────────
def health_check(self) -> bool:
try:
r = self._session.get(f"{self.base_url}/health",
timeout=CONNECT_TIMEOUT)
return r.status_code == 200
except requests.RequestException:
return False
# ── 上传 session ──────────────────────────────────
def upload_session_zip(self, zip_path: str, session_name: str,
progress_cb: Optional[Callable[[int, int], None]] = None,
name: str = "", project_id: str = "",
episode_index: int = 0
) -> dict:
"""
上传一个 session 的 zip 包到服务器。
Args:
zip_path: zip 文件路径
session_name: 会话名称(v1.1.0 池化 = 任务名)
progress_cb: 进度回调 (uploaded_bytes, total_bytes)
name: 上传接口的 name 表单字段(会话名;服务器按它解析目标项目,
空则回退 session_name)
project_id: 上传接口的 project_id 表单字段(目标项目 ID;
空 = 服务器按名称自动匹配,服务器上有多个同名/近似项目时
会返回 409 "Ambiguous project prefix")
episode_index: v1.1.0 池化表单字段——值为本 episode 的 file 号
(0 基,与 zip 内 file-NNN 完全一致;真实全局序号 N 在
parquet 行的 episode_index 列)。旧服务器忽略未知字段,
不影响既有上传。
Returns:
{"ok": True, "session_id": "...", "response": {...}}
或 {"ok": False, "error": "..."}
"""
url = f"{self.base_url}/api/v1/session/upload"
file_size = os.path.getsize(zip_path)
# 包装文件对象,在读取时回调进度
class _ProgressReader:
def __init__(self, path, cb, total):
self._f = open(path, "rb")
self._cb = cb
self._total = total
self._read = 0
def read(self, size=-1):
data = self._f.read(size)
if data:
self._read += len(data)
if self._cb:
self._cb(self._read, self._total)
return data
def close(self):
self._f.close()
reader = _ProgressReader(zip_path, progress_cb, file_size)
try:
r = self._session.post(
url,
files={"file": (session_name + ".zip", reader, "application/zip")},
# 显式传 name/project_id:服务器按 name 解析目标项目,
# 多个同名/近似项目(含 workflow 别名撞名)时按前缀匹配
# 会 409 歧义,须用 project_id 消歧。
data={"name": name or session_name,
"project_id": project_id or "",
"episode_index": str(episode_index)},
timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
)
if r.status_code in (200, 201):
data = r.json()
return {"ok": True,
"session_id": data.get("session_id", ""),
"response": data}
if r.status_code == 409:
# 服务器按名称匹配项目时发现歧义(多项目前缀撞名)
try:
detail = r.json().get("detail", "")
except ValueError:
detail = r.text
if "project_id" in str(detail) or "prefix" in str(detail).lower():
return {"ok": False,
"ambiguous_project": True,
"error": "项目名有歧义:服务器上有多个同名/近似项目。"
"请在 ☁ 上传对话框的『目标项目』中指定项目后重试"}
return {"ok": False, "error": f"HTTP 409: {r.text[:300]}"}
return {"ok": False,
"error": f"HTTP {r.status_code}: {r.text[:300]}"}
except requests.RequestException as e:
return {"ok": False, "error": str(e)[:300]}
finally:
reader.close()
def get_projects(self, limit: int = 100) -> list[dict]:
"""GET /api/v1/projects 项目列表(需已认证 session;失败/未认证返回 [])。
用于上传对话框的「目标项目」下拉框。
"""
try:
r = self._session.get(
f"{self.base_url}/api/v1/projects",
params={"limit": limit},
timeout=CONNECT_TIMEOUT,
)
if r.status_code == 200:
return r.json().get("projects", [])
except requests.RequestException:
pass
return []
# ── 查询 ──────────────────────────────────────────
def try_get_sessions(self, limit: int = 50) -> Optional[list[dict]]:
"""GET /api/v1/sessions;**失败返回 None**。
与 get_sessions 的区别:None = 查询失败(网络/认证/非 200),
而 [] = 服务器上确实一个会话都没有。上传前的快照必须能区分
这两种情况,否则查询失败会被误判成"服务器是空的",POST 失败
后任何一个同名会话都会被当成"新出现"而误报成功。
"""
try:
r = self._session.get(
f"{self.base_url}/api/v1/sessions",
params={"limit": limit},
timeout=CONNECT_TIMEOUT,
)
if r.status_code == 200:
data = r.json()
return data.get("sessions", []) or []
except requests.RequestException:
pass
return None
def get_sessions(self, limit: int = 50) -> list[dict]:
sessions = self.try_get_sessions(limit)
return sessions if sessions is not None else []
def get_session(self, session_id: str) -> Optional[dict]:
try:
r = self._session.get(
f"{self.base_url}/api/v1/session/{session_id}",
timeout=CONNECT_TIMEOUT,
)
if r.status_code == 200:
return r.json()
except requests.RequestException:
pass
return None
def delete_session(self, session_id: str) -> bool:
try:
r = self._session.delete(
f"{self.base_url}/api/v1/session/{session_id}",
timeout=CONNECT_TIMEOUT,
)
return r.status_code == 200
except requests.RequestException:
return False
|