aps-agent/server/integrations/mes_http.py

382 lines
16 KiB
Python
Raw Normal View History

# ============================================================
# HTTP MES 适配器框架(moduleId: integ-mes-http, 可复用 ✔)
# round-45 方向 II(矩阵 73/75):config-driven HTTP MES 适配器
# - 鉴权:MES_HTTP_TOKEN + MES_HTTP_TOKEN_HEADER(默认 Authorization: Bearer)
# - 幂等:idemKey 头(X-Idem-Key,可配 MES_HTTP_IDEM_HEADER)或 body.idemKey
# - 超时:MES_HTTP_TIMEOUT_SECONDS(默认 10s)
# - 重试:连接失败/超时/5xx/429 指数退避(MES_HTTP_MAX_RETRIES,默认 2)
# - 状态/报工回流:GET /work-orders/{id} 轮询 + POST /work-orders/{id}/reports 回执
# - 失败语义:MesHttpError{code,status_code} 显式抛错(供 round-39 saga 补偿识别);
# 未配置 MES_HTTP_BASE_URL → fail-closed(MES_HTTP_NOT_CONFIGURED)
# 与 MockMesClient 同接口(status/create_work_order/post_report/cancel_work_order),
# server/aps_domain/mes.py 按配置切换客户端(stub 默认,HTTP 显式启用)。
# ============================================================
# round-46 方向 LL(矩阵 73 现场对接准备):readiness() 配置就绪检查
# - 稳定 JSON 字段 {configured, baseUrl, tokenPresent, timeoutSec, maxRetries,
# connectivity, lastError, message};未配置 fail-closed 明确提示(网关 200 不 500)
# - readiness(probe=True) 轻量 GET /health(短超时 ≤3s),失败记录 lastError 不抛
from __future__ import annotations
import os
import time
from typing import Any
from urllib.parse import quote
import httpx
ENV_BASE_URL = "MES_HTTP_BASE_URL"
ENV_TOKEN = "MES_HTTP_TOKEN"
ENV_TOKEN_HEADER = "MES_HTTP_TOKEN_HEADER"
ENV_TIMEOUT = "MES_HTTP_TIMEOUT_SECONDS"
ENV_MAX_RETRIES = "MES_HTTP_MAX_RETRIES"
ENV_IDEM_HEADER = "MES_HTTP_IDEM_HEADER"
_DEFAULT_TOKEN_HEADER = "Authorization"
_DEFAULT_IDEM_HEADER = "X-Idem-Key"
_DEFAULT_TIMEOUT_SEC = 10.0
_DEFAULT_MAX_RETRIES = 2
_PROBE_TIMEOUT_SEC = 3.0 # 连通探测短超时(readiness probe)
_RETRY_BACKOFF_BASE = 0.25 # 指数退避:0.25/0.5/1.0/... 秒
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
class MesHttpError(Exception):
"""显式失败语义:code + message + status_code(供 saga 补偿识别)。"""
def __init__(self, code: str, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
def __str__(self) -> str:
base = f"[{self.code}] {self.message}"
if self.status_code is not None:
base += f" (HTTP {self.status_code})"
return base
class HttpMesClient:
"""真实 MES HTTP 适配器:鉴权/幂等/超时/重试/回执(config-driven)。"""
def __init__(
self,
*,
base_url: str | None = None,
token: str | None = None,
token_header: str | None = None,
timeout_sec: float | None = None,
max_retries: int | None = None,
idem_header: str | None = None,
transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = (
base_url if base_url is not None else os.environ.get(ENV_BASE_URL) or ""
).rstrip("/")
self.token = token if token is not None else os.environ.get(ENV_TOKEN) or ""
self.token_header = (
token_header or os.environ.get(ENV_TOKEN_HEADER) or _DEFAULT_TOKEN_HEADER
).strip()
try:
self.timeout_sec = float(
timeout_sec if timeout_sec is not None
else os.environ.get(ENV_TIMEOUT) or _DEFAULT_TIMEOUT_SEC
)
except ValueError as exc:
raise MesHttpError("MES_HTTP_CONFIG_INVALID", f"{ENV_TIMEOUT} 配置无效", 503) from exc
if self.timeout_sec <= 0:
raise MesHttpError("MES_HTTP_CONFIG_INVALID", f"{ENV_TIMEOUT} 必须大于 0", 503)
raw_retries = max_retries if max_retries is not None else os.environ.get(ENV_MAX_RETRIES)
try:
self.max_retries = max(0, int(raw_retries or _DEFAULT_MAX_RETRIES))
except ValueError as exc:
raise MesHttpError("MES_HTTP_CONFIG_INVALID", f"{ENV_MAX_RETRIES} 配置无效", 503) from exc
self.idem_header = (
idem_header or os.environ.get(ENV_IDEM_HEADER) or _DEFAULT_IDEM_HEADER
).strip()
self.transport = transport
# ---------------- 配置 ----------------
@property
def configured(self) -> bool:
return bool(self.base_url)
def _ensure_configured(self) -> None:
if not self.base_url:
raise MesHttpError(
"MES_HTTP_NOT_CONFIGURED",
f"未配置 {ENV_BASE_URL}:HTTP MES 适配器不可用(fail-closed)。"
"请设置环境变量或传入 base_url 指向真实 MES / 本地 stub。",
503,
)
def _auth_headers(self) -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if self.token:
if self.token_header.lower() == "authorization" and not self.token.startswith("Bearer "):
headers[self.token_header] = "Bearer " + self.token
else:
headers[self.token_header] = self.token
return headers
# ---------------- HTTP 原语(超时 + 指数退避重试) ----------------
def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
params: dict[str, str] | None = None,
idem_key: str | None = None,
allow_retry: bool = True,
) -> httpx.Response:
self._ensure_configured()
headers = self._auth_headers()
if idem_key:
headers[self.idem_header] = idem_key
last_error: MesHttpError | None = None
attempt = 0
while True:
try:
with httpx.Client(
base_url=self.base_url,
timeout=self.timeout_sec,
transport=self.transport,
) as client:
resp = client.request(method, path, json=json_body, params=params, headers=headers)
except httpx.TimeoutException:
last_error = MesHttpError(
"MES_HTTP_TIMEOUT",
f"MES 请求超时(>{self.timeout_sec}s):{method} {path}",
504,
)
retryable = True
except httpx.RequestError as exc:
last_error = MesHttpError(
"MES_HTTP_CONNECT_FAILED",
f"无法连接 MES({self.base_url}):{exc}",
502,
)
retryable = True
else:
if resp.status_code < 400:
return resp
last_error = self._error_from_response(resp)
retryable = resp.status_code in _RETRYABLE_STATUS
if not allow_retry or not retryable or attempt >= self.max_retries:
raise last_error
attempt += 1
time.sleep(_RETRY_BACKOFF_BASE * (2 ** (attempt - 1)))
def _error_from_response(self, resp: httpx.Response) -> MesHttpError:
try:
body = resp.json()
except ValueError:
body = {}
message = ""
if isinstance(body, dict):
detail = body.get("detail")
if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("error") or "")
else:
message = str(body.get("message") or body.get("error") or detail or "")
fallback = message or f"MES HTTP {resp.status_code}"
if resp.status_code in (401, 403):
return MesHttpError("MES_HTTP_AUTH_FAILED", f"MES 鉴权失败:{fallback}", resp.status_code)
if resp.status_code == 404:
return MesHttpError("MES_HTTP_NOT_FOUND", f"MES 资源不存在:{fallback}", resp.status_code)
if resp.status_code >= 500 or resp.status_code == 429:
return MesHttpError("MES_HTTP_UPSTREAM_ERROR", f"MES 上游错误:{fallback}", resp.status_code)
return MesHttpError("MES_HTTP_REJECTED", f"MES 拒绝请求:{fallback}", resp.status_code)
@staticmethod
def _json(resp: httpx.Response) -> dict[str, Any]:
try:
data = resp.json()
except ValueError as exc:
raise MesHttpError("MES_HTTP_INVALID_RESPONSE", "MES 返回非 JSON 响应", resp.status_code) from exc
if not isinstance(data, dict):
raise MesHttpError("MES_HTTP_INVALID_RESPONSE", "MES 返回非对象响应", resp.status_code)
return data
@staticmethod
def _wo_path(wo_id: str) -> str:
return "/work-orders/" + quote(str(wo_id), safe="")
# ---------------- 领域方法(与 MockMesClient 同接口) ----------------
def status(self) -> dict[str, Any]:
"""连接状态(供预览/清单展示):失败不抛错,返回 connected=False。"""
if not self.base_url:
return {
"connected": False, "mode": "http", "system": "MES-HTTP",
"detail": f"未配置 {ENV_BASE_URL}",
}
try:
resp = self._request("GET", "/health", allow_retry=False)
body = self._json(resp)
except MesHttpError as exc:
return {
"connected": False, "mode": "http", "system": "MES-HTTP",
"error": exc.code, "detail": exc.message,
}
return {
"connected": True, "mode": "http",
"system": body.get("system") or "MES-HTTP",
"plant": body.get("plant") or "CNWH",
"updatedAt": body.get("updatedAt") or "",
"woCount": int(body.get("woCount") or 0),
"openCount": int(body.get("openCount") or 0),
"reportCount": int(body.get("reportCount") or 0),
}
# ---------------- 对接就绪检查(round-46 方向 LL · 矩阵 73 现场对接准备) ----------------
def readiness(self, *, probe: bool = False) -> dict[str, Any]:
"""现场对接就绪检查:稳定 JSON 字段,不抛错(网关可直接转发给前端)。
- 未配置 → configured=False + 中文提示(fail-closed;网关 200 不 500)。
- 已配置未探测 → connectivity="unknown"。
- probe=True → 对 base_url 发轻量 GET /health(短超时 ≤3s),
成功 connectivity="ok",失败 connectivity="failed" 且 lastError 记录错误(不抛)。
"""
result: dict[str, Any] = {
"configured": self.configured,
"baseUrl": self.base_url or None,
"tokenPresent": bool(self.token),
"timeoutSec": self.timeout_sec,
"maxRetries": self.max_retries,
"connectivity": None, # "unknown" | "ok" | "failed" | null
"lastError": None,
"message": "",
}
if not self.configured:
result["message"] = (
f"未配置 {ENV_BASE_URL}:HTTP MES 适配器不可用(fail-closed)。"
"请设置环境变量或传入 base_url 指向真实 MES / 本地 stub。"
)
return result
if not probe:
result["connectivity"] = "unknown"
result["message"] = (
f"已配置 {ENV_BASE_URL},尚未探测连通性;"
"可调用 POST /api/integrations/mes/readiness/probe 触发轻量探测。"
)
return result
ok, error = self._probe_health()
if ok:
result["connectivity"] = "ok"
result["message"] = "连通正常"
else:
result["connectivity"] = "failed"
result["lastError"] = {
"code": error.code,
"message": error.message,
"statusCode": error.status_code,
}
result["message"] = f"连通失败:{error.message}"
return result
def _probe_health(self) -> tuple[bool, MesHttpError | None]:
"""轻量连通探测:GET /health,短超时(≤3s),失败返回 (False, error) 不抛出。"""
timeout = min(self.timeout_sec, _PROBE_TIMEOUT_SEC)
try:
with httpx.Client(
base_url=self.base_url,
timeout=timeout,
transport=self.transport,
) as client:
resp = client.get("/health", headers=self._auth_headers())
except httpx.ConnectTimeout:
# 连接阶段超时(拒连/防火墙丢包等)→ 判为连接失败(502),而非上游超时
return False, MesHttpError(
"MES_HTTP_CONNECT_FAILED",
f"无法连接 MES({self.base_url}):连接超时(>{timeout}s)",
502,
)
except httpx.TimeoutException:
return False, MesHttpError(
"MES_HTTP_TIMEOUT",
f"MES 连通探测超时(>{timeout}s)",
504,
)
except httpx.RequestError as exc:
return False, MesHttpError(
"MES_HTTP_CONNECT_FAILED",
f"无法连接 MES({self.base_url}):{exc}",
502,
)
if resp.status_code >= 400:
return False, self._error_from_response(resp)
return True, None
def create_work_order(self, payload: dict[str, Any], idem_key: str) -> dict[str, Any]:
"""幂等下发:idemKey 头/body 携带;重复 idemKey → duplicate=True(不重复创建)。"""
if not idem_key:
raise MesHttpError("MES_HTTP_REJECTED", "下发缺少 idemKey(幂等键)", 400)
body = dict(payload)
body.setdefault("idemKey", idem_key)
resp = self._request("POST", "/work-orders", json_body=body, idem_key=idem_key)
data = self._json(resp)
if "externalWo" not in data:
if "id" in data:
data = {"duplicate": False, "externalWo": data}
else:
raise MesHttpError(
"MES_HTTP_INVALID_RESPONSE",
"MES 下发响应缺少 externalWo", resp.status_code,
)
return data
def post_report(self, external_wo_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""报工回执:POST 进度/数量/状态回流到真实 MES。"""
resp = self._request(
"POST", self._wo_path(external_wo_id) + "/reports",
json_body=payload,
)
data = self._json(resp)
if "report" not in data:
raise MesHttpError("MES_HTTP_INVALID_RESPONSE", "MES 报工回执缺少 report", resp.status_code)
return data
def fetch_work_order(self, external_wo_id: str) -> dict[str, Any]:
"""状态查询:GET 轮询外部工单。"""
resp = self._request("GET", self._wo_path(external_wo_id))
data = self._json(resp)
if "id" not in data:
raise MesHttpError("MES_HTTP_INVALID_RESPONSE", "MES 状态查询缺少 id", resp.status_code)
return data
def cancel_work_order(
self,
external_wo_id: str,
*,
reason: str = "saga-compensation",
) -> dict[str, Any]:
"""幂等撤单(saga 补偿):重复撤单 → duplicate=True。"""
resp = self._request(
"POST", self._wo_path(external_wo_id) + "/cancel",
json_body={"reason": reason},
idem_key="cancel:" + str(external_wo_id),
)
data = self._json(resp)
if "externalWo" not in data:
raise MesHttpError("MES_HTTP_INVALID_RESPONSE", "MES 撤单响应缺少 externalWo", resp.status_code)
return data
_client: HttpMesClient | None = None
def get_http_mes_client() -> HttpMesClient:
"""进程内单例(按环境变量构建);测试/热切换用 reset_http_mes_client 重建。"""
global _client
if _client is None:
_client = HttpMesClient()
return _client
def reset_http_mes_client(**kwargs: Any) -> HttpMesClient:
"""测试/配置热切换:重建单例(kwargs 透传 HttpMesClient 构造参数)。"""
global _client
_client = HttpMesClient(**kwargs)
return _client