#!/usr/bin/env python3 # -*- coding: utf-8 -*- """中间件链**签名契约**层(R48 根因修复)。 根因(R48 实测 2026-10-26 06:43,evidence/retest-r48-probe-20261026-064308.log) ---------------------------------------------------------------------------- 部署态全站 500(`500 Internal Server Error / Server got itself in trouble`), A/B/C 三族(/healthz 方法语义、匿名读鉴权、缺参写状态行)全部被遮蔽。 调用链: app/get_only_405.py (healthz 兜底 / 链最外层) -> 内层链 -> pkgs/ahserver/ahserver/real_ip.py:16 request['client_ip'] = request.remote -> AttributeError: 'AHApp' object has no attribute 'remote' `request` 位置上出现的是 **AHApp(应用对象)**,不是 `web.Request`。这不是 real_ip 写错,而是**中间件注册/装配的签名契约不匹配**: aiohttp 的 `Application` 用 `__middleware_version__` 属性区分两种中间件风格—— * **new-style**:`@web.middleware` 装饰过的 `async def mw(request, handler)`, 装饰器写入 `__middleware_version__ = 1`; * **legacy-style**:**没有**该标记的可调用对象,被当作「工厂」调用 `mw(app)`。 因此把一个**裸** `async def mw(request, handler)`(忘记 `@web.middleware`)塞进 `app.middlewares` / 构造期 `mws` 列表,aiohttp 会按 legacy 工厂路径用 **app** 去填 第一个形参 —— 中间件眼里的 `request` 变成 `AHApp`,`request.remote` 立刻 AttributeError;同时内层链可能返回**未被 await 的 coroutine/function**,产生 `Expect response, not `(R21 历史日志原文)。 本仓库的暴露点(本轮机械核对,全部是「裸函数进链」): app/get_only_405.py :: method_gate_middleware (无 @web.middleware) app/auth_route_gate.py :: auth_route_gate_middleware(无 @web.middleware) 且 install() 直接 insert 裸函数 另有已正确装饰但仍需契约兜底的:`route_guard_middleware.py`、 `business_status_middleware.py`(它们的注册点分布在多条路径上,任一处漏装饰 就会复现同类 500)。 修复策略(本文件,确定性、可自测) ---------------------------------- 1. `ensure_middleware(mw)` —— 幂等地把任意中间件规范成 new-style(补 `__middleware_version__ = 1`),保留 `__name__`;非可调用/签名不符则拒绝。 2. `assert_middleware_contract(seq)` —— 返回违规清单(供 /healthz 诊断与 selftest)。 3. `normalize_chain(seq)` —— 就地规范化中间件序列,返回诊断 dict(不静默失败: 不可修复项写进 `rejected`)。 4. `safe_bind(mw, handler)` —— 构造 chain 时把 handler 绑到**第二个**形参位 (`mw(request, handler)`),**禁止** `partial(mw, handler)` 这类把 handler 绑进 第一个(request)位的写法;并校验返回值必须是 `web.StreamResponse`,否则抛 `MiddlewareContractError`(不产生 "Expect response, not function" 的隐晦 500)。 5. `install_chain_contract(app)` —— 构造期/装配后统一规范化 `app._middlewares` (或 `app.middlewares`),幂等,永不抛出(返回诊断)。 6. `selftest()` —— 进程内 aiohttp 应用自测(/healthz GET/HEAD/POST/PUT/DELETE + 受保护 .dspy 匿名/带 cookie 两口径 + 裸中间件回归),见 `tests/test_middleware_chain_contract.py`。 设计约束 -------- * 零应用内依赖(可被 scense.py / yuanjing.py 以 `import middleware_chain_contract` 或 `from app import ...` 两种路径导入,不触发包内其他模块的副作用)。 * 一切「不可修复」都进诊断,不 `except: pass`。 """ import inspect import logging log = logging.getLogger("yuanjing.middleware_chain_contract") #: 契约修订号(/healthz 与 selftest 断言用;改动契约语义必须递增) CONTRACT_REVISION = "r52-chain-contract-1" #: aiohttp new-style 中间件标记属性 MARKER = "__middleware_version__" #: aiohttp 要求中间件的形参个数(request, handler) ARITY = 2 class MiddlewareContractError(RuntimeError): """中间件不符合 `(request, handler)` 签名契约,或返回了非响应对象。""" # ---------------------------------------------------------------- 判定 def _aiohttp_web(): """取 aiohttp.web(取不到返回 None,调用方必须降级而不能静默成功)。""" try: from aiohttp import web as _w # noqa: PLC0415 return _w except Exception: # noqa: BLE001 return None def has_marker(mw): """该中间件是否已带 new-style 标记(aiohttp 才不会再按 legacy 工厂调用)。""" return getattr(mw, MARKER, None) == 1 def positional_arity(fn): """可调用对象接受的位置参数个数;无法判定时返回 -1。""" try: sig = inspect.signature(fn) except (TypeError, ValueError): return -1 return len([ p for p in sig.parameters.values() if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) ]) def is_middleware_shaped(mw): """形如 `(request, handler)` 的协程函数(或带 1 个位置的 legacy 工厂)。""" if not callable(mw): return False n = positional_arity(mw) if n == ARITY: return True # legacy 工厂:def factory(app) -> middleware;本轮只告警,不当作本应用风格 return False def contract_violations(mw): """返回单项违规描述列表(空列表 = 合规)。""" out = [] if not callable(mw): out.append("not callable: %r" % (mw,)) return out if not is_middleware_shaped(mw): out.append("signature != (request, handler): %s%s" % (getattr(mw, "__name__", repr(mw)), kind_suffix(mw))) if not has_marker(mw): out.append("missing %s (aiohttp 会按 legacy 工厂 mw(app) 调用 → " "'AHApp' object has no attribute 'remote'): %s" % (MARKER, getattr(mw, "__name__", repr(mw)))) return out def kind_suffix(mw): try: return " (arity=%d)" % positional_arity(mw) except Exception: # noqa: BLE001 return "" def assert_middleware_contract(seq): """返回 `[{index, name, violations}]`(空列表 = 全部合规)。""" out = [] for i, mw in enumerate(seq or ()): v = contract_violations(mw) if v: out.append({"index": i, "name": getattr(mw, "__name__", repr(mw)), "violations": v}) return out def describe(seq): """中间件名列表(诊断用)。""" return [getattr(m, "__name__", repr(m)) for m in (seq or ())] # ---------------------------------------------------------------- 规范化 def ensure_middleware(mw, name=None): """幂等规范化:返回带 new-style 标记的中间件;不合规抛 MiddlewareContractError。""" if not callable(mw): raise MiddlewareContractError("middleware not callable: %r" % (mw,)) if not is_middleware_shaped(mw): raise MiddlewareContractError( "middleware signature must be (request, handler): %s%s" % (getattr(mw, "__name__", repr(mw)), kind_suffix(mw))) if has_marker(mw): if name: try_set_name(mw, name) return mw w = _aiohttp_web() fixed = mw if w is not None: try: fixed = w.middleware(mw) # aiohttp 官方装饰器,写入标记 except Exception: # noqa: BLE001 fixed = mw if not has_marker(fixed): try: setattr(fixed, MARKER, 1) # 兜底:手工补标记 except Exception as exc: # noqa: BLE001 raise MiddlewareContractError( "cannot mark middleware %s: %s" % (getattr(mw, "__name__", repr(mw)), exc)) try_set_name(fixed, name or getattr(mw, "__name__", None)) return fixed def try_set_name(mw, name): if not name: return mw try: mw.__name__ = name except Exception: # noqa: BLE001 pass return mw def normalize_chain(seq, only_missing_marker=True): """就地规范化中间件序列,返回诊断 dict(幂等)。 :param seq: 可变序列(list)。`app._middlewares` / 构造期 mws 列表。 :param only_missing_marker: True 时只补标记,不改变顺序、不删项。 :return: {revision, total, normalized, rejected, names, ok} """ diag = {"revision": CONTRACT_REVISION, "total": 0, "normalized": [], "rejected": [], "names": [], "ok": True} if seq is None: diag["ok"] = False diag["rejected"].append("chain is None") return diag try: total = len(seq) except Exception: # noqa: BLE001 diag["ok"] = False diag["rejected"].append("chain not a sized sequence") return diag diag["total"] = total for i in range(total): mw = seq[i] name = getattr(mw, "__name__", repr(mw)) if has_marker(mw) and is_middleware_shaped(mw): continue try: seq[i] = ensure_middleware(mw) diag["normalized"].append({"index": i, "name": name}) log.warning("[chain-contract] 第 %d 层中间件 %s 未带 %s,已规范化为 " "new-style(原实现会被 aiohttp 按 legacy 工厂 mw(app) 调用)", i, name, MARKER) except MiddlewareContractError as exc: diag["rejected"].append({"index": i, "name": name, "error": str(exc)}) log.error("[chain-contract] 第 %d 层中间件 %s 不可修复:%s", i, name, exc) diag["names"] = describe(seq) diag["violations"] = assert_middleware_contract(seq) diag["ok"] = not diag["rejected"] and not diag["violations"] return diag # ---------------------------------------------------------------- 绑定与执行 def safe_bind(mw, handler): """把 `handler` 绑到中间件的**第二个**形参位,返回 `chain(request)`。 **禁止** `partial(mw, handler)`:partial 会把 handler 填到第一个形参 (request)位,请求对象反而落到 handler 位 —— 这正是 「`Expect response, not `」与 「`'AHApp' object has no attribute 'remote'`」两类 500 的同源错误绑定。 """ if not callable(mw): raise MiddlewareContractError("middleware not callable: %r" % (mw,)) async def _chain(request): resp = await mw(request, handler) return assert_response(resp, mw, request) _chain.__name__ = "chain(%s)" % getattr(mw, "__name__", "mw") return _chain def assert_response(resp, mw=None, request=None): """中间件必须返回 `web.StreamResponse`;否则抛清晰契约错误。""" w = _aiohttp_web() name = getattr(mw, "__name__", "mw") if mw is not None else "mw" if w is None: return resp if isinstance(resp, w.StreamResponse): return resp if inspect.isawaitable(resp): raise MiddlewareContractError( "%s returned an awaitable (%r) —— 中间件漏 await" % (name, type(resp).__name__)) raise MiddlewareContractError( "Expect response, not %r from %s (path=%s)" % (type(resp).__name__, name, getattr(request, "path", "") or "?")) def build_chain(middlewares, handler): """按 `[最外层 ... 最内层]` 顺序组链,返回 `chain(request)`;全程契约校验。""" chain = handler for mw in reversed(list(middlewares or ())): chain = safe_bind(ensure_middleware(mw), chain) return chain # ---------------------------------------------------------------- 装配钩子 def _mw_list(app): """取 app 的中间件列表(aiohttp 版本差异:`middlewares` / `_middlewares`)。""" mws = getattr(app, "_middlewares", None) if mws is not None: return mws, "_middlewares" mws = getattr(app, "middlewares", None) if mws is not None: return mws, "middlewares" return None, None def install_chain_contract(app, middlewares=None): """构造期/装配后统一规范化中间件链签名契约(幂等,永不抛出)。 :param app: AHApp / aiohttp Application 实例(可为 None) :param middlewares: 构造期 mws 列表(在 `make_app()` 之前先规范化) :return: 诊断 dict(含 revision / normalized / rejected / names / ok) """ diag = {"revision": CONTRACT_REVISION, "ok": True, "sources": [], "normalized": [], "rejected": [], "names": []} try: if middlewares is not None: d = normalize_chain(middlewares) diag["sources"].append("ctor-arg") diag["normalized"] += d.get("normalized", []) diag["rejected"] += d.get("rejected", []) diag["ok"] = diag["ok"] and d.get("ok", True) diag["names"] = d.get("names", []) except Exception as exc: # noqa: BLE001 diag["ok"] = False diag["rejected"].append({"source": "ctor-arg", "error": "%s: %s" % (type(exc).__name__, exc)}) try: if app is not None: mws, src = _mw_list(app) if mws is None: diag["ok"] = False diag["rejected"].append({"source": "app", "error": "no middleware list"}) else: d = normalize_chain(mws) diag["sources"].append(src) diag["normalized"] += d.get("normalized", []) diag["rejected"] += d.get("rejected", []) diag["ok"] = diag["ok"] and d.get("ok", True) diag["names"] = d.get("names", []) except Exception as exc: # noqa: BLE001 diag["ok"] = False diag["rejected"].append({"source": "app", "error": "%s: %s" % (type(exc).__name__, exc)}) if not diag["ok"]: log.error("[chain-contract] 装配核验未通过 rejected=%s", diag["rejected"]) else: log.info("[chain-contract] 装配核验通过 rev=%s sources=%s names=%s", CONTRACT_REVISION, diag["sources"], diag["names"]) return diag