Skip to content

Commit 13d2f04

Browse files
feat(api): add feature-flagged /rest/v1/health endpoint (#936)
* feat(api): add feature-flagged /rest/v1/health endpoint Adds a lightweight deploy/uptime health probe at GET /rest/v1/health, gated behind the CRE_ENABLE_HEALTH feature flag (off by default). Behavior: - Flag off (default): endpoint returns 404, as if it does not exist. - Flag on, healthy: 200 with {ok, db_reachable, cre_count, standards_count} when the serving DB is reachable and holds a non-empty dataset. - Flag on, unhealthy: 503 when the DB is unreachable or the dataset is empty/broken (reason explains which). Node_collection.health_check() runs cheap COUNT queries over CRE and Node, never raises (connectivity errors are reported as ok=False), and treats a zero count for either as an empty dataset. Scope is intentionally limited to DB reachability + data sanity. Deeper checks (gap-analysis completeness, mapping coverage, Neo4j, Redis) are deliberately excluded by design and belong in ops tooling. * fix: load .env in feature_flags and document CRE_ENABLE_HEALTH flag * Modified the .env issue
1 parent e853cd3 commit 13d2f04

6 files changed

Lines changed: 152 additions & 1 deletion

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ REDIS_NO_SSL=false
2424
FLASK_CONFIG=development
2525
INSECURE_REQUESTS=false
2626

27+
# Feature Flags
28+
# Enable the deploy/uptime health probe at GET /rest/v1/health.
29+
# Set to one of 1, true, yes (case-insensitive) to enable; any other value
30+
# (including unset or false) leaves it off and the endpoint returns 404.
31+
32+
CRE_ENABLE_HEALTH=false
33+
2734
# Embeddings
2835

2936
NO_GEN_EMBEDDINGS=false

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ Then edit `.env` and provide values appropriate for your environment.
289289
* Google Auth: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_SECRET_JSON`, `LOGIN_ALLOWED_DOMAINS`
290290
* GCP: `GCP_NATIVE`
291291
* Spreadsheet Auth: `OpenCRE_gspread_Auth`
292+
* Feature flags: `CRE_ENABLE_HEALTH` (enable the `GET /rest/v1/health` deploy/uptime probe; off by default, returns 404 when unset)
292293

293294
See `.env.example` for full list and defaults.
294295

application/database/db.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2264,6 +2264,54 @@ def get_root_cres(self):
22642264
)
22652265
return self._hydrate_cres_batch(list(cres))
22662266

2267+
def health_check(self) -> Dict[str, Any]:
2268+
"""Lightweight liveness/readiness probe for the serving database.
2269+
2270+
Intended for use by a deploy/uptime health endpoint, NOT for deep
2271+
operational checks (GA completeness, mapping coverage, etc.) which are
2272+
slow and belong in ops tooling. Performs cheap COUNT queries and never
2273+
raises: connectivity failures are reported as ``ok=False`` so the caller
2274+
can return an appropriate status code.
2275+
2276+
Returns a dict with:
2277+
- ``ok``: True only if the DB is reachable AND holds a non-empty
2278+
dataset (at least one CRE and one standard/node).
2279+
- ``db_reachable``: True if the COUNT queries executed.
2280+
- ``cre_count`` / ``standards_count``: populated when reachable.
2281+
- ``reason``: short human-readable explanation when ``ok`` is False.
2282+
"""
2283+
try:
2284+
cre_count = self.session.query(func.count(CRE.id)).scalar() or 0
2285+
standards_count = self.session.query(func.count(Node.id)).scalar() or 0
2286+
except OperationalError:
2287+
return {
2288+
"ok": False,
2289+
"db_reachable": False,
2290+
"reason": "database unreachable",
2291+
}
2292+
except Exception: # pragma: no cover - defensive, never fail open
2293+
return {
2294+
"ok": False,
2295+
"db_reachable": False,
2296+
"reason": "database health query failed",
2297+
}
2298+
2299+
if cre_count == 0 or standards_count == 0:
2300+
return {
2301+
"ok": False,
2302+
"db_reachable": True,
2303+
"cre_count": cre_count,
2304+
"standards_count": standards_count,
2305+
"reason": "empty dataset",
2306+
}
2307+
2308+
return {
2309+
"ok": True,
2310+
"db_reachable": True,
2311+
"cre_count": cre_count,
2312+
"standards_count": standards_count,
2313+
}
2314+
22672315
def get_embeddings_by_doc_type(self, doc_type: str) -> Dict[str, List[float]]:
22682316
res = {}
22692317
embeddings = (

application/feature_flags.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import os
22

3+
try:
4+
from dotenv import load_dotenv # type: ignore
5+
6+
load_dotenv()
7+
except ImportError:
8+
pass
9+
310
TRUE_VALUES = {"1", "true", "yes"}
411

512

613
def is_cre_import_allowed() -> bool:
714
return os.getenv("CRE_ALLOW_IMPORT", "").strip().lower() in TRUE_VALUES
15+
16+
17+
def is_health_endpoint_enabled() -> bool:
18+
return os.getenv("CRE_ENABLE_HEALTH", "").strip().lower() in TRUE_VALUES

application/tests/web_main_test.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,3 +1360,66 @@ def test_get_cre_csv(self) -> None:
13601360
data.getvalue(),
13611361
response.data.decode(),
13621362
)
1363+
1364+
def test_health_disabled_by_default_returns_404(self) -> None:
1365+
os.environ.pop("CRE_ENABLE_HEALTH", None)
1366+
with self.app.test_client() as client:
1367+
response = client.get("/rest/v1/health")
1368+
self.assertEqual(404, response.status_code)
1369+
1370+
def test_health_enabled_empty_dataset_returns_503(self) -> None:
1371+
os.environ["CRE_ENABLE_HEALTH"] = "1"
1372+
try:
1373+
with self.app.test_client() as client:
1374+
response = client.get("/rest/v1/health")
1375+
self.assertEqual(503, response.status_code)
1376+
body = json.loads(response.data.decode())
1377+
self.assertFalse(body["ok"])
1378+
self.assertTrue(body["db_reachable"])
1379+
self.assertEqual("empty dataset", body["reason"])
1380+
finally:
1381+
os.environ.pop("CRE_ENABLE_HEALTH", None)
1382+
1383+
def test_health_enabled_populated_returns_200(self) -> None:
1384+
os.environ["CRE_ENABLE_HEALTH"] = "1"
1385+
try:
1386+
collection = db.Node_collection()
1387+
collection.add_cre(
1388+
defs.CRE(id="111-115", description="CA", name="CA", tags=["ta"])
1389+
)
1390+
collection.add_node(
1391+
defs.Standard(
1392+
name="s1", section="s11", subsection="s111", version="1.1.1"
1393+
)
1394+
)
1395+
with self.app.test_client() as client:
1396+
response = client.get("/rest/v1/health")
1397+
self.assertEqual(200, response.status_code)
1398+
body = json.loads(response.data.decode())
1399+
self.assertTrue(body["ok"])
1400+
self.assertTrue(body["db_reachable"])
1401+
self.assertGreaterEqual(body["cre_count"], 1)
1402+
self.assertGreaterEqual(body["standards_count"], 1)
1403+
finally:
1404+
os.environ.pop("CRE_ENABLE_HEALTH", None)
1405+
1406+
def test_health_db_unreachable_returns_503(self) -> None:
1407+
os.environ["CRE_ENABLE_HEALTH"] = "1"
1408+
try:
1409+
with patch.object(
1410+
db.Node_collection,
1411+
"health_check",
1412+
return_value={
1413+
"ok": False,
1414+
"db_reachable": False,
1415+
"reason": "database unreachable",
1416+
},
1417+
):
1418+
with self.app.test_client() as client:
1419+
response = client.get("/rest/v1/health")
1420+
self.assertEqual(503, response.status_code)
1421+
body = json.loads(response.data.decode())
1422+
self.assertFalse(body["ok"])
1423+
self.assertFalse(body["db_reachable"])
1424+
finally:
1425+
os.environ.pop("CRE_ENABLE_HEALTH", None)

application/web/web_main.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from application.cmd import cre_main
2323
from application.defs import cre_defs as defs
2424
from application.defs import cre_exceptions
25-
from application.feature_flags import is_cre_import_allowed
25+
from application.feature_flags import is_cre_import_allowed, is_health_endpoint_enabled
2626

2727
from application.utils import spreadsheet as sheet_utils
2828
from application.utils import mdutils, redirectors, gap_analysis
@@ -584,6 +584,27 @@ def text_search() -> Any:
584584
abort(404, "No object matches the given search terms")
585585

586586

587+
@app.route("/rest/v1/health", methods=["GET"])
588+
def health() -> Any:
589+
"""Deploy/uptime health probe (feature-flagged, off by default).
590+
591+
Enable with CRE_ENABLE_HEALTH=1. Scope is intentionally narrow and fast so
592+
it can gate deploys without failing for the wrong reason:
593+
- 200: app up, serving DB reachable, dataset non-empty (CREs and
594+
standards present).
595+
- 503: DB unreachable or dataset empty/broken.
596+
Deeper checks (gap-analysis completeness, mapping coverage, Neo4j/Redis)
597+
are deliberately excluded and live in ops tooling instead.
598+
"""
599+
if not is_health_endpoint_enabled():
600+
abort(404)
601+
602+
database = db.Node_collection()
603+
result = database.health_check()
604+
status_code = 200 if result.get("ok") else 503
605+
return jsonify(result), status_code
606+
607+
587608
@app.route("/rest/v1/root_cres", methods=["GET"])
588609
def find_root_cres() -> Any:
589610
"""

0 commit comments

Comments
 (0)