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