feat: validate the dimensions a caller may attach to a call
Adds validate_caller_dimensions and the two ChatRequest fields that carry them. Every limit rejects rather than trims: Langfuse drops metadata values past 200 characters, which leaves the caller believing something was recorded when nothing was. Whitespace on tenant_id is refused outright instead of stripped. " t1" and "t1" compare unequal inside an RLS policy, so silently rewriting the caller's value would hand them a tenant whose rows they cannot find. Non-finite floats are refused for a concrete reason: json.dumps writes them as the bare literals NaN and Infinity, which are not valid JSON and which JSONB rejects. Letting one through turns a caller's input mistake into a failed insert, and the telemetry layer degrades failed inserts to a warning -- so the mistake would surface as missing rows, nowhere else. The new fields go after sampling so no positional construction of ChatRequest shifts. Validation is split across three helpers to keep each one under the complexity gate.
This commit is contained in:
@@ -398,3 +398,132 @@ class TestSourceConfigExtraBody:
|
||||
"""
|
||||
with pytest.raises(TypeError):
|
||||
hash(_make_source())
|
||||
|
||||
|
||||
class TestCallerDimensionsValidation:
|
||||
"""调用方自定义维度的入口校验(issue #11 设计 §4.2)。
|
||||
|
||||
这些红线全部要求**报错**而非静默丢弃: Langfuse 对超长 value 的做法是
|
||||
直接丢掉,本库不抄——P5 严禁默认值掩盖错误。且报错点必须在进洋葱之前,
|
||||
洋葱内的一切失败都会被遥测层降级成 warning,校验放那里等于没有校验。
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"", # 空串是哨兵值的地盘(老行/未归属)
|
||||
" ", # 纯空白 strip 后为空
|
||||
" t1", # 首尾空白: 与 "t1" 在 RLS 等值比较下是两个租户
|
||||
"t1 ",
|
||||
"x" * 129, # 上限 128
|
||||
123, # 非 str
|
||||
],
|
||||
)
|
||||
def test_bad_tenant_id_rejected(self, bad):
|
||||
"""租户标识形态错误必须当场报错,而非带着走到落库。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
validate_caller_dimensions(bad, None, origin="chat(tenant_id=...)")
|
||||
assert "chat(tenant_id=...)" in str(exc.value) # 信息须能定位来源
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"Batch", # 大写不合字符集
|
||||
"batch-id", # 连字符不合字符集
|
||||
"b" * 65, # 键长上限 64
|
||||
"pg_internal", # 保留前缀
|
||||
"", # 空键
|
||||
],
|
||||
)
|
||||
def test_bad_meta_key_rejected(self, key):
|
||||
"""键集合被假定为低基数且稳定,形态必须收紧(OTel semconv 字符集)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
validate_caller_dimensions(None, {key: "v"}, origin="test")
|
||||
|
||||
def test_non_str_meta_key_rejected(self):
|
||||
"""非 str 键无法进 JSON 对象,须先于值校验报出键的问题。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="str"):
|
||||
validate_caller_dimensions(None, {1: "a"}, origin="test")
|
||||
|
||||
def test_too_many_meta_keys_rejected(self):
|
||||
"""上限 16: 容器是审计维度,不是给调用方塞整个请求体的地方。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="16"):
|
||||
validate_caller_dimensions(None, {f"k{i}": "v" for i in range(17)}, origin="test")
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a"], {"a": 1}, None, object()])
|
||||
def test_non_scalar_meta_value_rejected(self, bad):
|
||||
"""只收扁平标量(OTel AnyValue 的可移植子集);嵌套让调用方自己序列化。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="值"):
|
||||
validate_caller_dimensions(None, {"k": bad}, origin="test")
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_non_finite_float_rejected(self, bad):
|
||||
"""json.dumps 会把它们写成 NaN/Infinity 字面量——非合法 JSON,PG JSONB 拒收。
|
||||
|
||||
放行则调用方的输入错误会在写入层失败、被遥测降级吞成 warning,
|
||||
即"输入错误"静默变成"丢遥测"(Codex 审查推翻了初稿的"不可达"论断)。
|
||||
"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
validate_caller_dimensions(None, {"k": bad}, origin="test")
|
||||
|
||||
def test_too_long_meta_value_rejected(self):
|
||||
"""字符串值上限 256(对齐 Sentry tag / Langfuse 的量级)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
validate_caller_dimensions(None, {"k": "v" * 257}, origin="test")
|
||||
|
||||
def test_absent_dimensions_pass_through(self):
|
||||
"""两者都不传是绝大多数调用点的现状,必须零摩擦放行。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
assert validate_caller_dimensions(None, None, origin="test") == (None, {})
|
||||
|
||||
@pytest.mark.parametrize("good", ["s", 1, 1.5, True, False, 0])
|
||||
def test_scalar_meta_values_accepted(self, good):
|
||||
"""bool 是 int 子类,两者都合法;0/False 不得被真值判断误杀。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
_, meta = validate_caller_dimensions(None, {"k": good}, origin="test")
|
||||
assert meta == {"k": good}
|
||||
|
||||
def test_returns_independent_copy(self):
|
||||
"""调用方复用同一 dict 逐次改值是预期模式,不拷贝会有竞态(同 overlay 决策 E)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
caller_dict = {"batch": "b-42"}
|
||||
_, meta = validate_caller_dimensions("t1", caller_dict, origin="test")
|
||||
caller_dict["batch"] = "b-99"
|
||||
assert meta == {"batch": "b-42"}
|
||||
|
||||
|
||||
class TestChatRequestDimensions:
|
||||
"""ChatRequest 承载维度的字段契约(issue #11)。"""
|
||||
|
||||
def test_defaults_are_absent_dimensions(self):
|
||||
"""新字段必须带默认值——三项目逐字段构造的 fake 才能零改动(ARCH §5.1 约定①)。"""
|
||||
request = ChatRequest(messages=[{"role": "user", "content": "x"}])
|
||||
assert request.tenant_id is None
|
||||
assert request.meta == {}
|
||||
|
||||
def test_dimensions_are_carried(self):
|
||||
"""维度随请求在洋葱内流转,是缓存 key 之外三个遥测入口的共同读取点。"""
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
tenant_id="t1",
|
||||
meta={"batch": "b-42"},
|
||||
)
|
||||
assert request.tenant_id == "t1"
|
||||
assert request.meta == {"batch": "b-42"}
|
||||
|
||||
Reference in New Issue
Block a user