# -*- coding: utf-8 -*- """响应契约守卫(最内层中间件)—— Bug Zhpd9MXyeGtznDMqini6X / R50 ## 为什么需要它(R50 机械取证定案) R49 全站矩阵把 500 记为 0,R50 在目标机日志里取到真实栈(`logs/*.log`): ------ /index.ui RuntimeError ----- Traceback (most recent call last): File ".../app/entry_semantics.py", line 485, in entry_notfound_guard return await handler(request) File ".../app/route_guard_middleware.py", line 499, in resource_preflight_middleware return await handler(request) File ".../app/route_guard_middleware.py", line 533, in auth_required_middleware return await handler(request) File ".../app/route_guard_middleware.py", line 579, in write_endpoint_semantics_middleware return await handler(request) File ".../app/business_status_middleware.py", line 153, in business_status_middleware response = await handler(request) File ".../venv/lib/python3.10/site-packages/aiohttp_session/__init__.py", line 196, in factory raise RuntimeError(f"Expect response, not {type(response)!r}") RuntimeError: Expect response, not 即:链尾**路由处理器返回了一个函数对象**,不是 `web.Response`。 `aiohttp_session.factory` 在拿到非 Response 时抛 RuntimeError,异常沿中间件链上抛, 最终被鉴权/状态层改写成一个「鉴权失败」样子的响应 —— 这就是 「38 条 500 一条也判不出来 / 500 计数=0」的机械原因: **处理器契约破坏(返回函数)与鉴权失败是两件事,却被压成同一种 401 表象。** ## 本守卫做两件事(顺序即语义) 1. **救援**:链尾返回可调用对象时,把它当作真实处理器调用一次 (`resp(request)`,awaitable 则 await)。这是 aiohttp 装配期的「handler 未落地」 短路,直接补上这一次调用即可拿到真正的 Response —— 站点能力立刻恢复, 而不是让整站挂在鉴权表象上。 2. **归因**:救援失败 / 返回 None / 返回非法对象时,返回 **500 + 处理器身份** (模块名、限定名、源文件、行号),让 500 可被矩阵判为「真实 500 并指名道姓」, 绝不再伪装成 401 auth_unavailable。 作为 `app.middlewares` 的**最后一位(最内层)**装配:只有贴住路由处理器, 才能观察到「处理器返回值」本身;装在外层只会看到已经被 factory 判定过的结果。 ## 不变量(不得放宽) - 正常 Response(含所有 4xx/5xx 语义响应)**原样透传**,一个字节都不改。 - 守卫自身异常一律吞掉并放行原响应,避免守卫成为新的单点故障。 """ import inspect import logging log = logging.getLogger("yuanjing.response_contract_guard") #: 装配标记,供 /healthz 与自检读取 REVISION = "r50-response-contract-v1" def _handler_identity(fn): """取可调用对象身份:模块 / 限定名 / 源文件 / 行号。取不到就给 repr。""" ident = {"repr": repr(fn)[:300]} try: ident["module"] = getattr(fn, "__module__", "") or "" ident["qualname"] = getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "") or "" except Exception: # noqa: BLE001 pass try: src = inspect.getsourcefile(fn) if src: ident["file"] = src _, lineno = inspect.getsourcelines(fn) ident["line"] = lineno except Exception: # noqa: BLE001 pass return ident def _is_response(obj): """宽松判定:aiohttp Response 及其子类(含各 HTTPException 派生)。""" try: from aiohttp import web return isinstance(obj, web.StreamResponse) except Exception: # noqa: BLE001 return hasattr(obj, "status") and hasattr(obj, "headers") def _json_response(payload, status): """构造 JSON 响应。**不得传 dumps=None**——aiohttp 的 `json_response(dumps=None)` 会退化成 text/plain 直发 repr,导致 500 归因体不是合法 JSON、矩阵无法解析。""" from aiohttp import web try: return web.json_response(payload, status=status) except Exception: # noqa: BLE001 import json as _json return web.Response(status=status, text=_json.dumps(payload, ensure_ascii=False, default=str), content_type="application/json") def _fail(request, reason, ident, rescued_error=""): payload = { "ok": False, "success": False, "status": 500, "error": "response_contract_violation", "reason": reason, "path": getattr(request, "path", "") or "", "method": (getattr(request, "method", "GET") or "GET").upper(), "handler": ident, "rescued_error": str(rescued_error)[:500], "guard_revision": REVISION, "hint": "处理器返回值不是 Response:见 handler.file/line 定位真实缺陷点", } log.error("[response_contract] %s %s %s -> %s %s", payload["method"], payload["path"], reason, ident.get("qualname", "-"), ident.get("file", "-")) return _json_response(payload, 500) # [fix rvtGi1mlUsTwQrXnybZj6 / R60] 显式登记为新式中间件:aiohttp>=3.9 用 # `__middleware_version__ == 1` 区分新式/旧式,裸协程函数会被当旧式工厂调用 # → 返回 handler 函数而不是 Response(`Expect response, not `)。 from aiohttp import web as _r60_web # noqa: E402 @_r60_web.middleware async def response_contract_guard(request, handler): """最内层响应契约守卫:救援「返回函数」的处理器,并把契约破坏归因为 500。""" try: resp = await handler(request) except Exception: # noqa: BLE001 # 处理器自身异常:原样上抛,交给既有的 500/业务状态层(不在此改写语义) raise if _is_response(resp): return resp if resp is None: return _fail(request, "handler_returned_none", {"repr": "None"}) if callable(resp): ident = _handler_identity(resp) # ① 救援:把返回的函数当作真实处理器调用一次 try: rescued = resp(request) if inspect.isawaitable(rescued): rescued = await rescued except Exception as exc: # noqa: BLE001 return _fail(request, "handler_returned_callable_rescue_failed", ident, exc) if _is_response(rescued): log.warning("[response_contract] rescued callable handler %s -> %s", ident.get("qualname", "-"), getattr(rescued, "status", "?")) return rescued return _fail(request, "handler_returned_callable_rescue_nonresponse", ident, "type=%r" % (type(rescued),)) return _fail(request, "handler_returned_nonresponse", {"repr": repr(resp)[:300], "type": type(resp).__name__}) def install(app): """把守卫装到 app.middlewares 的**最后一位(最内层)**。幂等,永不抛出。""" out = {"ok": False, "revision": REVISION, "names": [], "error": ""} try: mws = getattr(app, "middlewares", None) if mws is None: out["error"] = "app.middlewares unavailable" return out names = [getattr(m, "__name__", repr(m)) for m in mws] if response_contract_guard.__name__ in names: out.update(ok=True, names=names) return out app.middlewares.append(response_contract_guard) out.update(ok=True, names=[getattr(m, "__name__", repr(m)) for m in app.middlewares]) return out except Exception as exc: # noqa: BLE001 out["error"] = "%s: %s" % (type(exc).__name__, exc) log.error("[response_contract] install failed: %s", out["error"]) return out