from __future__ import annotations import asyncio import hmac import random import time import uuid from contextlib import asynccontextmanager, suppress from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Annotated, Protocol import uvicorn from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine def utcnow() -> datetime: return datetime.now(UTC) class Settings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") app_env: str = "production-like" bitrix_sync_enabled: bool = True bitrix_sync_database_url: str | None = None bitrix_sync_service_token: str = Field(min_length=16) bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05) bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05) bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5) bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05) bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05) bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1) bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5) bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30) @model_validator(mode="after") def validate_enabled(self) -> Settings: if self.bitrix_sync_enabled and not self.bitrix_sync_database_url: raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled") return self @dataclass class Snapshot: state: str started_at: datetime last_started_at: datetime | None = None last_finished_at: datetime | None = None last_success_at: datetime | None = None success: bool | None = None duration_ms: int | None = None error_code: str | None = None consecutive_failures: int = 0 next_check_at: datetime | None = None worker_running: bool = False class Probe(Protocol): async def check(self) -> None: ... async def close(self) -> None: ... class DatabaseProbe: def __init__(self, engine: AsyncEngine) -> None: self.engine = engine async def check(self) -> None: async with self.engine.connect() as connection: result = await connection.scalar(text("SELECT 1")) if result != 1: raise RuntimeError("unexpected_result") async def close(self) -> None: await self.engine.dispose() def database_url(value: str) -> str: if value.startswith("postgresql://"): return value.replace("postgresql://", "postgresql+asyncpg://", 1) return value def classify_error(exc: Exception) -> str: name = type(exc).__name__.lower() text_value = str(exc).lower() if isinstance(exc, TimeoutError): return "db_query_timeout" if "auth" in name or "password" in text_value: return "db_auth_failed" if "ssl" in name or "tls" in text_value or "certificate" in text_value: return "db_tls_failed" if str(exc) == "unexpected_result": return "unexpected_result" return "db_unavailable" def jsonable(value): if isinstance(value, datetime): return value.isoformat().replace("+00:00", "Z") return value def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI: cfg = settings or Settings() @asynccontextmanager async def lifespan(app: FastAPI): app.state.snapshot = Snapshot( state="disabled" if not cfg.bitrix_sync_enabled else "starting", started_at=utcnow(), ) app.state.stop = asyncio.Event() app.state.probe = probe app.state.loop_task = None if cfg.bitrix_sync_enabled: if app.state.probe is None: engine = create_async_engine( database_url(cfg.bitrix_sync_database_url or ""), pool_size=cfg.bitrix_sync_db_pool_size, max_overflow=0, pool_pre_ping=True, pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec, connect_args={"server_settings": {"application_name": "han-bitrix-sync"}}, ) app.state.probe = DatabaseProbe(engine) await run_probe(app) app.state.snapshot.worker_running = True app.state.loop_task = asyncio.create_task( periodic_loop(app), name="db-connectivity-probe" ) yield app.state.snapshot.state = "stopping" app.state.stop.set() if app.state.loop_task: app.state.loop_task.cancel() with suppress(asyncio.CancelledError): await app.state.loop_task if app.state.probe: await app.state.probe.close() app = FastAPI( title="HAN Bitrix Sync Connectivity Stub", version="1.0.0", lifespan=lifespan, docs_url=None if cfg.app_env != "test" else "/docs", ) app.state.settings = cfg @app.middleware("http") async def request_id(request: Request, call_next): request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) response = await call_next(request) response.headers["X-Request-ID"] = request.state.request_id return response @app.exception_handler(HTTPException) async def http_error(_: Request, exc: HTTPException): return JSONResponse(status_code=exc.status_code, content=exc.detail) @app.get("/health/live") async def live(): return {"status": "live"} @app.get("/health/ready") async def ready(request: Request): snapshot: Snapshot = request.app.state.snapshot if not cfg.bitrix_sync_enabled: return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503) age = ( (utcnow() - snapshot.last_success_at).total_seconds() if snapshot.last_success_at else None ) max_age = max( cfg.bitrix_sync_ready_max_staleness_sec, 2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio), ) is_ready = ( snapshot.state == "healthy" and snapshot.worker_running and age is not None and age <= max_age ) if is_ready: return { "status": "ready", "mode": "db_connectivity_stub", "database": { "status": "ok", "last_success_at": jsonable(snapshot.last_success_at), "age_seconds": round(age or 0, 3), }, } reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable" return JSONResponse( { "status": "not_ready", "reason": reason, "database": { "status": "down", "last_success_at": jsonable(snapshot.last_success_at), "consecutive_failures": snapshot.consecutive_failures, }, }, status_code=503, ) @app.get("/internal/sync/v1/status") async def status( request: Request, authorization: Annotated[str | None, Header()] = None, ): candidate = ( authorization[7:] if authorization and authorization.startswith("Bearer ") else "" ) if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token): raise HTTPException( 401, { "error": { "code": "service_unauthorized", "message": "Service authentication failed", "request_id": request.state.request_id, "details": {}, } }, ) snapshot: Snapshot = request.app.state.snapshot last_check = None if snapshot.last_started_at: last_check = { "started_at": jsonable(snapshot.last_started_at), "finished_at": jsonable(snapshot.last_finished_at), "success": snapshot.success, "duration_ms": snapshot.duration_ms, "error_code": snapshot.error_code, } next_in = ( max(0, (snapshot.next_check_at - utcnow()).total_seconds()) if snapshot.next_check_at else None ) return { "service": "bitrix-sync", "enabled": cfg.bitrix_sync_enabled, "mode": "db_connectivity_stub", "crm_sync_implemented": False, "state": snapshot.state, "started_at": jsonable(snapshot.started_at), "last_check": last_check, "last_success_at": jsonable(snapshot.last_success_at), "consecutive_failures": snapshot.consecutive_failures, "next_check_in_seconds": round(next_in, 3) if next_in is not None else None, } return app async def run_probe(app: FastAPI) -> None: cfg: Settings = app.state.settings snapshot: Snapshot = app.state.snapshot snapshot.last_started_at = utcnow() started = time.monotonic() try: async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec): await app.state.probe.check() except Exception as exc: snapshot.success = False snapshot.error_code = classify_error(exc) snapshot.consecutive_failures += 1 snapshot.state = "degraded" else: snapshot.success = True snapshot.error_code = None snapshot.consecutive_failures = 0 snapshot.last_success_at = utcnow() snapshot.state = "healthy" finally: snapshot.last_finished_at = utcnow() snapshot.duration_ms = round((time.monotonic() - started) * 1000) async def periodic_loop(app: FastAPI) -> None: cfg: Settings = app.state.settings snapshot: Snapshot = app.state.snapshot try: while not app.state.stop.is_set(): if snapshot.consecutive_failures: cap = min( cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1), cfg.bitrix_sync_db_retry_max_sec, ) delay = random.uniform(0, cap) else: jitter = ( cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio ) delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter) snapshot.next_check_at = utcnow() + timedelta(seconds=delay) try: await asyncio.wait_for(app.state.stop.wait(), timeout=delay) break except TimeoutError: await run_probe(app) finally: snapshot.worker_running = False _settings = Settings() app = create_app(_settings) def run() -> None: uvicorn.run("app.main:app", host="0.0.0.0", port=8080)