Skip to content

Commit 1c01590

Browse files
amosttAygentic
andauthored
feat(api): add operational endpoints health/readiness/version [AYG-68] (#4)
* feat(api): add operational endpoints health/readiness/version [AYG-68] - Add /healthz, /readyz, /version root endpoints - Add readiness Supabase check with resilient 503 response path - Add integration tests for schemas, auth-free access, and failure paths Fixes AYG-68 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com> * fix(api): address code review findings for health endpoints [AYG-68] - Offload sync Supabase check to thread pool via anyio.to_thread.run_sync to avoid blocking the async event loop (BUG-001) - Use select("*", head=True) for lighter HEAD connectivity probe (QUAL-001) - Add explicit AttributeError catch for missing Supabase client (QUAL-002) - Replace str(exc) with error_type= in logger to prevent credential leak (SEC-001) - Patch settings in test_includes_service_name and test_default_values_for_unset_env_vars for CI robustness (TEST-001, TEST-002) - Strengthen test_exception_does_not_crash to assert full body values (TEST-003) Related to AYG-68 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com> --------- Co-authored-by: Aygentic <noreply@aygentic.com>
1 parent 70669ef commit 1c01590

File tree

4 files changed

+366
-0
lines changed

4 files changed

+366
-0
lines changed

backend/app/api/routes/health.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Operational endpoints: health, readiness, and version.
2+
3+
These endpoints are public (no authentication required) and are used by
4+
container orchestrators for liveness/readiness probes and by API gateways
5+
for service registration and discovery.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import anyio
11+
from fastapi import APIRouter, Request
12+
from fastapi.responses import JSONResponse
13+
from postgrest.exceptions import APIError
14+
15+
from app.core.config import settings
16+
from app.core.logging import get_logger
17+
18+
logger = get_logger(module=__name__)
19+
20+
router = APIRouter(tags=["operations"])
21+
22+
23+
@router.get("/healthz")
24+
async def healthz() -> dict[str, str]:
25+
"""Liveness probe — returns 200 immediately with no dependency checks."""
26+
return {"status": "ok"}
27+
28+
29+
def _check_supabase(request: Request) -> str:
30+
"""Check Supabase connectivity via a lightweight PostgREST HEAD request.
31+
32+
Returns ``"ok"`` if the server is reachable (even if the probe table does
33+
not exist) or ``"error"`` if the connection cannot be established.
34+
"""
35+
try:
36+
client = request.app.state.supabase
37+
client.table("_health_check").select("*", head=True).execute()
38+
return "ok"
39+
except APIError:
40+
# PostgREST returned an HTTP error (e.g. table not found).
41+
# The server IS reachable — the check passes.
42+
return "ok"
43+
except AttributeError:
44+
logger.error("supabase_client_not_initialized", check="supabase")
45+
return "error"
46+
except Exception as exc:
47+
logger.warning(
48+
"readiness_check_failed",
49+
check="supabase",
50+
error_type=type(exc).__name__,
51+
)
52+
return "error"
53+
54+
55+
@router.get("/readyz")
56+
async def readyz(request: Request) -> JSONResponse:
57+
"""Readiness probe — checks Supabase connectivity."""
58+
supabase_status = await anyio.to_thread.run_sync(lambda: _check_supabase(request))
59+
is_ready = supabase_status == "ok"
60+
return JSONResponse(
61+
status_code=200 if is_ready else 503,
62+
content={
63+
"status": "ready" if is_ready else "not_ready",
64+
"checks": {"supabase": supabase_status},
65+
},
66+
)
67+
68+
69+
@router.get("/version")
70+
async def version() -> dict[str, str]:
71+
"""Build metadata from environment variables for gateway discoverability."""
72+
return {
73+
"service_name": settings.SERVICE_NAME,
74+
"version": settings.SERVICE_VERSION,
75+
"commit": settings.GIT_COMMIT,
76+
"build_time": settings.BUILD_TIME,
77+
"environment": settings.ENVIRONMENT,
78+
}

backend/app/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from starlette.middleware.cors import CORSMiddleware
88

99
from app.api.main import api_router
10+
from app.api.routes.health import router as health_router
1011
from app.core.config import settings
1112
from app.core.errors import register_exception_handlers
1213
from app.core.http_client import HttpClient
@@ -78,3 +79,6 @@ def custom_generate_unique_id(route: APIRoute) -> str:
7879
app.add_middleware(RequestPipelineMiddleware, environment=settings.ENVIRONMENT)
7980

8081
app.include_router(api_router, prefix=settings.API_V1_STR)
82+
83+
# Operational endpoints at root level (no API prefix) — public, no auth required.
84+
app.include_router(health_router)

backend/tests/integration/__init__.py

Whitespace-only changes.
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""Integration tests for operational endpoints (/healthz, /readyz, /version).
2+
3+
Uses a minimal FastAPI app with the health router mounted. All external
4+
dependencies (Supabase) are mocked — no running database required.
5+
6+
Run:
7+
uv run pytest backend/tests/integration/test_health.py -v
8+
"""
9+
10+
import os
11+
12+
# Ensure required env vars are set for config.Settings import.
13+
# setdefault does NOT overwrite existing env vars; values below are
14+
# only used when running tests outside Docker (no .env loaded).
15+
os.environ.setdefault("SUPABASE_URL", "http://localhost:54321")
16+
os.environ.setdefault("SUPABASE_SERVICE_KEY", "test-service-key")
17+
os.environ.setdefault("CLERK_SECRET_KEY", "test-clerk-key")
18+
19+
from unittest.mock import MagicMock, patch
20+
21+
from fastapi import FastAPI
22+
from fastapi.testclient import TestClient
23+
from postgrest.exceptions import APIError
24+
25+
from app.api.routes.health import router as health_router
26+
27+
# ---------------------------------------------------------------------------
28+
# Helpers
29+
# ---------------------------------------------------------------------------
30+
31+
32+
def _make_app(supabase_mock: MagicMock | None = None) -> FastAPI:
33+
"""Create a minimal FastAPI app with the health router."""
34+
app = FastAPI()
35+
app.include_router(health_router)
36+
if supabase_mock is not None:
37+
app.state.supabase = supabase_mock
38+
return app
39+
40+
41+
def _healthy_supabase() -> MagicMock:
42+
"""Return a mock Supabase client that reports healthy."""
43+
mock = MagicMock()
44+
mock.table.return_value.select.return_value.execute.return_value = MagicMock()
45+
return mock
46+
47+
48+
def _unreachable_supabase() -> MagicMock:
49+
"""Return a mock Supabase client that simulates connection failure."""
50+
mock = MagicMock()
51+
mock.table.side_effect = ConnectionError("Connection refused")
52+
return mock
53+
54+
55+
def _api_error_supabase() -> MagicMock:
56+
"""Return a mock Supabase client where table doesn't exist (PostgREST APIError).
57+
58+
This simulates the table not existing, but the server being reachable.
59+
"""
60+
mock = MagicMock()
61+
mock.table.return_value.select.return_value.execute.side_effect = APIError(
62+
{"message": "relation '_health_check' does not exist", "code": "PGRST204"}
63+
)
64+
return mock
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# /healthz — Liveness probe
69+
# ---------------------------------------------------------------------------
70+
71+
72+
class TestHealthz:
73+
"""Liveness probe tests."""
74+
75+
def test_returns_200_ok(self) -> None:
76+
"""GET /healthz returns 200 with {"status": "ok"}."""
77+
client = TestClient(_make_app())
78+
79+
response = client.get("/healthz")
80+
81+
assert response.status_code == 200
82+
assert response.json() == {"status": "ok"}
83+
84+
def test_no_auth_required(self) -> None:
85+
"""GET /healthz succeeds without Authorization header."""
86+
client = TestClient(_make_app())
87+
88+
response = client.get("/healthz", headers={})
89+
90+
assert response.status_code == 200
91+
92+
def test_response_schema_exact(self) -> None:
93+
"""Response contains only the 'status' field — no extra keys."""
94+
client = TestClient(_make_app())
95+
96+
data = client.get("/healthz").json()
97+
98+
assert set(data.keys()) == {"status"}
99+
100+
def test_never_checks_dependencies(self) -> None:
101+
"""Healthz does not access app.state.supabase (liveness only)."""
102+
mock = MagicMock()
103+
client = TestClient(_make_app(supabase_mock=mock))
104+
105+
client.get("/healthz")
106+
107+
mock.table.assert_not_called()
108+
109+
110+
# ---------------------------------------------------------------------------
111+
# /readyz — Readiness probe
112+
# ---------------------------------------------------------------------------
113+
114+
115+
class TestReadyz:
116+
"""Readiness probe tests."""
117+
118+
def test_healthy_supabase_returns_200(self) -> None:
119+
"""GET /readyz returns 200 when Supabase is reachable."""
120+
client = TestClient(_make_app(supabase_mock=_healthy_supabase()))
121+
122+
response = client.get("/readyz")
123+
124+
assert response.status_code == 200
125+
assert response.json() == {
126+
"status": "ready",
127+
"checks": {"supabase": "ok"},
128+
}
129+
130+
def test_unreachable_supabase_returns_503(self) -> None:
131+
"""GET /readyz returns 503 when Supabase is unreachable."""
132+
client = TestClient(_make_app(supabase_mock=_unreachable_supabase()))
133+
134+
response = client.get("/readyz")
135+
136+
assert response.status_code == 503
137+
assert response.json() == {
138+
"status": "not_ready",
139+
"checks": {"supabase": "error"},
140+
}
141+
142+
def test_api_error_still_reports_ok(self) -> None:
143+
"""PostgREST APIError (table not found) means server IS reachable."""
144+
client = TestClient(_make_app(supabase_mock=_api_error_supabase()))
145+
146+
response = client.get("/readyz")
147+
148+
assert response.status_code == 200
149+
assert response.json()["checks"]["supabase"] == "ok"
150+
151+
def test_missing_supabase_client_returns_503(self) -> None:
152+
"""GET /readyz returns 503 when app.state.supabase is not set."""
153+
client = TestClient(_make_app()) # No supabase mock set
154+
155+
response = client.get("/readyz")
156+
157+
assert response.status_code == 503
158+
assert response.json()["checks"]["supabase"] == "error"
159+
160+
def test_exception_does_not_crash(self) -> None:
161+
"""Supabase check exception returns valid JSON, not a 500 crash."""
162+
mock = MagicMock()
163+
mock.table.side_effect = RuntimeError("unexpected")
164+
client = TestClient(_make_app(supabase_mock=mock))
165+
166+
response = client.get("/readyz")
167+
168+
assert response.status_code == 503
169+
assert response.headers["content-type"] == "application/json"
170+
body = response.json()
171+
assert body["status"] == "not_ready"
172+
assert body["checks"]["supabase"] == "error"
173+
174+
def test_no_auth_required(self) -> None:
175+
"""GET /readyz succeeds without Authorization header."""
176+
client = TestClient(_make_app(supabase_mock=_healthy_supabase()))
177+
178+
response = client.get("/readyz", headers={})
179+
180+
assert response.status_code == 200
181+
182+
def test_response_schema_exact(self) -> None:
183+
"""Response contains only 'status' and 'checks' — no extra keys."""
184+
client = TestClient(_make_app(supabase_mock=_healthy_supabase()))
185+
186+
data = client.get("/readyz").json()
187+
188+
assert set(data.keys()) == {"status", "checks"}
189+
assert set(data["checks"].keys()) == {"supabase"}
190+
191+
192+
# ---------------------------------------------------------------------------
193+
# /version — Build metadata
194+
# ---------------------------------------------------------------------------
195+
196+
197+
class TestVersion:
198+
"""Build metadata endpoint tests."""
199+
200+
def test_returns_200_with_metadata(self) -> None:
201+
"""GET /version returns 200 with all required metadata fields."""
202+
client = TestClient(_make_app())
203+
204+
response = client.get("/version")
205+
206+
assert response.status_code == 200
207+
data = response.json()
208+
assert "service_name" in data
209+
assert "version" in data
210+
assert "commit" in data
211+
assert "build_time" in data
212+
assert "environment" in data
213+
214+
def test_includes_service_name(self) -> None:
215+
"""GET /version includes service_name for gateway discoverability."""
216+
mock_settings = MagicMock()
217+
mock_settings.SERVICE_NAME = "my-service"
218+
mock_settings.SERVICE_VERSION = "0.1.0"
219+
mock_settings.GIT_COMMIT = "unknown"
220+
mock_settings.BUILD_TIME = "unknown"
221+
mock_settings.ENVIRONMENT = "local"
222+
223+
with patch("app.api.routes.health.settings", mock_settings):
224+
data = TestClient(_make_app()).get("/version").json()
225+
226+
assert data["service_name"] == "my-service"
227+
228+
def test_default_values_for_unset_env_vars(self) -> None:
229+
"""GIT_COMMIT and BUILD_TIME default to 'unknown' when not set."""
230+
mock_settings = MagicMock()
231+
mock_settings.SERVICE_NAME = "my-service"
232+
mock_settings.SERVICE_VERSION = "0.1.0"
233+
mock_settings.GIT_COMMIT = "unknown"
234+
mock_settings.BUILD_TIME = "unknown"
235+
mock_settings.ENVIRONMENT = "local"
236+
237+
with patch("app.api.routes.health.settings", mock_settings):
238+
data = TestClient(_make_app()).get("/version").json()
239+
240+
assert data["commit"] == "unknown"
241+
assert data["build_time"] == "unknown"
242+
243+
def test_custom_settings_values(self) -> None:
244+
"""Version endpoint reflects custom settings values."""
245+
mock_settings = MagicMock()
246+
mock_settings.SERVICE_NAME = "custom-service"
247+
mock_settings.SERVICE_VERSION = "2.0.0"
248+
mock_settings.GIT_COMMIT = "abc1234"
249+
mock_settings.BUILD_TIME = "2026-02-28T00:00:00Z"
250+
mock_settings.ENVIRONMENT = "staging"
251+
252+
with patch("app.api.routes.health.settings", mock_settings):
253+
client = TestClient(_make_app())
254+
data = client.get("/version").json()
255+
256+
assert data == {
257+
"service_name": "custom-service",
258+
"version": "2.0.0",
259+
"commit": "abc1234",
260+
"build_time": "2026-02-28T00:00:00Z",
261+
"environment": "staging",
262+
}
263+
264+
def test_response_schema_exact(self) -> None:
265+
"""Response contains exactly the five expected fields."""
266+
client = TestClient(_make_app())
267+
268+
data = client.get("/version").json()
269+
270+
assert set(data.keys()) == {
271+
"service_name",
272+
"version",
273+
"commit",
274+
"build_time",
275+
"environment",
276+
}
277+
278+
def test_no_auth_required(self) -> None:
279+
"""GET /version succeeds without Authorization header."""
280+
client = TestClient(_make_app())
281+
282+
response = client.get("/version", headers={})
283+
284+
assert response.status_code == 200

0 commit comments

Comments
 (0)