Files
chongming-utils-request/tests/test_client.py
T

289 lines
10 KiB
Python
Raw Normal View History

"""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()