Skip to content

Commit fec8dfe

Browse files
fix: API OOM 137 - add /api/livez, connection pooling, close pool on shutdown (#33)
- Add /api/livez lightweight liveness probe (no DB) for k8s liveness checks - Add DB connection pooling (ThreadedConnectionPool, 2-10 conns) to reduce connection churn and avoid exhaustion under load - Close connection pool on app/collector shutdown - Add tests for livez and health endpoints Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5909121 commit fec8dfe

4 files changed

Lines changed: 104 additions & 18 deletions

File tree

‎src/app_readonly.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async def lifespan(app: FastAPI):
3030
# Shutdown
3131
logger.info("Shutting down Hell Divers 2 API")
3232
scraper.close()
33+
db.close_pool()
3334

3435

3536
# Initialize FastAPI app
@@ -394,9 +395,17 @@ async def get_config():
394395
}
395396

396397

398+
@app.get("/api/livez", tags=["Health"])
399+
async def liveness_probe():
400+
"""Lightweight liveness probe - no DB, for Kubernetes liveness checks.
401+
Returns 200 if the process is alive. Use /api/health for readiness (includes DB check).
402+
"""
403+
return {"status": "ok"}
404+
405+
397406
@app.get("/api/health", tags=["Health"])
398407
async def health_check():
399-
"""Health check endpoint"""
408+
"""Health check endpoint - includes DB and upstream status (for readiness)."""
400409
# Returns local service status and upstream API status
401410
upstream_status = db.get_upstream_status()
402411
return {

‎src/database.py‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,66 @@
11
import json
22
import logging
33
import psycopg2
4+
from psycopg2 import pool
45
from datetime import datetime, timezone
56
from typing import Dict, List, Optional
67

78
logger = logging.getLogger(__name__)
89

10+
# Connection pool defaults
11+
DEFAULT_POOL_MIN_CONN = 2
12+
DEFAULT_POOL_MAX_CONN = 10
13+
914

1015
class Database:
11-
"""PostgreSQL database manager for Hell Divers 2 API data"""
16+
"""PostgreSQL database manager for Hell Divers 2 API data with connection pooling"""
1217

1318
def __init__(self, database_url: Optional[str] = None):
1419
"""
15-
Initialize database connection
16-
20+
Initialize database connection pool
21+
1722
Args:
1823
database_url: PostgreSQL connection string (postgresql://user:pass@host:port/db)
1924
If None, will try to get from DATABASE_URL env var
2025
"""
2126
if database_url is None:
2227
import os
2328
database_url = os.getenv("DATABASE_URL", "")
24-
29+
2530
if not database_url:
2631
raise ValueError("DATABASE_URL must be provided")
27-
32+
2833
self.database_url = database_url
2934
self._initialized = False
30-
# Lazy initialization - only connect when first needed
35+
self._pool: Optional[pool.ThreadedConnectionPool] = None
36+
37+
def _get_pool(self) -> pool.ThreadedConnectionPool:
38+
"""Get or create the connection pool (lazy init)"""
39+
if self._pool is None:
40+
self._pool = pool.ThreadedConnectionPool(
41+
minconn=DEFAULT_POOL_MIN_CONN,
42+
maxconn=DEFAULT_POOL_MAX_CONN,
43+
dsn=self.database_url,
44+
)
45+
logger.info("Database connection pool initialized")
46+
return self._pool
3147

3248
def _get_connection(self):
33-
"""Get a database connection"""
34-
return psycopg2.connect(self.database_url)
49+
"""Get a database connection from the pool (returns to pool on close)."""
50+
conn = self._get_pool().getconn()
51+
# Wrap so conn.close() returns to pool instead of closing the underlying connection
52+
def _close():
53+
self._get_pool().putconn(conn)
54+
55+
conn.close = _close
56+
return conn
57+
58+
def close_pool(self):
59+
"""Close the connection pool. Call on application shutdown."""
60+
if self._pool is not None:
61+
self._pool.closeall()
62+
self._pool = None
63+
logger.info("Database connection pool closed")
3564

3665
@staticmethod
3766
def _parse_expiration_time(expiration_time: str) -> Optional[datetime]:

‎src/main.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,39 +46,39 @@ def run_collector():
4646
from src.database import Database
4747
from src.collector import DataCollector
4848
from src.config import Config
49-
49+
5050
global _collector
51-
51+
5252
logger.info("Starting High Command API Collector")
53-
53+
5454
# Initialize database
5555
database_url = Config.DATABASE_URL
5656
if not database_url:
5757
raise ValueError("DATABASE_URL environment variable must be set")
58-
58+
5959
db = Database(database_url)
6060
logger.info("Database initialized with connection string")
61-
61+
6262
# Initialize collector
6363
interval = Config.SCRAPE_INTERVAL
6464
_collector = DataCollector(db, interval=interval)
65-
65+
6666
def signal_handler(sig, frame):
6767
"""Handle shutdown signals"""
6868
logger.info("Received shutdown signal, stopping collector...")
6969
if _collector:
7070
_collector.stop()
7171
sys.exit(0)
72-
72+
7373
# Register signal handlers
7474
signal.signal(signal.SIGINT, signal_handler)
7575
signal.signal(signal.SIGTERM, signal_handler)
76-
76+
7777
# Start collector
7878
try:
7979
_collector.start()
8080
logger.info(f"Collector started with {interval}s interval")
81-
81+
8282
# Keep running
8383
while _collector.is_running:
8484
time.sleep(1)
@@ -89,6 +89,7 @@ def signal_handler(sig, frame):
8989
finally:
9090
if _collector:
9191
_collector.stop()
92+
db.close_pool()
9293
logger.info("Collector stopped")
9394

9495

‎tests/test_app_readonly.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Unit tests for app_readonly (read-only API used in production)."""
2+
3+
import pytest
4+
from fastapi.testclient import TestClient
5+
from unittest.mock import patch
6+
from src.app_readonly import app
7+
8+
9+
@pytest.fixture
10+
def client():
11+
"""Create test client for readonly app"""
12+
return TestClient(app)
13+
14+
15+
class TestLivezEndpoint:
16+
"""Test lightweight liveness probe - no DB dependency."""
17+
18+
def test_livez_returns_200(self, client):
19+
"""Liveness probe returns 200 without hitting DB."""
20+
response = client.get("/api/livez")
21+
assert response.status_code == 200
22+
data = response.json()
23+
assert data == {"status": "ok"}
24+
25+
26+
class TestHealthEndpoint:
27+
"""Test health endpoint (includes DB check)."""
28+
29+
@patch("src.app_readonly.db.get_upstream_status")
30+
def test_health_upstream_online(self, mock_status, client):
31+
"""Health check when upstream is online."""
32+
mock_status.return_value = True
33+
response = client.get("/api/health")
34+
assert response.status_code == 200
35+
data = response.json()
36+
assert data["status"] == "healthy"
37+
assert data["mode"] == "read-only"
38+
assert data["upstream_api"] == "online"
39+
40+
@patch("src.app_readonly.db.get_upstream_status")
41+
def test_health_upstream_offline(self, mock_status, client):
42+
"""Health check when upstream is offline."""
43+
mock_status.return_value = False
44+
response = client.get("/api/health")
45+
assert response.status_code == 200
46+
data = response.json()
47+
assert data["upstream_api"] == "offline"

0 commit comments

Comments
 (0)