feat(stability): stage 6 — lightweight /ping healthcheck that never touches Telegram (#6)

The container healthcheck hit /rss/...?limit=1 (5s timeout): on a cold cache RSS
generation exceeds 5s, or a hung TG RPC makes it hang, so docker/autoheal restarts
the container mid-download and corrupts temp files. Replace it with /ping, which
reflects process/loop liveness (answers instantly, always) plus TG liveness read
from the watchdog's last-probe data — issuing ZERO Telegram RPC.

- telegram_client: public watchdog_last_ok_age() — seconds since the last successful
  watchdog probe (None if never). Pure read of the Stage-1 _wd_last_ok_monotonic
  field; no RPC.
- api_server: /ping route (no token, no TG RPC, no SQLite, no fs scan). healthy =
  connected and (age is None or age < threshold). age is None right after boot =>
  healthy (don't kill before the first probe). connected coerced to bool so the JSON
  "connected" field is always a bool (pre-start reports false, never null).
- config: TG_PING_UNHEALTHY_AFTER knob, default interval*(failures+1)+timeout = 250s
  (how long until the watchdog itself gives up), env-overridable.
- dockercompose.yml: healthcheck -> curl -sf http://127.0.0.1:80/ping, interval 5m,
  timeout 5s, retries 3, start_period 30s. Old /rss check removed, not left behind.
- tests: 10 (healthy/stale/disconnected/fresh-boot/pre-start-null/no-token +
  the anti-regression zero-TG-RPC spy across all branches + the accessor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:34:06 +03:00
parent d0801ef0ff
commit 7d6ee0271d
6 changed files with 237 additions and 4 deletions
+33 -1
View File
@@ -33,7 +33,7 @@ from pyrogram import errors
from pyrogram.types import Message
from pyrogram.enums import MessageMediaType
from fastapi import FastAPI, HTTPException, Response, Request
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from telegram_client import TelegramClient
from config import get_settings, setup_logging
from rss_generator import generate_channel_rss, generate_channel_html
@@ -1065,6 +1065,38 @@ async def get_raw_post_json(channel: str, post_id: int, request: Request, token:
raise HTTPException(status_code=500, detail=error_message) from e
@app.get("/ping")
async def ping() -> JSONResponse:
"""Lightweight liveness probe for the container healthcheck.
Reflects process/event-loop liveness (always answers in microseconds) and TG liveness
from the watchdog's last-probe data. It MUST NOT issue any Telegram RPC (no get_me,
no safe_get_messages), touch SQLite, or walk the filesystem — that is the whole point:
it stays instant and truthful even while a real TG RPC is hung. It only reads the
already-recorded watchdog timestamp and the is_connected bool.
"""
age = client.watchdog_last_ok_age() # seconds since last OK probe, None if never
# is_connected is None before client.start() and a bool afterwards; coerce so the JSON
# "connected" field is always a bool (never null) and the pre-start window reports false.
connected = bool(client.client.is_connected)
threshold = Config["tg_ping_unhealthy_after"]
# age is None right after boot: the watchdog hasn't run its first probe yet. Treat that
# as healthy (gate on connected only) so a freshly-started container is not killed before
# its first probe cycle — otherwise start_period would have to cover a full watchdog interval.
# Note: when the watchdog is DISABLED (TG_WATCHDOG_ENABLED=false) age stays None forever,
# so /ping degenerates to a pure connectivity check and cannot detect a stale-but-connected
# ("zombie") session — that TG-liveness signal only exists while the watchdog runs.
healthy = connected and (age is None or age < threshold)
return JSONResponse(
{
"status": "ok" if healthy else "degraded",
"connected": connected,
"last_probe_age_s": round(age, 1) if age is not None else None,
"threshold_s": threshold,
},
status_code=200 if healthy else 503,
)
@app.get("/health")
@app.get("/health/{token}")
async def health_check(request: Request, token: str | None = None) -> Response: