- config: pydantic-settings по подсистемам, ADMIN_IDS через запятую - texts.yaml + валидация при старте (лишний/пропущенный ключ — ошибка сразу) - меню: reply-клавиатура разделов (Участники — только админам), инлайн «Назад»/«Отмена» с редактированием сообщения на месте, /start, /menu, /help - AccessMiddleware (outer) на сообщениях и кнопках: до #25 пускает только ADMIN_IDS, постороннему — одна фраза в сутки, устаревшие кнопки молча гасятся - heartbeat-файл + HEALTHCHECK в Dockerfile; JSON-логи - FSM пока в памяти (фабрика), хранилище в БД — в #5 - тесты на фейковой сессии aiogram через настоящий Dispatcher Refs #4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
36 lines
949 B
Python
36 lines
949 B
Python
"""Liveness: a background task touches a file while the event loop runs; the Docker healthcheck checks its age.
|
|
|
|
A stuck loop stops touching the file, so the container turns unhealthy even though the process is still up.
|
|
`python -m app.health` exits 0 when healthy, 1 otherwise.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from app.config import RuntimeSettings
|
|
|
|
|
|
async def heartbeat(path: Path, interval: float) -> None:
|
|
while True:
|
|
await asyncio.to_thread(path.touch)
|
|
await asyncio.sleep(interval)
|
|
|
|
|
|
def is_alive(path: Path, max_age: float, now: float | None = None) -> bool:
|
|
try:
|
|
age = (time.time() if now is None else now) - path.stat().st_mtime
|
|
except FileNotFoundError:
|
|
return False
|
|
return age <= max_age
|
|
|
|
|
|
def main() -> None:
|
|
rt = RuntimeSettings()
|
|
sys.exit(0 if is_alive(rt.heartbeat_file, rt.heartbeat_max_age) else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|