#!/usr/bin/env python3 # -*- coding: utf-8 -*- """aiohttp 中间件链「签名归一 + 响应契约」模块(Bug rvtGi1mlUsTwQrXnybZj6 / R60 正解)。 问题(部署态实测,证据 docs/04-deploy/evidence_r58/) ---------------------------------------------------- `POST /rbac/user/up_login.dspy` 恒返回 `401 {"error": "auth_unavailable"}`、无 `Set-Cookie`; `logs/scense.log:1735-1737` 三条原文: [auth_strict] 闸门异常但为公开路径,放行: /rbac/user/up_login.dspy [auth_strict] 拒绝匿名/无效会话请求: POST /rbac/user/up_login.dspy reason=auth_unavailable [auth_strict] 闸门异常: Expect response, not `Expect response, not ` = **链上某层把「未 await 的 handler 函数本体」 当成 Response 交回上游**。成因是 13 层中间件里新式 `async def (request, handler)` 与旧式工厂 `def (app, handler) -> handler` 混排,链构建器按新式语义调用旧式工厂时拿到的就是 handler。 真实故障被 `auth_gate_strict` 的 fail-closed `except` 降级成 401,于是「登录端点自己 401」, 形成 "先登录才能登录" 的自锁。 为什么不能靠 `__middleware_version__` 标记(R55 已实测失败,勿重试) -------------------------------------------------------------------- aiohttp>=3.9 用 `getattr(m, "__middleware_version__", None) == 1` 区分新式/旧式。 但标记只能由 `web.middleware` 装饰器写在**函数对象**上;链上还可能存在 `functools.partial` / 可调用实例 / 已冻结 FrozenList 中的对象,setattr 静默失败, R55 目标机实测 `new_style_marked=[]`(标记一个没生效)。**标记不可依赖**。 本模块的正解 ------------ 不再猜协议、不再改原对象,而是把**每一个**中间件包成同一种适配器: * 适配器自身是 `async def (request, handler)`,且带 `__middleware_version__ = 1` (属性写在适配器函数对象上,一定成功——不是去改别人的对象); * 适配器**按返回值自适应**:awaitable → 新式,直接 await;callable → 旧式工厂, 继续以其返回值收口(旧式 `(app, handler)` 用 `request.app` 作为 app 传入); * 出口做**响应契约校验**:最终必须是非 callable 的 Response 类对象,否则抛 `ResponseContractError`(含层名 + 原始类型)。这样「把 handler 当 Response 返回」 会立刻变成一条带层名的明确 500,而不是被下游闸门静默改写成 401。 用法(`app/scense.py::_load_semantic_middlewares()` 的调用点) -------------------------------------------------------------- from mw_signature import normalize_chain mws = normalize_chain(_load_semantic_middlewares()) # 幂等 # app.middlewares 已就位时(钩子期兜底,可选): from mw_signature import normalize_app_middlewares normalize_app_middlewares(app) 不依赖 aiohttp 的导入(本地无 aiohttp 的可测性):只在需要时探 `aiohttp.web` 判断 Response 类型,缺失时退化为鸭子类型(`hasattr(resp, "status")` 且非 callable), 因此单元自测可在无 aiohttp 环境跑(见 scripts/selftest_mw_signature.py)。 """ import inspect import logging log = logging.getLogger("yuanjing.mw_signature") #: aiohttp 判定新式中间件用的属性名(只写在我们自己的适配器上)。 NEW_STYLE_ATTR = "__middleware_version__" #: 适配器幂等标记(同一对象重复 normalize 不再套娃)。 ADAPTER_MARK = "__mw_signature_adapter__" #: 旧式工厂的第一形参惯用名。命中即按「工厂 (app, handler) -> handler」调用。 LEGACY_FIRST_PARAMS = frozenset(("app", "application", "_app", "ahapp")) try: # pragma: no cover - 目标机必有 aiohttp,本机可能没有 from aiohttp import web as _aiohttp_web _RESPONSE_BASES = (_aiohttp_web.StreamResponse,) except Exception: # noqa: BLE001 _aiohttp_web = None _RESPONSE_BASES = () class ResponseContractError(RuntimeError): """中间件把非 Response(典型:handler 函数本体)返回给了上游。 这不是 401 语义,是**装配/契约缺陷**:必须 500 + traceback,禁止降级成鉴权失败。 """ def describe(mw): """给中间件一个可读名字(日志/诊断用,永不抛)。""" for attr in ("__qualname__", "__name__"): val = getattr(mw, attr, None) if isinstance(val, str) and val: mod = getattr(mw, "__module__", "") or "" return ("%s.%s" % (mod, val)) if mod else val return type(mw).__name__ def is_new_style(mw): """是否已被标记为新式 `(request, handler)` 中间件。""" return getattr(mw, NEW_STYLE_ATTR, None) == 1 def is_adapter(mw): return bool(getattr(mw, ADAPTER_MARK, False)) def _first_param_name(fn): try: params = list(inspect.signature(fn).parameters.values()) except (TypeError, ValueError): return None for p in params: if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD): return p.name.lower() return None def _looks_like_response(obj): """Response 判定:优先 aiohttp 真实类型,无 aiohttp 时鸭子类型。""" if obj is None or callable(obj): return False if _RESPONSE_BASES and isinstance(obj, _RESPONSE_BASES): return True # 鸭子类型:StreamResponse 家族都有 status + headers return hasattr(obj, "status") and hasattr(obj, "headers") and not inspect.iscoroutine(obj) def _contract_check(result, mw, request): """出口响应契约校验:非 Response → ResponseContractError(带层名,可定位)。""" if _looks_like_response(result): return result path = getattr(request, "path", "-") kind = type(result).__name__ if callable(result): detail = ("中间件返回了可调用对象(%s),而不是 Response —— " "典型的旧式工厂被按新式调用 / handler 未 await 直接返回" % kind) elif result is None: detail = "中间件返回 None(漏写 return 或 return 写在 async with 块内)" else: detail = "中间件返回了非 Response 对象(%s)" % kind raise ResponseContractError( "Expect response, not %r: layer=%s path=%s: %s" % (kind, describe(mw), path, detail)) def _call_legacy_factory(factory, request, handler): """旧式工厂:(app, handler) -> 新 handler。app 取 request.app(取不到给 None)。""" app = getattr(request, "app", None) return factory(app, handler) async def _invoke(mw, request, handler): """按返回值自适应地执行一层中间件,并做响应契约校验。""" first = _first_param_name(mw) if first in LEGACY_FIRST_PARAMS: result = _call_legacy_factory(mw, request, handler) else: result = mw(request, handler) if inspect.isawaitable(result): return _contract_check(await result, mw, request) if callable(result): # 旧式工厂的返回值:新 handler,继续收口一次 inner = result resp = inner(request) if inspect.isawaitable(resp): resp = await resp return _contract_check(resp, mw, request) return _contract_check(result, mw, request) def adapt(mw): """把单个中间件包成统一的新式适配器(幂等;已是适配器则原样返回)。""" if is_adapter(mw): return mw layer = describe(mw) async def _adapter(request, handler): try: return await _invoke(mw, request, handler) except ResponseContractError: # 契约错误原样上抛(带层名),由上层/闸门转 500 —— 绝不改写成 401 raise except Exception as exc: # noqa: BLE001 log.exception("[mw-signature] 层内部异常 layer=%s path=%s: %s: %s", layer, getattr(request, "path", "-"), type(exc).__name__, exc) raise try: _adapter.__name__ = "%s__mw_sig" % getattr(mw, "__name__", type(mw).__name__) _adapter.__qualname__ = _adapter.__name__ _adapter.__doc__ = "mw_signature adapter of %s" % layer except Exception: # noqa: BLE001 pass setattr(_adapter, NEW_STYLE_ATTR, 1) setattr(_adapter, ADAPTER_MARK, True) setattr(_adapter, "__mw_signature_of__", layer) return _adapter def normalize_chain(middlewares): """归一整个中间件列表(顺序不变:列表首位 = 最外层)。 :param middlewares: 可迭代中间件 :return: `ChainResult`(list 子类,可直接 `app = web.Application(middlewares=normalize_chain(mws))`)。 报告从属性取:`res = normalize_chain(mws); res.report`。 [fix rvtGi1mlUsTwQrXnybZj6 / R61] 旧的 docstring 声称支持 `mws, report = normalize_chain(x)` 两值解包 —— **实际不支持**:ChainResult 是 list 子类,两值解包会按元素迭代,抛 `ValueError: too many values to unpack` (R61 本地 e2e 实测命中)。这里改正契约说明,避免调用方按错误文档取报告。 """ out = [] table = [] for pos, mw in enumerate(list(middlewares or ())): layer = describe(mw) old_style = _first_param_name(mw) in LEGACY_FIRST_PARAMS marked = is_new_style(mw) wrapped = adapt(mw) out.append(wrapped) table.append({ "pos": pos, "layer": layer, "new_style_marked": marked, "legacy_factory": old_style, "adapter": describe(wrapped), }) report = { "count": len(out), "legacy_factory_layers": [e["layer"] for e in table if e["legacy_factory"]], "unmarked_layers": [e["layer"] for e in table if not e["new_style_marked"]], "all_adapter_verified": all(is_new_style(m) for m in out), "table": table, } if report["legacy_factory_layers"] or report["unmarked_layers"]: log.warning("[mw-signature] 链签名归一:%d 层;旧式工厂=%s;原未标记=%s;全部适配器新式=%s", report["count"], report["legacy_factory_layers"], report["unmarked_layers"], report["all_adapter_verified"]) else: log.info("[mw-signature] 链签名归一:%d 层,全部已是新式并完成适配器包裹", report["count"]) return ChainResult(out, report) class ChainResult(list): """兼容 `mws = normalize_chain(x)` 直接赋值用法:本体就是中间件列表。""" def __init__(self, items, report): super().__init__(items) self.report = report def normalize_app_middlewares(app): """把 `app.middlewares` 就地换成一整串适配器(含子应用),幂等。 返回 report dict。FrozenList 已冻结时依次尝试 `_list[i]` / `_middlewares` 回写; 全部失败则记 ERROR(该层仍按原样进链,但下一次 normalize 会再试)。 """ fixed = [] def _fix(application): mws = getattr(application, "middlewares", None) if not mws: return for i, m in enumerate(list(mws)): nm = adapt(m) if nm is m: continue written = False for target in (mws, getattr(mws, "_list", None)): if target is None: continue try: target[i] = nm written = True break except Exception: # noqa: BLE001 continue if written: fixed.append(describe(m)) else: log.error("[mw-signature] 回写 app.middlewares 失败 pos=%s layer=%s", i, describe(m)) _fix(app) for sub in list(getattr(app, "_subapps", None) or ()): _fix(sub) report = {"normalized": fixed, "count": len(fixed)} if fixed: log.info("[mw-signature] app.middlewares 归一 %d 层: %s", len(fixed), fixed) return report def signature_table(middlewares): """纯诊断:13 层中间件的签名形态表(写进 /healthz 诊断或启动日志)。""" table = [] for pos, mw in enumerate(list(middlewares or ())): table.append({ "pos": pos, "layer": describe(mw), "new_style_marked": is_new_style(mw), "adapted": is_adapter(mw), "legacy_factory": _first_param_name(mw) in LEGACY_FIRST_PARAMS, }) return table __all__ = [ "NEW_STYLE_ATTR", "ResponseContractError", "ChainResult", "adapt", "normalize_chain", "normalize_app_middlewares", "signature_table", "is_new_style", "is_adapter", "describe", ]