diff --git a/src/polygateway/transports/_http_errors.py b/src/polygateway/transports/_http_errors.py new file mode 100644 index 0000000..f050997 --- /dev/null +++ b/src/polygateway/transports/_http_errors.py @@ -0,0 +1,63 @@ +"""HTTP 错误响应体的取用与摘要(issue #10 设计 §3.2)。 + +两个 transport 各有自己的状态码分类逻辑(OCR 有意不做 429 细分),但**摘要口径 +必须是同一份**——issue #10 的教训正是"只有一个分支用了响应体",一处例外就是 +下一次事后查不到原因。故本模块是全库唯一的摘要实现,不得在别处复制。 +""" + +from __future__ import annotations + +import httpx + +_ERROR_BODY_CAP = 2048 +"""摘要总长上限(**字符**,含省略标记在内)。 + +取值对齐 Kubernetes client-go `rest/request.go` 的 `maxUnstructuredResponseTextBytes += 2048`——它是唯一与本设计同场景(读 HTTP 错误体做诊断)的成熟先例。按字符而非 +字节切,多字节字符不会被切成半个;`error` 列是 TEXT,无定长约束,不需要字节口径。 +""" + +_HEAD_CHARS = 1400 +_TAIL_CHARS = 600 + + +def summarize_body(text: str) -> str: + """折叠空白后按头尾策略摘要;空/空白入参返回空串。 + + **折叠空白**不是洁癖: 错误体常是缩进 JSON,原样拼进 message 会把一行日志 + 炸成多行、把遥测列变得不可读。 + + **保头保尾**而非头部硬切: 截断的对象是结构化 JSON,信息分布头重尾也重—— + 人话(`message`)在前,机器可判的 `type`/`code`/`param`/`request_id` 在后。 + k8s/Sentry 用头部硬切是因为它们截的是任意文本;本函数截的是错误 JSON, + 头部硬切正好切掉向网关方追查时唯一有用的那部分。策略取自标准库 `reprlib` + 对"给人读的长字符串"的处置。 + + **标记记下省略字数**,读的人才知道自己丢了多少,不会误以为网关只说了这么多。 + """ + collapsed = " ".join(text.split()) + if len(collapsed) <= _ERROR_BODY_CAP: + return collapsed + omitted = len(collapsed) - _HEAD_CHARS - _TAIL_CHARS + return f"{collapsed[:_HEAD_CHARS]}…(略 {omitted} 字)…{collapsed[-_TAIL_CHARS:]}" + + +def compose_message(message: str, summary: str) -> str: + """摘要非空才拼后缀,避免留下悬空的分隔符。 + + 分隔符取 ` | ` 而非既有的 `: `,让"库说的话"与"网关说的话"一眼可分。 + """ + return f"{message} | {summary}" if summary else message + + +def response_body(response: httpx.Response) -> str: + """取**已缓冲**的响应文本;未读缓冲一律降级空串。 + + 绝不在此触发网络读: 那会在错误路径上凭空插入一次可能挂住的 IO。降级方向 + 与缓存/遥测同档(库铁律)——诊断信息缺失不得把一次本可正确分类的失败变成 + 不可分类的崩溃,那正是 `ResponseNotRead` 泄漏出四分类之外的后果。 + """ + try: + return response.text + except httpx.ResponseNotRead: + return "" diff --git a/tests/unit/test_http_error_body.py b/tests/unit/test_http_error_body.py new file mode 100644 index 0000000..b68325a --- /dev/null +++ b/tests/unit/test_http_error_body.py @@ -0,0 +1,89 @@ +"""HTTP 错误响应体摘要口径(issue #10 设计 §3.2/§3.4)。 + +摘要是 message 与 `body_text` 共用的**同一份串**,故它的边界行为直接决定 +遥测里看到的与下游 catch 到的是否一致——本组用例把规则钉成算术。 +""" + +import httpx +import pytest + +from polygateway.transports._http_errors import ( + _ERROR_BODY_CAP, + _HEAD_CHARS, + _TAIL_CHARS, + compose_message, + response_body, + summarize_body, +) + +# issue #10 原文给出的真实响应体(一字不改),关键在于 code 收尾 +_REAL_SAMPLE = ( + '{"error":{"message":"<400> ***.***.InvalidParameter: The image format is illegal ' + 'and cannot be opened","type":"invalid_request_error","param":"",' + '"code":"invalid_parameter_error"}}' +) + + +class TestSummarizeBody: + def test_short_body_passes_through(self): + assert summarize_body(_REAL_SAMPLE) == _REAL_SAMPLE + + def test_whitespace_collapsed(self): + """错误体常是缩进 JSON: 不折叠会把一行日志炸成多行、遥测列不可读。""" + assert ( + summarize_body('{\n "error": {\n "code": "x"\n }\n}') + == '{ "error": { "code": "x" } }' + ) + + @pytest.mark.parametrize("raw", ("", " ", "\n\t \n")) + def test_blank_yields_empty(self, raw): + assert summarize_body(raw) == "" + + def test_exactly_at_cap_is_untouched(self): + body = "x" * _ERROR_BODY_CAP + assert summarize_body(body) == body + + def test_one_over_cap_is_summarized(self): + summary = summarize_body("x" * (_ERROR_BODY_CAP + 1)) + assert summary != "x" * (_ERROR_BODY_CAP + 1) + assert "略" in summary + + def test_head_and_tail_both_survive(self): + """头部硬切会丢掉尾部,而 JSON 错误体的 code/request_id 正在尾部。""" + body = "H" * 5000 + "T" * 5000 + summary = summarize_body(body) + assert summary[:_HEAD_CHARS] == body[:_HEAD_CHARS] + assert summary[-_TAIL_CHARS:] == body[-_TAIL_CHARS:] + assert f"…(略 {10000 - _HEAD_CHARS - _TAIL_CHARS} 字)…" in summary + + def test_real_sample_tail_visible_in_oversized_body(self): + """设计 §7 用例 3c: 超长体里,追查网关方所需的 code 仍须可见。""" + summary = summarize_body("PADDING" * 1000 + _REAL_SAMPLE) + assert '"code":"invalid_parameter_error"}}' in summary + + def test_idempotent(self): + """再摘要一次不得嵌套标记,否则重复经手的串会层层套娃。""" + once = summarize_body("y" * 9999) + assert summarize_body(once) == once + + +class TestComposeMessage: + def test_empty_summary_leaves_message_intact(self): + assert compose_message("qwen_1 请求被拒: 400", "") == "qwen_1 请求被拒: 400" + + def test_non_empty_summary_is_appended(self): + assert compose_message("qwen_1 请求被拒: 400", "{}") == "qwen_1 请求被拒: 400 | {}" + + +class TestResponseBody: + def test_reads_buffered_text(self): + assert response_body(httpx.Response(400, content=b'{"e":1}')) == '{"e":1}' + + def test_unread_stream_degrades_to_empty(self): + """取不到诊断信息绝不能升级为崩溃: 未读缓冲返回空串,且不触发网络读。""" + + class _Unread(httpx.SyncByteStream): + def __iter__(self): + yield b"body" + + assert response_body(httpx.Response(400, stream=_Unread())) == ""