aps-agent/server/integrations/mes_http.py

382 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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