modify: call 对齐 worker_manager 语义(换副本重试/冷静期/信封原样透传),0.1.1 补 orjson 依赖与回归测试
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user