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

289 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()