modify: call 对齐 worker_manager 语义(换副本重试/冷静期/信封原样透传),0.1.1 补 orjson 依赖与回归测试

This commit is contained in:
2026-10-14 14:33:56 +08:00
parent 556b754c7b
commit e27465c475
5 changed files with 548 additions and 52 deletions
+31 -6
View File
@@ -15,8 +15,9 @@ uv pip install -e utils/request
## 功能 ## 功能
- **自动服务发现**:基于 Zenoh liveliness 自动发现存活的 Worker - **自动服务发现**:基于 Zenoh liveliness 自动发现存活的 Worker
- **随机负载均衡**:在可用 Worker 间随机选择 - **随机负载均衡**:在可用 Worker 间随机选择;疑似故障副本进入 30s 冷静期被跳过
- **超时重试**:请求超时后自动重试 - **换副本重试**:超时 / 无回复 / 业务错误时换一个「未试过」的副本重试,
无未试副本立即失败(不重复打同一个坏副本);业务错误不列入冷静期
- **同步/异步支持**:提供 `call`(异步)和 `call_sync`(同步)接口 - **同步/异步支持**:提供 `call`(异步)和 `call_sync`(同步)接口
--- ---
@@ -44,11 +45,26 @@ async with Client(session=zenoh_session) as client:
| `subject` | `str` | Zenoh 主题(如 `"example.add"`) | | `subject` | `str` | Zenoh 主题(如 `"example.add"`) |
| `payload` | `dict` | 业务参数(自动封装为信封) | | `payload` | `dict` | 业务参数(自动封装为信封) |
| `method` | `str` | HTTP 方法(`"GET"` / `"POST"`) | | `method` | `str` | HTTP 方法(`"GET"` / `"POST"`) |
| `timeout` | `float` | 超时时间(秒) | | `timeout` | `float` | 单次调用超时(秒),换副本重试重新计时 |
| `retries` | `int` | 失败重试次数(每次重试换一个未试过的副本) |
> **信封协议**:`call()` 会把 `payload` 自动封装为 > **信封协议**:`call()` 把 `payload` 封装为
> `{"meta": {"method": ...}, "data": payload}` 发送,**不再修改调用方传入的 dict**; > `{"meta": {"method": ...}, "data": payload}` 发送,**不修改调用方传入的 dict**;
> 若传入的 `payload` 已含 `meta`/`data` 结构则原样复用(仅补 `meta.method`)。 > 若传入的 `payload` 已含 `meta`/`data` 键则视为完整信封**原样使用**(保留全部
> 字段、不改写 `0`/`[]` 等 falsy 值),仅缺 `meta.method` 时补入。`meta` 非 dict
> 会立即抛 `ValueError`,不会被发送。
### 与网关 `worker_manager.call_raw` 的语义对齐
- **载荷**:业务数据整体入 `data`;调用方自备信封时不改写(对齐 call_raw 的
"谁构造信封谁负责")。
- **回复**:worker 回复解析为 dict 返回,空回复兜底 `{}`(对齐 call_raw 的 `b"{}"`)。
- **重试**:超时 / 无回复 / 业务错误换未试过的副本重试;超时与无回复的副本进入
30s 冷静期,后续调用跳过;失败终态抛 `RuntimeError` 并携带最后一个错误详情
(已尝试副本数、次数上限、具体原因)。
- **信任模型**:本包定位为**内部 worker 请求工具**(worker 间调用 / 服务端内部直连),
走 Zenoh 直连、无 HTTP 链路:`meta.user_info` **不会**被注入或校验,需要时由调用方
自填;`cacheable`、实例定向(`instance_id`)等能力请走网关 HTTP 或 `call_raw`。
### 同步调用 ### 同步调用
@@ -83,6 +99,15 @@ result = client.call_sync(
--- ---
## 测试
```bash
python tests/test_client.py
```
---
## 依赖 ## 依赖
- `zenoh` — Zenoh Python 绑定(`eclipse-zenoh`) - `zenoh` — Zenoh Python 绑定(`eclipse-zenoh`)
- `orjson` — JSON 序列化
+2 -1
View File
@@ -4,12 +4,13 @@ requires = ["hatchling"]
[project] [project]
name = "chongming-request" name = "chongming-request"
version = "0.1.0" version = "0.1.1"
description = "worker direct communication" description = "worker direct communication"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"eclipse-zenoh>=1.9.0", "eclipse-zenoh>=1.9.0",
"orjson>=3.10",
] ]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
+173 -43
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import concurrent.futures
import logging import logging
import random import random
import time import time
@@ -10,6 +11,35 @@ import orjson
_SENTINEL = object() _SENTINEL = object()
# Zenoh RPC 专用线程池:隔离于 asyncio.to_thread 的全局默认池(上限 min(32, cpu+4))。
# zenoh session.get 迭代是同步阻塞调用,wait_for 超时后线程仍阻塞在 zenoh 内部
# 直到查询自身结束,故池上限即并发上限;与网关 worker_manager 同策略。
_ZENOH_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
max_workers=256, thread_name_prefix="zenoh-rpc"
)
# 超时/无回复副本的冷静期(秒):半死副本(父进程被杀留下的孤儿、事件循环卡死的
# 副本)还在服务发现里,被选中就白吃一整轮超时;冷静期让后续调用跨请求避开它,
# 到期自动恢复为正常候选。取值与 worker_manager._SUSPECT_TTL 一致。
_SUSPECT_TTL = 30.0
def _first_reply(iterator):
"""线程内取迭代器首个元素;迭代耗尽返回哨兵,避免 StopIteration 跨线程传播"""
for item in iterator:
return item
return _SENTINEL
def _err_text(payload: Any) -> str:
"""错误载荷归一为文本(兼容 zenoh ZBytes 与 bytes)"""
if payload is None:
return "unknown error"
if isinstance(payload, (bytes, bytearray)):
return bytes(payload).decode("utf-8", "replace") or "unknown error"
return str(payload) or "unknown error"
class Client: class Client:
def __init__(self, def __init__(self,
session: Optional[zenoh.Session] = None, session: Optional[zenoh.Session] = None,
@@ -24,12 +54,18 @@ class Client:
self._worker_cache = {} self._worker_cache = {}
self._cache_ttl = 15 self._cache_ttl = 15
self._cache_timestamps = {} self._cache_timestamps = {}
self._suspect_until: Dict[str, float] = {}
async def _get_workers(self, worker_type: str) -> List[str]: async def _get_workers(self, worker_type: str) -> List[str]:
"""存活副本 key 列表(liveliness 发现,TTL 内复用缓存)。
空结果不写缓存:否则首个请求没发现副本就把空表锁 15s,新上线副本
要等 TTL 过期才可见(与 worker_manager 定期刷新缓存的时效等效)。
"""
now = time.monotonic() now = time.monotonic()
if worker_type in self._worker_cache: cached = self._worker_cache.get(worker_type)
if now - self._cache_timestamps[worker_type] < self._cache_ttl: if cached and now - self._cache_timestamps.get(worker_type, 0.0) < self._cache_ttl:
return self._worker_cache[worker_type] return cached
try: try:
replies = self._session.liveliness().get(f"worker/{worker_type}/*") replies = self._session.liveliness().get(f"worker/{worker_type}/*")
workers = [str(r.ok.key_expr) for r in replies if r.ok] workers = [str(r.ok.key_expr) for r in replies if r.ok]
@@ -37,11 +73,53 @@ class Client:
self._cache_timestamps[worker_type] = now self._cache_timestamps[worker_type] = now
return workers return workers
except Exception as e: except Exception as e:
if worker_type in self._worker_cache: if cached:
self._logger.warning("Using expired cache") self._logger.warning(f"Worker 发现失败,使用过期缓存: {e}")
return self._worker_cache[worker_type] return cached
self._logger.error(str(e)) raise RuntimeError(f"无法发现 worker '{worker_type}' 的存活副本: {e}")
raise ValueError(str(e))
def _mark_suspect(self, worker_expr: str) -> None:
"""把超时/无回复的副本列入冷静期(选副本时优先跳过)"""
now = time.monotonic()
self._suspect_until[worker_expr] = now + _SUSPECT_TTL
if len(self._suspect_until) > 32: # 顺手清理过期项,防长期运行膨胀
self._suspect_until = {
k: t for k, t in self._suspect_until.items() if t > now
}
def _filter_suspects(self, workers: List[str]) -> List[str]:
"""过滤冷静期内的副本;全在冷静期时退回原列表(宁可慢,也不能无副本可用)"""
if not self._suspect_until:
return workers
now = time.monotonic()
healthy = [w for w in workers if self._suspect_until.get(w, 0.0) <= now]
return healthy or workers
def _pick_worker(self, workers: List[str], exclude: List[str]) -> Optional[str]:
"""随机挑一个未试过且不在冷静期的副本;无候选返回 None"""
candidates = self._filter_suspects([w for w in workers if w not in exclude])
return random.choice(candidates) if candidates else None
def _build_envelope(self, payload: Any, method: str) -> Dict[str, Any]:
"""信封协议: {"meta": {...}, "data": {...}},不修改调用方 payload。
业务载荷(非 dict,或 dict 且不含 meta/data 键)整体放入 data;
调用方已备信封(含 meta 或 data 键)则保留全部字段原样使用,仅缺
meta.method 时补入——对齐 worker_manager.call_raw 的"不改写调用方载荷"语义。
"""
if isinstance(payload, dict) and ("meta" in payload or "data" in payload):
envelope = dict(payload)
meta = envelope.get("meta")
if meta is None:
envelope["meta"] = {"method": method.upper()}
elif isinstance(meta, dict):
meta = dict(meta)
meta.setdefault("method", method.upper())
envelope["meta"] = meta
else:
raise ValueError(f"信封 meta 必须是 dict,当前为 {type(meta).__name__}")
return envelope
return {"meta": {"method": method.upper()}, "data": payload}
async def call( async def call(
self, self,
@@ -52,57 +130,110 @@ class Client:
timeout: float = 3.0, timeout: float = 3.0,
retries: int = 1, retries: int = 1,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
# 信封协议: {"meta": {...}, "data": {...}},不修改调用方的 payload。 """
# 若调用方已传信封结构则复用,仅补充 meta.method。 统一调用入口:信封组装 → 选副本(随机均衡)→ 超时/失败换副本重试。
if isinstance(payload, dict) and ("meta" in payload or "data" in payload):
envelope = { Args:
"meta": dict(payload.get("meta") or {}), worker_type: Worker 类型标识(如 "example")
"data": payload.get("data") or {}, subject: 调用的主题(如 "example.add")
} payload: 业务参数(自动封装为信封);已含 meta/data 键时视为
envelope["meta"].setdefault("method", method.upper()) 完整信封原样使用,仅缺 meta.method 时补入
else: method: HTTP 方法("GET" / "POST"),写入 meta.method
envelope = {"meta": {"method": method.upper()}, "data": payload} timeout: 单次调用超时(秒),每次换副本重试都重新计时
full_key = "" retries: 失败重试次数(0 = 只调一次;每次重试换一个未试过的副本)
for attempt in range(retries + 1):
workers = await self._get_workers(worker_type) Returns:
if not workers: Dict[str, Any]: 调用结果(worker 回复解析后的 dict;空回复兜底 {})
raise RuntimeError(...) """
target = random.choice(workers) payload_bytes = orjson.dumps(self._build_envelope(payload, method))
# 候选副本在本轮调用内固定(与 worker_manager 一致:选副本读缓存 +
# 换副本,不在重试过程中重新发现)
workers = await self._get_workers(worker_type)
if not workers:
raise RuntimeError(
f"类型 '{worker_type}' 没有可用 Worker。请检查: "
f"1) worker_type 是否写错 2) Worker 进程是否正常运行 "
f"3) Zenoh 会话能否发现 worker/{worker_type}/* 的 liveliness"
)
target = self._pick_worker(workers, [])
if target is None: # 冷静期全量退回兜底,workers 非空时不可达,防守
raise RuntimeError(f"类型 '{worker_type}' 没有可用 Worker(全部在冷静期)")
full_key = f"{target}/{subject}"
tried: List[str] = []
last_error: Optional[str] = None
attempt = 0
def _switch_worker(why: str) -> bool:
"""换一个未试过的副本重试;无候选时返回 False 结束重试。
没有未试副本还继续循环只会重复打同一个坏副本:若它已死,白等一整轮
超时;若它已回业务错误,结果必然相同。保留具体失败原因便于定位。
"""
nonlocal target, full_key, last_error
new_worker = self._pick_worker(workers, tried)
if new_worker is None:
last_error = f"已无其它未试副本可换({why}):{last_error}"
return False
if new_worker != target:
self._logger.info(f"{why}切换 Worker: {target} -> {new_worker}")
target = new_worker
full_key = f"{target}/{subject}" full_key = f"{target}/{subject}"
return True
for attempt in range(retries + 1):
if target not in tried:
tried.append(target)
try: try:
reply_iter = self._session.get( reply_iter = self._session.get(
full_key, full_key,
payload=orjson.dumps(envelope), payload=payload_bytes,
consolidation=zenoh.ConsolidationMode.NONE, consolidation=zenoh.ConsolidationMode.NONE,
timeout=timeout,
) )
def _get_first(iterator): # 走专用线程池;zenoh 查询自身也设了同一超时,超时后迭代很快结束,
for item in iterator: # 不会在默认池里滞留线程
return item loop = asyncio.get_running_loop()
return _SENTINEL
first_reply = await asyncio.wait_for( first_reply = await asyncio.wait_for(
asyncio.to_thread(_get_first, reply_iter), loop.run_in_executor(_ZENOH_EXECUTOR, _first_reply, reply_iter),
timeout=timeout, timeout=timeout,
) )
if first_reply is _SENTINEL: if first_reply is _SENTINEL:
self._logger.warning(f"Worker {target} did not respond to '{full_key}', retrying {attempt+1}/{retries}...") last_error = f"Worker {target} 无回复"
self._logger.warning(f"{last_error},尝试重试 {attempt+1}/{retries+1}")
self._mark_suspect(target)
if not _switch_worker("无回复"):
break
continue continue
reply = first_reply reply = first_reply
if reply.ok: # type: ignore if reply.ok: # type: ignore
payload_bytes = bytes(reply.ok.payload) if reply.ok.payload else b"" # type: ignore reply_bytes = bytes(reply.ok.payload) if reply.ok.payload else b"" # type: ignore
if not payload_bytes: # 空回复兜底 {}(与 worker_manager.call_raw 的 b"{}" 一致)
result = {} return orjson.loads(reply_bytes) if reply_bytes else {}
else:
result = orjson.loads(payload_bytes)
return result
else: else:
error_payload = reply.err.payload # type: ignore error_msg = _err_text(reply.err.payload) # type: ignore
error_msg = str(error_payload) if error_payload else "unknown error" last_error = f"Worker {target} 返回错误: {error_msg}"
self._logger.warning(f"Worker Error: {error_msg} retrying {attempt+1}/{retries}...") self._logger.warning(f"{last_error} (尝试 {attempt+1}/{retries+1})")
# 错误回复(含 Zenoh 超时答复)也换一个副本重试
if not _switch_worker("错误后"):
break
continue continue
except asyncio.TimeoutError: except asyncio.TimeoutError:
raise RuntimeError(f"Request to '{full_key}' timed out after {timeout} seconds") last_error = f"Worker {target} 超时 (>{timeout}s)"
raise RuntimeError(f"Failed to get a valid response from '{full_key}' after {retries} retries") self._logger.warning(f"{last_error},尝试重试 {attempt+1}/{retries+1}")
self._mark_suspect(target)
# 超时后换一个副本重试,避免打到仍在处理中的同一个副本
if not _switch_worker("超时后"):
break
continue
raise RuntimeError(
f"Worker '{worker_type}' 调用失败 (已尝试 {len(tried)} 个 Worker, "
f"实际尝试 {attempt + 1} 次/上限 {retries + 1} 次, timeout={timeout}s): {last_error}"
)
async def close(self): async def close(self):
if self._owns_session and self._session: if self._owns_session and self._session:
@@ -116,4 +247,3 @@ class Client:
def call_sync(self, *args, **kwargs): def call_sync(self, *args, **kwargs):
return asyncio.run(self.call(*args, **kwargs)) return asyncio.run(self.call(*args, **kwargs))
+288
View File
@@ -0,0 +1,288 @@
"""chongming_request.Client 语义回归:信封组装 / 换副本重试 / 冷静期 / 错误文案。
不依赖真实 Zenoh 会话与 worker:假 Session 捕获出站载荷、模拟各类回复。
运行: python tests/test_client.py
"""
import asyncio
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import orjson # noqa: E402
import chongming_request.client as client_module # noqa: E402
from chongming_request import Client # noqa: E402
class FakeOk:
def __init__(self, payload):
self.payload = payload
class FakeErr:
def __init__(self, payload):
self.payload = payload
class FakeReply:
def __init__(self, payload=b"", err=None):
"""err 给出时构造 err 回复(ok=None,与 zenoh 超时答复同形)"""
self.ok = None if err is not None else FakeOk(payload)
self.err = err
class BlockingIter:
"""模拟半死副本:迭代阻塞超过等待超时"""
def __init__(self, delay):
self.delay = delay
def __iter__(self):
time.sleep(self.delay)
return iter(())
class StubSession:
"""按副本给回复;记录每次 get 的 key 与入参"""
def __init__(self, workers, replies=None, blocking=None):
self.workers = workers
self.replies = replies or {} # worker_key -> FakeReply 或其列表(按调用次序弹出)
self.blocking = blocking or {} # worker_key -> 阻塞秒数
self.calls = [] # (full_key, payload_bytes, kwargs)
def liveliness(self):
class LV:
def get(_self, key):
return [
type("R", (), {"ok": type("O", (), {"key_expr": w})()})()
for w in self.workers
]
return LV()
def get(self, full_key, **kwargs):
self.calls.append((full_key, kwargs.get("payload"), kwargs))
worker_key = full_key.rsplit("/", 1)[0]
if worker_key in self.blocking:
return BlockingIter(self.blocking[worker_key])
item = self.replies.get(worker_key, [FakeReply(b'{"ok":true}')])
if isinstance(item, list):
item = item.pop(0)
return iter([item])
def sent_keys(self):
return [k for k, _, _ in self.calls]
class FirstPick:
"""把 random.choice 固定为取第一个候选,使副本选择在测试里确定"""
def choice(self, seq):
return seq[0]
def _pin_first_pick():
original = client_module.random
client_module.random = FirstPick()
return original
# ── 信封组装 ────────────────────────────────────────────────────────────────
def test_wrap_business_payload():
sess = StubSession(["worker/demo/edge-01"])
payload = {"a": 1, "b": {"c": 2}}
out = asyncio.run(Client(session=sess).call("demo", "calc.add", payload, method="post", timeout=1.0))
assert out == {"ok": True}
key, sent, kwargs = sess.calls[0]
assert key == "worker/demo/edge-01/calc.add"
assert orjson.loads(sent) == {"meta": {"method": "POST"}, "data": {"a": 1, "b": {"c": 2}}}
assert kwargs["timeout"] == 1.0 # zenoh 查询自身超时已对齐
assert payload == {"a": 1, "b": {"c": 2}} # 调用方 dict 未被修改
def test_envelope_reuse_verbatim():
"""调用方信封:保留全部字段、falsy 值不改写,仅补 meta.method,不改调用方 dict"""
sess = StubSession(["worker/demo/edge-01"])
payload = {"meta": {"trace": "t1"}, "data": 0, "trace_id": "z"}
asyncio.run(Client(session=sess).call("demo", "calc.add", payload, method="POST"))
assert orjson.loads(sess.calls[0][1]) == {
"meta": {"trace": "t1", "method": "POST"}, "data": 0, "trace_id": "z",
}
assert payload["meta"] == {"trace": "t1"} # 嵌套 meta 也未被写回
def test_envelope_method_not_overridden():
sess = StubSession(["worker/demo/edge-01"])
asyncio.run(Client(session=sess).call("demo", "calc.add", {"meta": {"method": "put"}, "data": {}}, method="POST"))
assert orjson.loads(sess.calls[0][1])["meta"]["method"] == "put"
def test_data_only_dict_stays_envelope():
"""只有 data 键的 dict 按信封处理(协议兼容规则),补上 meta.method"""
sess = StubSession(["worker/demo/edge-01"])
asyncio.run(Client(session=sess).call("demo", "calc.add", {"data": {"x": 1}}, method="POST"))
assert orjson.loads(sess.calls[0][1]) == {"data": {"x": 1}, "meta": {"method": "POST"}}
def test_non_dict_payload_wrapped():
sess = StubSession(["worker/demo/edge-01"])
asyncio.run(Client(session=sess).call("demo", "calc.add", [1, 2]))
assert orjson.loads(sess.calls[0][1]) == {"meta": {"method": "GET"}, "data": [1, 2]}
def test_meta_not_dict_raises_before_send():
sess = StubSession(["worker/demo/edge-01"])
try:
asyncio.run(Client(session=sess).call("demo", "calc.add", {"meta": "hello", "n": 1}))
except ValueError as e:
assert "meta" in str(e)
else:
raise AssertionError("应抛 ValueError")
assert sess.calls == [] # 发送前就失败
# ── 回复语义 ────────────────────────────────────────────────────────────────
def test_empty_reply_returns_empty_dict():
sess = StubSession(["worker/demo/edge-01"], replies={"worker/demo/edge-01": [FakeReply(b"")]})
assert asyncio.run(Client(session=sess).call("demo", "calc.add", {})) == {}
def test_no_workers_raises_runtime_error():
sess = StubSession([])
try:
asyncio.run(Client(session=sess).call("demo", "calc.add", {}))
except RuntimeError as e:
assert "没有可用 Worker" in str(e) and "demo" in str(e)
else:
raise AssertionError("应抛 RuntimeError")
def test_call_sync():
sess = StubSession(["worker/demo/edge-01"])
assert Client(session=sess).call_sync("demo", "calc.add", {}) == {"ok": True}
# ── 换副本重试 / 冷静期 ─────────────────────────────────────────────────────
def test_timeout_switches_worker_and_marks_suspect():
sess = StubSession(
["worker/demo/edge-01", "worker/demo/edge-02"],
blocking={"worker/demo/edge-01": 0.5},
replies={"worker/demo/edge-02": [FakeReply(b'{"who":"edge-02"}')]},
)
c = Client(session=sess)
original = _pin_first_pick()
try:
out = asyncio.run(c.call("demo", "calc.add", {}, timeout=0.05, retries=1))
finally:
client_module.random = original
assert out == {"who": "edge-02"}
assert sess.sent_keys() == [
"worker/demo/edge-01/calc.add",
"worker/demo/edge-02/calc.add",
]
assert "worker/demo/edge-01" in c._suspect_until # 超时副本进入冷静期
def test_suspect_skipped_on_next_call():
sess = StubSession(["worker/demo/edge-01", "worker/demo/edge-02"])
c = Client(session=sess)
c._suspect_until["worker/demo/edge-01"] = time.monotonic() + 30
original = _pin_first_pick()
try:
asyncio.run(c.call("demo", "calc.add", {}))
finally:
client_module.random = original
assert sess.sent_keys() == ["worker/demo/edge-02/calc.add"]
def test_err_reply_switches_then_success():
sess = StubSession(
["worker/demo/edge-01", "worker/demo/edge-02"],
replies={
"worker/demo/edge-01": [FakeReply(err=FakeErr(b'{"error":"boom"}'))],
"worker/demo/edge-02": [FakeReply(b'{"ok":2}')],
},
)
c = Client(session=sess)
original = _pin_first_pick()
try:
out = asyncio.run(c.call("demo", "calc.add", {}))
finally:
client_module.random = original
assert out == {"ok": 2}
assert sess.sent_keys() == [
"worker/demo/edge-01/calc.add",
"worker/demo/edge-02/calc.add",
]
# 业务错误不列入冷静期(与 worker_manager 一致:只有超时/无回复进冷静期)
assert "worker/demo/edge-01" not in c._suspect_until
def test_single_replica_fails_fast_with_detail():
sess = StubSession(["worker/demo/edge-01"], blocking={"worker/demo/edge-01": 0.3})
try:
asyncio.run(Client(session=sess).call("demo", "calc.add", {}, timeout=0.05, retries=2))
except RuntimeError as e:
msg = str(e)
assert "已尝试 1 个 Worker" in msg
assert "上限 3 次" in msg
assert "无其它未试副本可换" in msg and "超时" in msg # last_error 保留
else:
raise AssertionError("应抛 RuntimeError")
assert len(sess.calls) == 1 # 无未试副本立即结束,不白等剩余重试
def test_zenoh_timeout_reply_treated_as_error():
"""zenoh 查询自身超时给出的答复(ok=None, err.payload=b"Timeout")走错误通道"""
sess = StubSession(
["worker/demo/edge-01"],
replies={"worker/demo/edge-01": [FakeReply(err=FakeErr(b"Timeout"))]},
)
try:
asyncio.run(Client(session=sess).call("demo", "calc.add", {}))
except RuntimeError as e:
assert "返回错误: Timeout" in str(e)
else:
raise AssertionError("应抛 RuntimeError")
def test_empty_discovery_not_cached():
"""发现结果为空不写缓存:副本随后上线,下一次调用立即可见(不等 15s TTL)"""
sess = StubSession([])
c = Client(session=sess)
try:
asyncio.run(c.call("demo", "calc.add", {}))
except RuntimeError:
pass
sess.workers.append("worker/demo/edge-01")
assert asyncio.run(c.call("demo", "calc.add", {})) == {"ok": True}
def test_exhausted_error_message_contains_worker_error():
sess = StubSession(
["worker/demo/edge-01"],
replies={"worker/demo/edge-01": [FakeReply(err=FakeErr(b"boom"))]},
)
try:
asyncio.run(Client(session=sess).call("demo", "calc.add", {}, retries=1))
except RuntimeError as e:
assert "返回错误: boom" in str(e)
assert "已尝试 1 个 Worker" in str(e)
else:
raise AssertionError("应抛 RuntimeError")
def _main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for t in tests:
t()
print(f"PASS {t.__name__}")
print(f"\n{len(tests)} tests passed")
if __name__ == "__main__":
_main()
Generated
+54 -2
View File
@@ -187,10 +187,11 @@ wheels = [
[[package]] [[package]]
name = "chongming-request" name = "chongming-request"
version = "0.1.0" version = "0.1.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "eclipse-zenoh" }, { name = "eclipse-zenoh" },
{ name = "orjson" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -199,7 +200,10 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "eclipse-zenoh", specifier = ">=1.9.0" }] requires-dist = [
{ name = "eclipse-zenoh", specifier = ">=1.9.0" },
{ name = "orjson", specifier = ">=3.10" },
]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "twine", specifier = ">=7.0.0" }] dev = [{ name = "twine", specifier = ">=7.0.0" }]
@@ -411,6 +415,54 @@ wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" },
] ]
[[package]]
name = "orjson"
version = "3.13.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f2/72/380b97dc45bd162d23afe5194721ef678d9eac7cfaa549fe2873f7f0a518/orjson-3.13.0.tar.gz", hash = "sha256:d1de5eb04485110c5da4c657e49168995d55e076b1ce60f1a042e254f4186c4f", size = 2732604, upload-time = "2026-10-07T14:09:25.719Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/98/17/ed65f84ed5ed6a1e06eb628611b4172e7480fc4ad92594856751a6363cac/orjson-3.13.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb8644dc6d705e1269ed2842bf4dbe2b4e50d670de503bf79d5cef3a5148a4c7", size = 223063, upload-time = "2026-10-07T14:08:21.979Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6f/4d/9332eb96d2e379384be0f211f543835eebc81f460c9403b84abe1294c431/orjson-3.13.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6ff2a2c67f35202f7d823753d38ad371a9b7fc297567cdfff4420e763cb9f6f8", size = 123364, upload-time = "2026-10-07T14:08:24.026Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b4/06/558456b7da27e974a8c9ea09117b07119f6fa131cd62b8b9ecad9eea94e1/orjson-3.13.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:65c4e0e106ccc7265b488385659117a6805c37d042f737558ecd68aa0c67ad8f", size = 113199, upload-time = "2026-10-07T14:08:25.476Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b7/f2/1187a9c09965620348262ec0f406868f6d7c234b2e9b5ee51020bdde5748/orjson-3.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:fbbad6b9b1da43f25c1f5b20cd5a268e028a2fc95d5a8d1ade6059973bc71584", size = 130329, upload-time = "2026-10-07T14:08:26.877Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/46/07/5d1a151bc11600434fe799e73abfc6a4d463d02e149a20e47c59d3a985ae/orjson-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d895cf7bbfd50ef34bb63bb727b14514f259f3e3f8dd010783bd38e864c6e", size = 129072, upload-time = "2026-10-07T14:08:28.355Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ea/8c/bb07c368abbf4021c4cd01c12edb526e00090f7f750ff1b88da6e6b6c7a6/orjson-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bceadfd314bd238f584fc229a4bbaf0e573597e7a026dec5429fbf29fd66c641", size = 130612, upload-time = "2026-10-07T14:08:30.041Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d2/8d/4b66d19619ed344ac000ffea7c006477d0061d580646e736ef0e203759e8/orjson-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b74c30e56346aad067937d766846ee74c231d1d18aad3f324e9b9261de3b2d5e", size = 134632, upload-time = "2026-10-07T14:08:31.474Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ea/88/f8221f6593e37eb26ec4706e185b9ac6f38ff0c8f7bad5459844031ffd2d/orjson-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4329c19b8a25693f60a77b867c9d2a3ab637b20e36f5b7bea7f5acb492b44b15", size = 126807, upload-time = "2026-10-07T14:08:32.914Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/9d/a1ca7321eeafd7d72e174cdc388cc96301f41516d863e7b1f64f0a1735be/orjson-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:b571236d8393edcd3236e07423f762bfcf571f852aad667a3bce9e7b755e0790", size = 121538, upload-time = "2026-10-07T14:08:34.325Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d0/a0/1f19b4779c910104370932fceb9ed436b47ac077f297db74008062525c04/orjson-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:8594956a75223f657e1e68c568c0eeb3dd145f02cd6b78a47fd9a8095dbc4eae", size = 126259, upload-time = "2026-10-07T14:08:35.765Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a9/56/f8ad2546150168858c16915c452b00eecb79597597524d1ad6ae14ad4eab/orjson-3.13.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:64e8f345048d988c8b68d3882e5d41028fca1219a9939b32e4a77be34c8ae8e3", size = 222892, upload-time = "2026-10-07T14:08:37.495Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1f/19/725d23160b2471a3f27026c55bb79af34687652d8be8f5f583cee5dcd42f/orjson-3.13.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ded33b972cffdaf4ca0ac917338ab61d2bb10d68987dbcae641c313fbfdbf499", size = 123319, upload-time = "2026-10-07T14:08:38.989Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ac/08/e5d81a00b22c73dfcb60d80da3bd92d5a7684346593536565f184dbae3c9/orjson-3.13.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:45e34deb3437509f4ec9888dd9ee5dc426cfe21be10f1eb4ea3a9e4d33034f9e", size = 113196, upload-time = "2026-10-07T14:08:40.383Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/67/78/fda6117c69a43e470b1e9dff38dd8c5f0bc6fd8a47e4d4561ab023039335/orjson-3.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9825b954155b345c4759f24e5f8d652b9aec2261bb5d4e1abe06bba0a1200535", size = 130245, upload-time = "2026-10-07T14:08:41.878Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6d/31/d0cfebd456defb234414795ae7599696bf124843dfe077d0c9ece0c93554/orjson-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b081f0e7b600ff24513dec4ca75507fa05e904607847e386e8310d5b7b96b6c7", size = 128981, upload-time = "2026-10-07T14:08:43.716Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/45/46/f8d83189ff5b7b2ff225a58c5908618cc4e86afe09e65d17a30ac68c9da4/orjson-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbed5f4c4b88d94bcc36115f4c3bb3aa25da1563a5c3328aa3acebce2b083040", size = 130370, upload-time = "2026-10-07T14:08:45.132Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e6/6a/d6344c305003ea826b3fa0482645a897a3cd6d477ed74e1fe15d3322cb23/orjson-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9b61676116f755126b90e740a9cff36b91562f47ec330056cc88cc3b9f02f4b", size = 134595, upload-time = "2026-10-07T14:08:46.63Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9f/52/d73fa44f88d53e02d10de1cf77c16ed13204ff5bca47e1692da6b406619c/orjson-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ef75ed7e81dae34a3649f82df52cd85f9ac839a7d6ec78ab355b33b3b27ef7f", size = 126513, upload-time = "2026-10-07T14:08:48.111Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fb/f8/bcfc50b4ab851c4f9c0ee62f52bf3b28f0bcd0d9fe08e0ad98d4585148db/orjson-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ee06e53b998c71ce3eb93b86222912fdd9dcced685ac64d4525d36fac338ea4", size = 121371, upload-time = "2026-10-07T14:08:49.549Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7b/7a/d6927845712ec2b1e89263cd12d7203531db185dbad67f914226f2fca156/orjson-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:89efecad02515df7f318d0613b5dfd6d2a1acd323a2b8294712789a715945525", size = 126134, upload-time = "2026-10-07T14:08:51.118Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f0/10/98b5a3cdc086abf78d8cd20bb0cba124485d4b6a745722197bd209d967a5/orjson-3.13.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a7bfc7db961c7d96cb75889dc6a1e4ae1e91d87ee61da564f582bd742b8dfeef", size = 222889, upload-time = "2026-10-07T14:08:52.673Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/22/7c/7728c5280ab5202f4891ff4b0b96e2e1dbd5520dfee53edf083c54409a64/orjson-3.13.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:91d933e668ff0ffe164d7c2daec36beba6d1ce7fadb71538fbe142a71f8a1e6e", size = 123312, upload-time = "2026-10-07T14:08:54.25Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a9/a5/d9a44321e6f66c0f64b45be587395f87ad94cb447bce7d92286f6b97d46a/orjson-3.13.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:6c8bfe728b81b0fd58a3c7f3f9c5a113f87f2992c9948e0f28707aafd737c0bc", size = 113146, upload-time = "2026-10-07T14:08:55.803Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/80/da/d95c80d413f288feb471e16d82e5c1512d2439728e3bac917d058c31f098/orjson-3.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8e05549f3b30f9d8a8e28c5aba11cc2a4b90b90961ec685ca58444b0815fc09", size = 130348, upload-time = "2026-10-07T14:08:57.31Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/04/0f/36fdfb32ad1852997bac00e3ce52c7888d8a1094ba9dcdcbb22fcc6b953a/orjson-3.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c749ab3ac30b5ab1ffb7677f8b92eacfdfdc5260210baa398f845bc3714c05d8", size = 128971, upload-time = "2026-10-07T14:08:58.843Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/25/de/a82acf93bdcca0c79ccff25ef0c6868d24ccbc2e72f21fae39c8cabce4f1/orjson-3.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58a9619d88f8818d9ab6b39d70d203789457ba13c1ed5d274f33ce9ae7e81a36", size = 130359, upload-time = "2026-10-07T14:09:00.412Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/ca/2bc4f7697cb9f6897bf61aca11803df096a5d971bf69ef5538b243bb1fa8/orjson-3.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2715c4808d1571029ed18fd07a82140bf3ba7def0dc89f8d015c416e3649bf87", size = 134583, upload-time = "2026-10-07T14:09:02.047Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/23/b3/12b1af9b87ff9fa0aaf4e5724c87672b30bb5de76f275f7fac64e8219c1b/orjson-3.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08bf722f923d2100bc5e5a5dcf72c656db557049c1bea26582fdd5dd9d5395a1", size = 126500, upload-time = "2026-10-07T14:09:03.863Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ad/ea/cf257fc8a7f4b18f5677c22b3a9673a1b51d4b7161f25177ed389b76560e/orjson-3.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:6adcaa85d79977659a448b4123a88eb33511a11ed2db243535ad7ea88a6668e0", size = 121378, upload-time = "2026-10-07T14:09:05.375Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/05/0a/9f4643f849e9918eab11983b83928af3aac14bedb04002e28e885ee1936f/orjson-3.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:83705c12b4afde10c62a5dd3fe6fdb21b7900bd0dcd5af1c85612ae94d0ee590", size = 126123, upload-time = "2026-10-07T14:09:07.085Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/15/d265f2b556c0c7c0b30ea830316d6e5af5b85dde08f234a1ebed60fab386/orjson-3.13.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5ef4d4157392a0439b74f7e49e5636b4ea43d9616bd0884effc0195fffcaa2d5", size = 223305, upload-time = "2026-10-07T14:09:08.84Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0c/97/781be8b80a33b8171b3f5acea941af47182c8b4b5827c2b7c3fea706f21c/orjson-3.13.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:84d87e322e1674408f85adea63f11aa19201eba082755aec20ebc217f493bbd2", size = 123515, upload-time = "2026-10-07T14:09:10.792Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/20/68/011bb98fa7da7b430b363db1bb7ef9160c438fc5c43e7468fb593c220037/orjson-3.13.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:8c2ac5c09b017c484df1b4c68b2cf250b4e8ba08204cb58e7cd6cbbc71a9c902", size = 129222, upload-time = "2026-10-07T14:09:12.542Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/86/7f/d96fa2aedaaec14c095ea9cd48d2158fdf33c0f4fd6e7a598d899d536b03/orjson-3.13.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:51d11525bc3ca736fa97ce4e4c7da9999cc00bf261522bede43b4e7531bd7965", size = 113152, upload-time = "2026-10-07T14:09:14.059Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e9/2d/ee77aa685c54bd920a1f0e2936986b46269adb0d72bf5098c2c694dbeb36/orjson-3.13.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:ac81530647c3423107cf61c3481e91f57134e9ddfb6ef83f5150ccbdcbc3a3ee", size = 130749, upload-time = "2026-10-07T14:09:15.835Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/48/eb/3411fbfdad61b3f3af22343b5af7ed5c8a1679e35f442e8f1b229b33040e/orjson-3.13.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:0526a3456db67b264c6d661b5f090077f326b6cd074d0ef53a72763595dec5d7", size = 130471, upload-time = "2026-10-07T14:09:17.463Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/87/71/abdc2b8c70b8d85a6cb22f404da0f52d7d712f9d49cda039a0cb1adcb973/orjson-3.13.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dd61e64802d51d1e4f16531c64536354fc3bc67932dc0cff254044f72bf0f187", size = 134793, upload-time = "2026-10-07T14:09:19.084Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0a/2e/1c13552d8b0241083116de02b2f284ee38501ef06ebfb79893f741538168/orjson-3.13.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c5e3ccaac3106e8fa6e2f2f6962449d7c757d7b067e41b395a19d6f0d6cec892", size = 126711, upload-time = "2026-10-07T14:09:20.645Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/85/f8/d4ece953a519d064cf690adaa68cd389d5b64fd261726334841b32978d6a/orjson-3.13.0-cp315-cp315-win_amd64.whl", hash = "sha256:7804dd1d6161da0e53b284c2aebf20f23e78eaac617300803e1467d1828d987f", size = 121496, upload-time = "2026-10-07T14:09:22.359Z" },
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/70/cf/f691388c4a9bc4af7dcc1648c4b40845869908b517d7c0009d005c7d1fa1/orjson-3.13.0-cp315-cp315-win_arm64.whl", hash = "sha256:f5c05a8fee59309f537590a1ff12d3c1009c485e96a50a9ac60dd085c09d0fc0", size = 126260, upload-time = "2026-10-07T14:09:23.928Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"