添加监控接口
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
|
||||
| [最近一周调用统计_20260915-20260921.md](最近一周调用统计_20260915-20260921.md) | 2026-09-15~09-21 网关调用量、接口构成、成功率。 |
|
||||
| [20260919-20_接口2空图失败统计.md](20260919-20_接口2空图失败统计.md) | 9/19~9/20 接口2 `code=0` 但无生发图(GPU 内部错误,跨午夜连续故障)。 |
|
||||
| [监控探活接口.md](监控探活接口.md) | 外部监控用的 `GET /monitor`:有在线 worker 返回 200,全部下线返回 503。 |
|
||||
|
||||
> 原先分散的「各接口技术方案 / 开发任务书 / 系统架构 / 网关任务书」已合并进 `实现说明.md`
|
||||
> (细节可查 git 历史)。
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
可选 `primary_workers`(`workers` 的子集)启用主备分组:主 worker 全部下线时才派发备用 worker;
|
||||
主组并行度由 `dispatch.max_global_concurrency` 控制(N 台主 worker 就设 N,依赖单进程 uvicorn)。
|
||||
修改配置需重启 `hair-gateway.service` 生效(配置启动时加载一次)。
|
||||
- 外部监控探活是 `GET /monitor`(未演练时有在线 worker 才 200,否则 503)。`POST /monitor/fault` 只改这条探活的返回,用来做报警演练,不摘 worker。`/health` 只表示网关进程存活,不能用来报警。详见 [`监控探活接口.md`](监控探活接口.md)。
|
||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||
|
||||
---
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
# 监控探活接口
|
||||
|
||||
外网监控机器用这个接口判断**当前对外服务能不能接业务**。不正常就报警。监控侧每 30 秒调用一次,网关不做主动推送。
|
||||
|
||||
对外地址:
|
||||
|
||||
```text
|
||||
GET https://hair.xiangsilian.com/monitor
|
||||
```
|
||||
|
||||
本机等价地址是 `GET http://127.0.0.1:8080/monitor`。nginx 对 `hair.xiangsilian.com` 的 `location /` 整段反代到网关 8080,所以这条路径不用单独改 nginx。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么不复用原来的 `/health`
|
||||
|
||||
网关上原来就有两个探活,都不能直接拿来报警:
|
||||
|
||||
| 路径 | 谁在用 | HTTP 状态 | 实际含义 |
|
||||
|------|--------|-----------|----------|
|
||||
| `GET /health` | 旧路径,和下面这条相同 | 永远 200 | 网关进程还活着 |
|
||||
| `GET /gateway-health` | 人看池子概况 | 永远 200 | 同上,body 里带 worker 计数 |
|
||||
| worker 的 `GET /health` | 网关自己每 8 秒打各台 GPU 机 | 200 或 503 | 那一台 worker 自己是否就绪 |
|
||||
|
||||
`/health` 和 `/gateway-health` 只要 uvicorn 进程在,就返回 `"status": "ok"`。五台 worker 全部下线时,HTTP 仍然是 200,只是 `workers_healthy` 变成 0。只看状态码的监控不会报警。
|
||||
|
||||
这次加的 `/monitor` 专供外部监控:
|
||||
|
||||
- 至少一台 worker 处于在线(`online`)→ **HTTP 200**
|
||||
- 一台在线的都没有 → **HTTP 503**
|
||||
- 报警演练打开时,即使还有在线 worker,也返回 **HTTP 503**(见第 9 节)
|
||||
|
||||
监控只需要判断状态码。200 正常,非 200(503、连接失败、超时)报警。
|
||||
|
||||
---
|
||||
|
||||
## 2. 调用约定
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| 方法 | `GET` |
|
||||
| 路径 | `/monitor` |
|
||||
| 鉴权 | 不需要。不校验 `X-Internal-Token` |
|
||||
| 请求体 | 无 |
|
||||
| 建议间隔 | 30 秒 |
|
||||
| 超时 | 建议 5 秒。接口本身只读内存,正常几十毫秒内返回 |
|
||||
| 请求日志 | 不记录。网关请求日志只记 `/api/`,这条不会进 `request_log.jsonl`,也不会出现在 `/admin/stats` |
|
||||
|
||||
30 秒一次不会给 worker 增加探测。`/monitor` 不访问 GPU 机,只读网关内存里已经维护好的池状态。真正打 worker `/health` 的是网关后台循环,间隔见 `gateway/config.json` 的 `health_check.interval_seconds`(当前 8 秒)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 怎样算「正常」
|
||||
|
||||
正常的定义是:**网关当前还能把业务请求派到至少一台 worker**。
|
||||
|
||||
池里每台 worker 有一个 `online` 标志。`workers_healthy` 就是 `online == true` 的台数。这个标志由后台健康检查维护,规则和派发用的是同一份状态:
|
||||
|
||||
1. 启动时立刻对配置里的每台 worker 打一次 `GET {worker}/health`(带 `X-Internal-Token`,超时 `health_check.timeout_seconds`,当前 3 秒)。返回 200 且 `healthy_threshold <= 1`(当前就是 1)则标记在线。
|
||||
2. 之后每 8 秒再探一轮。
|
||||
3. 连续失败达到 `unhealthy_threshold`(当前 2 次)才从在线改成下线。单次抖动不会下线。
|
||||
4. 业务转发失败(连不上、超时、HTTP ≥ 500、401)会立刻把那台 worker 标下线,并进入隔离期 `cooldown_seconds`(当前 60 秒)。隔离期内即使 `/health` 恢复 200 也不重新上线。
|
||||
5. 隔离期结束后,再有一次探测 200 才重新上线。
|
||||
|
||||
因此 `/monitor` 看到的「在线」,就是下一笔生发/测量请求实际可以派过去的机器,不是「进程还在」这么宽的概念。
|
||||
|
||||
### 会返回 200 的情况
|
||||
|
||||
- 主 worker 里还有任意一台在线。当前配置有 3 台主 worker。
|
||||
- 主 worker 全挂,但备用 worker 还有在线的。主备分组下,主组全下线之后流量会打到备用,服务仍然能接,所以不报警。body 里 `workers_primary_healthy` 会是 0,需要看主组是否掉光时看这个字段,不要用状态码。
|
||||
- 在线的 worker 全部正在忙。忙只说明队列里有请求,机器没有被摘掉。等某台 worker 空闲最多 `queue_wait_seconds`(当前 30 秒);全局并发槽排满时最多再等 `max_queue_wait_seconds`(当前 590 秒)。这不是宕机。
|
||||
|
||||
### 会返回 503 的情况
|
||||
|
||||
- 配置里的 worker 全部不在线。包括:探测连续失败被摘除、转发失败进入隔离期、启动后第一轮探测就没有一台返回 200。
|
||||
- 池还没初始化好(进程刚起来、`_workers` 仍为空)。这种情况窗口很短,发生在 uvicorn 完成启动之前;启动完成前端口还不会接受连接,监控更多会看到连接失败,而不是这条 503。
|
||||
|
||||
网关进程本身挂了、nginx 挂了、机器网络不通,监控连不上,同样应当按失败报警。那不是这个接口返回的 503,而是连接错误。
|
||||
|
||||
---
|
||||
|
||||
## 4. 响应
|
||||
|
||||
`Content-Type: application/json`。
|
||||
|
||||
### 200 正常
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 3,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 3
|
||||
}
|
||||
```
|
||||
|
||||
### 503 不正常
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 0,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 0,
|
||||
"message": "没有可用的 worker"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| status | string | `ok` 或 `unhealthy`。与 HTTP 状态码一致:`ok` 对应 200,`unhealthy` 对应 503 |
|
||||
| service | string | 固定 `gateway` |
|
||||
| workers_total | int | 配置里的 worker 总数 |
|
||||
| workers_healthy | int | 当前在线台数。未开演练时,大于 0 返回 200,等于 0 返回 503 |
|
||||
| workers_primary_total | int | 主 worker 台数。未配置 `primary_workers` 时,全部 worker 都视为主 worker |
|
||||
| workers_primary_healthy | int | 主 worker 里在线的台数。为 0 且 `workers_healthy > 0` 表示只剩备用在撑 |
|
||||
| message | string | 仅 HTTP 503 时出现。真故障文案是「没有可用的 worker」;演练文案以「模拟故障」开头 |
|
||||
| simulated | bool | 仅演练打开时出现,值为 `true`。用来区分演练 503 和真故障 |
|
||||
|
||||
不返回 worker 地址、密码、队列深度。地址和鉴权信息留在网关日志和 `gateway/config.json` 里。
|
||||
|
||||
---
|
||||
|
||||
## 5. 监控侧建议
|
||||
|
||||
按 **HTTP 状态码** 报警,不要只解析 body。
|
||||
|
||||
| 结果 | 处理 |
|
||||
|------|------|
|
||||
| 200 | 正常 |
|
||||
| 503 | 报警。body 的 `message` 可放进告警内容 |
|
||||
| 连接失败、超时,以及其他 5xx | 报警。网关或 nginx 不可达 |
|
||||
|
||||
建议连续 **2 次**失败再发告警(约 60 秒)。网关重启时端口会有几秒不可用(`hair-gateway.service` 的 `RestartSec=3`,启动时还要等第一轮 worker 探测完成)。只看单次失败会在重启时误报。
|
||||
|
||||
不需要带任何请求头。不要把这条 URL 配成 POST,也不要把它当成业务接口去看 `code` 字段——这里没有业务信封 `{code, message, request_id, data}`。
|
||||
|
||||
报警延迟,从「最后一台可用 worker 真的挂了」到监控看到 503,大致是两段相加:
|
||||
|
||||
- 网关摘除:后台每 8 秒探一次,要连续失败 2 次才下线,大约 8~16 秒。若是转发失败触发的被动摘除,则立刻下线。
|
||||
- 监控拉取:最多再等一个 30 秒周期。
|
||||
|
||||
所以典型告警延迟大约半分钟到一分钟,不是秒级。
|
||||
|
||||
---
|
||||
|
||||
## 6. 和业务接口的关系
|
||||
|
||||
worker 全下线时,走 GPU 的接口(测量、生发、发际线、遮罩等)都会失败,`/monitor` 返回 503。这是要报警的情况。
|
||||
|
||||
有两个接口在网关本机执行,不经过 worker 池:
|
||||
|
||||
- 接口 4 `POST /api/v1/face/features`(豆包)
|
||||
- 接口 99 `POST /api/v1/face/special-points`(本机 CPU)
|
||||
|
||||
这两条在 worker 全挂时仍可能成功。`/monitor` 不管它们。它表示的是「依赖 worker 池的对外服务是否还能接」,不是「网关进程里每一个路由都活着」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知边界
|
||||
|
||||
这些情况 `/monitor` **不会**变成 503,和派发逻辑一致,避免把「还能干活」报成故障:
|
||||
|
||||
- 只挂了一部分 worker,还剩至少一台在线。
|
||||
- 主 worker 全挂,备用仍在线。
|
||||
- 在线 worker 都在忙,请求在网关排队。排队超时是业务返回「后端服务繁忙」,不是这个探活的失败。
|
||||
- worker 的 `/health` 仍返回 200,但机器实际上不能生图。当前仓库里 worker 的 `/health` 只看模型是否加载完成(未就绪才 503),不检查磁盘水位,也不检查 ComfyUI、change_hair 是否真的能出图。2026-09-19 那次磁盘写满时,浅探活会一直 200,网关就不会把机器摘掉,`/monitor` 也会一直 200。worker 侧更深的探活不在本接口范围内。
|
||||
|
||||
另外,备用 worker 如果从启动起就探不通,日志里不一定有「下线」警告。下线警告只在「原本在线、随后连续失败」时打印。一直没上线的机器只体现在 `workers_total` 和 `workers_healthy` 的差额里。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实现与生效
|
||||
|
||||
| 项 | 位置 |
|
||||
|----|------|
|
||||
| 路由 | `gateway/app.py` 的 `GET /monitor`、`POST /monitor/fault` |
|
||||
| 判定 | 同文件 `monitor_payload()`:未开演练时 `workers_healthy > 0` 为正常;演练打开则一律 503 |
|
||||
| 池状态 | `gateway/pool.py` 的 `get_pool_status()`,统计 `online` 台数 |
|
||||
| 单测 | `tests/test_gateway_monitor.py` |
|
||||
|
||||
改的是网关代码。worker 不用发版。改完后要重启网关进程才会加载新代码:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart hair-gateway
|
||||
systemctl is-active hair-gateway
|
||||
curl -sS -D - http://127.0.0.1:8080/monitor
|
||||
curl -sS -D - https://hair.xiangsilian.com/monitor
|
||||
```
|
||||
|
||||
配置(worker 列表、探测间隔、下线阈值、隔离期)仍在 `gateway/config.json`,启动时加载一次。只改配置、不改代码,同样要重启 `hair-gateway`。
|
||||
|
||||
`/health` 与 `/gateway-health` 保持原样,继续永远返回 200,给「进程是否存活」这类检查用。外部报警请只挂 `/monitor`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 故障模拟(报警演练)
|
||||
|
||||
`POST /monitor/fault` 用来演练监控会不会报警。打开之后,`GET /monitor` 固定返回 503;worker 池、派发、业务接口都不变。测完必须关掉,否则监控会一直报。
|
||||
|
||||
开关只活在网关进程内存里。重启 `hair-gateway` 后自动回到关闭。
|
||||
|
||||
### 请求
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://hair.xiangsilian.com/monitor/fault \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Internal-Token: <gateway/config.json 里的 shared_password>" \
|
||||
-d '{"enabled": true}'
|
||||
```
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| 方法 | `POST` |
|
||||
| 路径 | `/monitor/fault` |
|
||||
| 鉴权 | 请求头 `X-Internal-Token`,值与 `gateway/config.json` 的 `shared_password` 相同。缺失或错误返回 401 |
|
||||
| Body | JSON 布尔。`{"enabled": true}` 打开,`{"enabled": false}` 关闭。字符串 `"true"`、数字 `1` 都算参数错误,返回 400 |
|
||||
|
||||
打开成功:
|
||||
|
||||
```json
|
||||
{"status": "ok", "simulated": true, "message": "已开启模拟故障,GET /monitor 将返回 503"}
|
||||
```
|
||||
|
||||
关闭成功:
|
||||
|
||||
```json
|
||||
{"status": "ok", "simulated": false, "message": "已关闭模拟故障,GET /monitor 恢复真实状态"}
|
||||
```
|
||||
|
||||
### 演练期间 `/monitor` 的 503
|
||||
|
||||
计数字段仍是真实池状态,多一个 `simulated: true`,用来和「真的没有 worker」区分。若演练期间在线数本身已经是 0,文案会同时写明「当前没有可用的 worker」,避免把真故障误读成纯演练。
|
||||
|
||||
在线数仍大于 0 时:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 3,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 3,
|
||||
"simulated": true,
|
||||
"message": "模拟故障:监控测试中,真实 worker 未摘除"
|
||||
}
|
||||
```
|
||||
|
||||
监控若只看 HTTP 状态码,这次 503 会报警,和真故障同一条链路。告警内容里如果带了 body,可以看到 `simulated` 和「模拟故障」。
|
||||
|
||||
### 建议步骤
|
||||
|
||||
1. 确认当前 `GET /monitor` 是 200。
|
||||
2. `POST /monitor/fault`,`enabled` 设为 `true`。
|
||||
3. 再拉一次 `/monitor`,应为 503,且 `workers_healthy` 没有变成 0。
|
||||
4. 等监控的 30 秒周期(若配了连续 2 次失败才告警,就等两次)。确认告警发出。
|
||||
5. `enabled` 设为 `false`。再拉 `/monitor`,应回到 200。确认告警恢复。
|
||||
|
||||
演练时业务请求仍会打到在线 worker。这个接口不能用来模拟磁盘满、ComfyUI 挂掉或某台机器被摘除。
|
||||
|
||||
---
|
||||
|
||||
## 10. 上线核对(2026-10-15)
|
||||
|
||||
`hair-gateway.service` 已重启并完成启动。启动日志:`Worker 池初始化完成 | 总数=5 | 在线=3 | 主worker=3`。三台主 worker 的 `/health` 均为 200;两台备用在这轮启动探测里没有上线,所以健康数是 3 不是 5。按上面的规则这仍是正常。
|
||||
|
||||
当时公网实查:
|
||||
|
||||
```text
|
||||
HTTP/1.1 200 OK
|
||||
{"status":"ok","service":"gateway","workers_total":5,"workers_healthy":3,"workers_primary_total":3,"workers_primary_healthy":3}
|
||||
```
|
||||
|
||||
旧的 `GET /health` 仍是 200。重启前 8080 上没有已建立连接。
|
||||
|
||||
故障模拟在同一次上线里用本机请求核对过:不带令牌或令牌错误返回 401,且开关保持关闭;`enabled: true` 之后 `GET /monitor` 为 503,`simulated` 为 true,`workers_healthy` 仍是 3;`enabled: false` 之后回到 200。核对结束时开关是关闭的。开关的开、关会在 `journalctl -u hair-gateway` 里各留一条 warning,不进业务请求日志。
|
||||
@@ -137,6 +137,104 @@ async def health():
|
||||
return await gateway_health()
|
||||
|
||||
|
||||
# 监控演练开关。只影响 GET /monitor 的响应,不摘除 worker、不改变派发。
|
||||
# 进程内存态,重启网关后自动关闭。
|
||||
_monitor_drill = {"enabled": False}
|
||||
|
||||
|
||||
def monitor_payload(status: dict, *, simulated: bool = False) -> dict:
|
||||
"""根据池状态生成监控响应。至少一台 worker 在线才算服务正常。
|
||||
|
||||
simulated 为真时强制 unhealthy,用于监控报警演练;计数字段仍是真实池状态。
|
||||
"""
|
||||
healthy = int(status.get("healthy") or 0)
|
||||
ok = healthy > 0 and not simulated
|
||||
body = {
|
||||
"status": "ok" if ok else "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": status.get("total", 0),
|
||||
"workers_healthy": healthy,
|
||||
"workers_primary_total": status.get("primary_total", 0),
|
||||
"workers_primary_healthy": status.get("primary_healthy", 0),
|
||||
}
|
||||
if simulated:
|
||||
body["simulated"] = True
|
||||
if healthy > 0:
|
||||
body["message"] = "模拟故障:监控测试中,真实 worker 未摘除"
|
||||
else:
|
||||
body["message"] = "模拟故障:监控测试中;同时当前没有可用的 worker"
|
||||
elif not ok:
|
||||
body["message"] = "没有可用的 worker"
|
||||
return body
|
||||
|
||||
|
||||
def _monitor_token_error(request: Request):
|
||||
"""校验 X-Internal-Token 与网关 shared_password 一致。通过时返回 None。"""
|
||||
from gateway.config import get_config
|
||||
|
||||
try:
|
||||
expected = get_config().get("shared_password") or ""
|
||||
except Exception: # noqa: BLE001
|
||||
expected = ""
|
||||
token = request.headers.get("X-Internal-Token")
|
||||
if not expected or token != expected:
|
||||
return JSONResponse(status_code=401, content={
|
||||
"status": "unauthorized",
|
||||
"message": "未授权:缺少或错误的 X-Internal-Token",
|
||||
})
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/monitor", include_in_schema=False)
|
||||
async def monitor():
|
||||
"""供外部监控探测当前服务是否可接业务。
|
||||
|
||||
有在线 worker 返回 200,全部下线返回 503(监控侧据此报警)。
|
||||
演练开关打开时也返回 503,但 worker 池保持原样。
|
||||
只读内存中的池状态,后台健康检查已按配置间隔探测过 worker,这里不再额外发包。
|
||||
"""
|
||||
body = monitor_payload(_get_pool_status_safe(), simulated=_monitor_drill["enabled"])
|
||||
if body["status"] != "ok":
|
||||
return JSONResponse(status_code=503, content=body)
|
||||
return body
|
||||
|
||||
|
||||
@app.post("/monitor/fault", include_in_schema=False)
|
||||
async def monitor_fault(request: Request):
|
||||
"""打开或关闭监控故障模拟。
|
||||
|
||||
Body 必须是 ``{"enabled": true}`` 或 ``{"enabled": false}``(JSON 布尔)。
|
||||
打开后 GET /monitor 返回 503,业务派发不变。需要请求头 X-Internal-Token。
|
||||
"""
|
||||
denied = _monitor_token_error(request)
|
||||
if denied is not None:
|
||||
return denied
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
payload = None
|
||||
if not isinstance(payload, dict) or type(payload.get("enabled")) is not bool:
|
||||
return JSONResponse(status_code=400, content={
|
||||
"status": "error",
|
||||
"message": '需要 JSON:{"enabled": true} 或 {"enabled": false}',
|
||||
})
|
||||
enabled = payload["enabled"]
|
||||
_monitor_drill["enabled"] = enabled
|
||||
if enabled:
|
||||
logger.warning("监控故障模拟已开启:GET /monitor 将返回 503,worker 池未改动")
|
||||
else:
|
||||
logger.warning("监控故障模拟已关闭:GET /monitor 恢复真实池状态")
|
||||
return {
|
||||
"status": "ok",
|
||||
"simulated": enabled,
|
||||
"message": (
|
||||
"已开启模拟故障,GET /monitor 将返回 503"
|
||||
if enabled else
|
||||
"已关闭模拟故障,GET /monitor 恢复真实状态"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index():
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""外部监控接口:有在线 worker 才算服务正常;故障模拟只改探活响应。"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway import app as gateway_app
|
||||
from gateway.app import monitor, monitor_fault, monitor_payload
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body=None, token="secret", raw=None):
|
||||
self._body = body
|
||||
self._raw = raw
|
||||
self.headers = {"X-Internal-Token": token} if token is not None else {}
|
||||
|
||||
async def json(self):
|
||||
if self._raw is not None:
|
||||
raise self._raw
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_monitor_drill():
|
||||
gateway_app._monitor_drill["enabled"] = False
|
||||
yield
|
||||
gateway_app._monitor_drill["enabled"] = False
|
||||
|
||||
|
||||
def test_monitor_ok_when_any_worker_online():
|
||||
body = monitor_payload({
|
||||
"total": 5, "healthy": 1, "primary_total": 3, "primary_healthy": 0,
|
||||
})
|
||||
assert body["status"] == "ok"
|
||||
assert body["workers_healthy"] == 1
|
||||
assert "message" not in body
|
||||
|
||||
|
||||
def test_monitor_unhealthy_when_none_online():
|
||||
body = monitor_payload({
|
||||
"total": 5, "healthy": 0, "primary_total": 3, "primary_healthy": 0,
|
||||
})
|
||||
assert body["status"] == "unhealthy"
|
||||
assert body["message"] == "没有可用的 worker"
|
||||
assert body["workers_total"] == 5
|
||||
|
||||
|
||||
def test_monitor_unhealthy_when_pool_empty():
|
||||
body = monitor_payload({"total": 0, "healthy": 0})
|
||||
assert body["status"] == "unhealthy"
|
||||
|
||||
|
||||
def test_monitor_http_503_when_no_worker(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.app._get_pool_status_safe",
|
||||
lambda: {"total": 2, "healthy": 0, "primary_total": 2, "primary_healthy": 0},
|
||||
)
|
||||
resp = asyncio.run(monitor())
|
||||
assert resp.status_code == 503
|
||||
assert resp.body
|
||||
assert b'"status":"unhealthy"' in resp.body or b'"status": "unhealthy"' in resp.body
|
||||
|
||||
|
||||
def test_monitor_http_200_when_worker_online(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.app._get_pool_status_safe",
|
||||
lambda: {"total": 2, "healthy": 2, "primary_total": 2, "primary_healthy": 1},
|
||||
)
|
||||
body = asyncio.run(monitor())
|
||||
assert body["status"] == "ok"
|
||||
assert body["workers_healthy"] == 2
|
||||
|
||||
|
||||
def test_monitor_payload_simulated_and_really_down():
|
||||
body = monitor_payload(
|
||||
{"total": 5, "healthy": 0, "primary_total": 3, "primary_healthy": 0},
|
||||
simulated=True,
|
||||
)
|
||||
assert body["status"] == "unhealthy"
|
||||
assert body["simulated"] is True
|
||||
assert body["workers_healthy"] == 0
|
||||
assert "没有可用的 worker" in body["message"]
|
||||
|
||||
|
||||
def test_monitor_payload_simulated_keeps_real_counts():
|
||||
body = monitor_payload(
|
||||
{"total": 5, "healthy": 3, "primary_total": 3, "primary_healthy": 3},
|
||||
simulated=True,
|
||||
)
|
||||
assert body["status"] == "unhealthy"
|
||||
assert body["simulated"] is True
|
||||
assert body["workers_healthy"] == 3
|
||||
assert "模拟故障" in body["message"]
|
||||
|
||||
|
||||
def _patch_token(monkeypatch, password="secret"):
|
||||
monkeypatch.setattr(
|
||||
"gateway.config.get_config",
|
||||
lambda: {"shared_password": password},
|
||||
)
|
||||
|
||||
|
||||
def test_fault_requires_token(monkeypatch):
|
||||
_patch_token(monkeypatch)
|
||||
resp = asyncio.run(monitor_fault(_Req(body={"enabled": True}, token="nope")))
|
||||
assert resp.status_code == 401
|
||||
assert gateway_app._monitor_drill["enabled"] is False
|
||||
|
||||
|
||||
def test_fault_rejects_non_bool(monkeypatch):
|
||||
_patch_token(monkeypatch)
|
||||
resp = asyncio.run(monitor_fault(_Req(body={"enabled": "true"})))
|
||||
assert resp.status_code == 400
|
||||
assert gateway_app._monitor_drill["enabled"] is False
|
||||
|
||||
|
||||
def test_fault_on_makes_monitor_503_then_off_restores(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.app._get_pool_status_safe",
|
||||
lambda: {"total": 5, "healthy": 3, "primary_total": 3, "primary_healthy": 3},
|
||||
)
|
||||
_patch_token(monkeypatch)
|
||||
|
||||
opened = asyncio.run(monitor_fault(_Req(body={"enabled": True})))
|
||||
assert opened["simulated"] is True
|
||||
failed = asyncio.run(monitor())
|
||||
assert failed.status_code == 503
|
||||
assert b'"simulated":true' in failed.body or b'"simulated": true' in failed.body
|
||||
assert b'"workers_healthy":3' in failed.body or b'"workers_healthy": 3' in failed.body
|
||||
|
||||
closed = asyncio.run(monitor_fault(_Req(body={"enabled": False})))
|
||||
assert closed["simulated"] is False
|
||||
ok = asyncio.run(monitor())
|
||||
assert ok["status"] == "ok"
|
||||
assert "simulated" not in ok
|
||||
Reference in New Issue
Block a user