289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""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()
|