Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions application/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from application.api.connector.routes import connector # noqa: E402
from application.celery_init import celery # noqa: E402
from application.core.settings import settings # noqa: E402
from application.core.service_checks import required_service_checks, summarize_checks # noqa: E402
from application.stt.upload_limits import ( # noqa: E402
build_stt_file_size_limit_message,
should_reject_stt_request,
Expand Down Expand Up @@ -81,6 +82,20 @@ def get_config():
return jsonify(response)


@app.route("/api/health")
def healthcheck():
"""Liveness: is the backend process up?"""
return jsonify({"status": "ok", "service": "backend"})

@app.route("/api/ready")
def readiness_check():
"""Readiness: can the backend reach Redis, Mongo, and (if enabled) Qdrant?"""
checks = required_service_checks()
all_ok, payload = summarize_checks(checks)
status_code = 200 if all_ok else 503
return jsonify({"status": "ready" if all_ok else "degraded", "checks": payload}), status_code


@app.route("/api/generate_token")
def generate_token():
if settings.AUTH_TYPE == "session_jwt":
Expand Down
114 changes: 114 additions & 0 deletions application/core/service_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import logging
import os
from dataclasses import dataclass
from typing import Dict, Tuple
from urllib.parse import urlparse

import redis
from pymongo import MongoClient
try:
from qdrant_client import QdrantClient
except ModuleNotFoundError: # optional dependency
QdrantClient = None

from application.core.settings import settings


@dataclass
class CheckResult:
ok: bool
detail: str


def _check_redis(url: str) -> CheckResult:
try:
client = redis.Redis.from_url(url, socket_connect_timeout=2, socket_timeout=2)
if client.ping():
return CheckResult(ok=True, detail="redis ping successful")
return CheckResult(ok=False, detail="redis ping failed")
except Exception as exc:
return CheckResult(ok=False, detail=f"redis connection failed: {exc}")


def _check_mongo(uri: str) -> CheckResult:
client = None
try:
client = MongoClient(uri, serverSelectionTimeoutMS=2000)
client.admin.command("ping")
return CheckResult(ok=True, detail="mongo ping successful")
except Exception as exc:
return CheckResult(ok=False, detail=f"mongo connection failed: {exc}")
finally:
if client is not None:
client.close()


def _check_qdrant(url: str) -> CheckResult:
if QdrantClient is None:
return CheckResult(ok=False, detail="qdrant_client not installed")
try:
client = QdrantClient(url=url, api_key=settings.QDRANT_API_KEY, timeout=2.0)
client.get_collections()
return CheckResult(ok=True, detail="qdrant API reachable")
except Exception as exc:
return CheckResult(ok=False, detail=f"qdrant connection failed: {exc}")


def _is_qdrant_enabled() -> bool:
return settings.VECTOR_STORE.lower() == "qdrant"


def _normalize_host(value: str) -> str:
parsed = urlparse(value)
return parsed.hostname or value


def required_service_checks() -> Dict[str, CheckResult]:
checks: Dict[str, CheckResult] = {
"redis": _check_redis(settings.CELERY_BROKER_URL),
"mongo": _check_mongo(settings.MONGO_URI),
}
if _is_qdrant_enabled():
qdrant_url = settings.QDRANT_URL or "http://qdrant:6333"
checks["qdrant"] = _check_qdrant(qdrant_url)
return checks


def summarize_checks(checks: Dict[str, CheckResult]) -> Tuple[bool, Dict[str, dict]]:
all_ok = all(result.ok for result in checks.values())
payload = {name: {"ok": result.ok, "detail": result.detail} for name, result in checks.items()}
return all_ok, payload


def log_startup_diagnostics(logger: logging.Logger) -> None:
vector_store = settings.VECTOR_STORE.lower()
diagnostics = {
"auth_type": settings.AUTH_TYPE or "none",
"vector_store": vector_store,
"llm_provider": settings.LLM_PROVIDER,
"mongo_host": _normalize_host(settings.MONGO_URI),
"broker_host": _normalize_host(settings.CELERY_BROKER_URL),
"cache_host": _normalize_host(settings.CACHE_REDIS_URL),
"startup_dependency_checks": settings.STARTUP_DEPENDENCY_CHECKS,
"startup_check_strict": settings.STARTUP_CHECK_STRICT,
"service_name": os.getenv("DOCSGPT_SERVICE_NAME", "docsgpt-backend"),
}
if vector_store == "qdrant":
diagnostics["qdrant_host"] = _normalize_host(settings.QDRANT_URL or "http://qdrant:6333")
logger.info("startup diagnostics: %s", diagnostics)


def run_startup_dependency_checks(logger: logging.Logger) -> None:
if not settings.STARTUP_DEPENDENCY_CHECKS:
logger.info("startup dependency checks disabled")
return

checks = required_service_checks()
all_ok, payload = summarize_checks(checks)
if all_ok:
logger.info("startup dependency checks passed: %s", payload)
return

logger.error("startup dependency checks failed: %s", payload)
if settings.STARTUP_CHECK_STRICT:
raise RuntimeError("startup dependency checks failed")
50 changes: 50 additions & 0 deletions application/healthcheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import argparse
import json
import sys
import urllib.error
import urllib.request

from application.core.service_checks import required_service_checks, summarize_checks


def _check_backend_endpoint(url: str) -> bool:
try:
with urllib.request.urlopen(url, timeout=4) as response:
return response.status == 200
except urllib.error.URLError:
return False


def main() -> int:
parser = argparse.ArgumentParser(description="DocsGPT healthcheck helper")
parser.add_argument(
"--target",
choices=["dependencies", "worker", "backend"],
default="dependencies",
help="Check dependency services or backend HTTP endpoint",
)
parser.add_argument(
"--url",
default="http://localhost:7091/api/health",
help="Backend URL used when target=backend",
)
args = parser.parse_args()

if args.target == "backend":
is_healthy = _check_backend_endpoint(args.url)
print(json.dumps({"target": "backend", "healthy": is_healthy}, ensure_ascii=True))
return 0 if is_healthy else 1

checks = required_service_checks()
all_ok, payload = summarize_checks(checks)
print(
json.dumps(
{"target": args.target, "healthy": all_ok, "checks": payload},
ensure_ascii=True,
)
)
return 0 if all_ok else 1


if __name__ == "__main__":
sys.exit(main())
178 changes: 178 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Unit tests for /api/health, /api/ready, and application.healthcheck CLI."""

import json
from unittest.mock import patch

import pytest
from application.core.service_checks import CheckResult
from flask import Flask, jsonify


def _register_health_routes(app: Flask) -> None:
"""Register only health/ready routes (mirrors application.app additions)."""
from application.core.service_checks import required_service_checks, summarize_checks

@app.route("/api/health")
def healthcheck():
return jsonify({"status": "ok", "service": "backend"})

@app.route("/api/ready")
def readiness_check():
checks = required_service_checks()
all_ok, payload = summarize_checks(checks)
status_code = 200 if all_ok else 503
return jsonify({"status": "ready" if all_ok else "degraded", "checks": payload}), status_code


@pytest.mark.unit
def test_api_health_returns_200_and_body():
"""GET /api/health returns 200 with status ok and service backend."""
app = Flask(__name__)
_register_health_routes(app)
client = app.test_client()
response = client.get("/api/health")
assert response.status_code == 200
data = response.get_json()
assert data == {"status": "ok", "service": "backend"}


@pytest.mark.unit
@patch("application.core.service_checks.required_service_checks")
@patch("application.core.service_checks.summarize_checks")
def test_api_ready_returns_200_when_healthy(mock_summarize, mock_required):
"""GET /api/ready returns 200 and status ready when all checks pass."""
mock_required.return_value = {
"redis": CheckResult(ok=True, detail="ok"),
"mongo": CheckResult(ok=True, detail="ok"),
}
mock_summarize.return_value = (
True,
{"redis": {"ok": True, "detail": "ok"}, "mongo": {"ok": True, "detail": "ok"}},
)
app = Flask(__name__)
_register_health_routes(app)
client = app.test_client()
response = client.get("/api/ready")
assert response.status_code == 200
data = response.get_json()
assert data["status"] == "ready"
assert "checks" in data


@pytest.mark.unit
@patch("application.core.service_checks.required_service_checks")
@patch("application.core.service_checks.summarize_checks")
def test_api_ready_returns_503_when_degraded(mock_summarize, mock_required):
"""GET /api/ready returns 503 and status degraded when a check fails."""
mock_required.return_value = {
"redis": CheckResult(ok=True, detail="ok"),
"mongo": CheckResult(ok=False, detail="connection failed"),
}
mock_summarize.return_value = (
False,
{"redis": {"ok": True, "detail": "ok"}, "mongo": {"ok": False, "detail": "connection failed"}},
)
app = Flask(__name__)
_register_health_routes(app)
client = app.test_client()
response = client.get("/api/ready")
assert response.status_code == 503
data = response.get_json()
assert data["status"] == "degraded"
assert data["checks"]["mongo"]["ok"] is False


@pytest.mark.unit
@patch("application.healthcheck.required_service_checks")
@patch("application.healthcheck.summarize_checks")
def test_healthcheck_cli_dependencies_healthy(mock_summarize, mock_required, capsys):
"""healthcheck --target dependencies exits 0 and prints JSON when checks pass."""
mock_required.return_value = {
"redis": CheckResult(ok=True, detail="ok"),
"mongo": CheckResult(ok=True, detail="ok"),
}
mock_summarize.return_value = (
True,
{"redis": {"ok": True, "detail": "ok"}, "mongo": {"ok": True, "detail": "ok"}},
)
from application.healthcheck import main

import sys

old = sys.argv
try:
sys.argv = ["healthcheck", "--target", "dependencies"]
exit_code = main()
assert exit_code == 0
out = capsys.readouterr().out
finally:
sys.argv = old
data = json.loads(out)
assert data["target"] == "dependencies"
assert data["healthy"] is True
assert "checks" in data


@pytest.mark.unit
@patch("application.healthcheck.required_service_checks")
@patch("application.healthcheck.summarize_checks")
def test_healthcheck_cli_dependencies_unhealthy(mock_summarize, mock_required, capsys):
"""healthcheck --target dependencies exits 1 when a check fails."""
mock_required.return_value = {
"redis": CheckResult(ok=False, detail="connection failed"),
"mongo": CheckResult(ok=True, detail="ok"),
}
mock_summarize.return_value = (
False,
{"redis": {"ok": False, "detail": "connection failed"}, "mongo": {"ok": True, "detail": "ok"}},
)
from application.healthcheck import main

import sys

old = sys.argv
try:
sys.argv = ["healthcheck", "--target", "dependencies"]
exit_code = main()
assert exit_code == 1
out = capsys.readouterr().out
finally:
sys.argv = old
data = json.loads(out)
assert data["healthy"] is False


@pytest.mark.unit
@patch("application.healthcheck._check_backend_endpoint")
def test_healthcheck_cli_backend_healthy(mock_check):
"""healthcheck --target backend exits 0 when backend URL returns 200."""
mock_check.return_value = True
from application.healthcheck import main

import sys

old = sys.argv
try:
sys.argv = ["healthcheck", "--target", "backend"]
exit_code = main()
assert exit_code == 0
finally:
sys.argv = old


@pytest.mark.unit
@patch("application.healthcheck._check_backend_endpoint")
def test_healthcheck_cli_backend_unhealthy(mock_check):
"""healthcheck --target backend exits 1 when backend URL fails."""
mock_check.return_value = False
from application.healthcheck import main

import sys

old = sys.argv
try:
sys.argv = ["healthcheck", "--target", "backend"]
exit_code = main()
assert exit_code == 1
finally:
sys.argv = old
Loading