Skip to content

Commit c81358c

Browse files
OLS-3221 Address CodeRabbit review comments
- Align 503 liveness response with LivenessResponse schema by adding optional reason field to LivenessResponse model and using Response parameter to set status code - Add NonNegativeInt/PositiveInt validation for PostgresConfig timeout fields (statement_timeout, lock_timeout, health_check_interval) - Validate liveness_db_failure_threshold in OLSConfig with helper function to ensure positive integer value - Add connect_timeout and statement_timeout to health check connection - Increment _consecutive_failures in _mark_unhealthy for proper liveness threshold tracking - Use _safe_rollback in DatabaseError exception paths - Close cursor with try/finally in postgres.py for proper cleanup - Add type annotations to test fixtures and functions - Tighten no-retry assertion in test_postgres.py to verify reconnect is not called on DatabaseError Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6e37719 commit c81358c

8 files changed

Lines changed: 76 additions & 42 deletions

File tree

ols/app/endpoints/health.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import time
1010
from typing import Any
1111

12-
from fastapi import APIRouter, HTTPException, status
12+
from fastapi import APIRouter, HTTPException, Response, status
1313
from langchain_core.messages.ai import AIMessage
1414

1515
from ols import config
@@ -131,16 +131,14 @@ def readiness_probe_get_method() -> ReadinessResponse:
131131

132132

133133
@router.get("/liveness", responses=get_liveness_responses)
134-
def liveness_probe_get_method() -> LivenessResponse:
134+
def liveness_probe_get_method(response: Response) -> LivenessResponse:
135135
"""Live status of service."""
136136
cache = config._conversation_cache
137137
if isinstance(cache, PostgresCache):
138138
threshold = config.ols_config.liveness_db_failure_threshold
139139
with cache._health_lock:
140140
failures = cache._consecutive_failures
141141
if failures >= threshold:
142-
raise HTTPException(
143-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
144-
detail={"alive": False, "reason": "database unreachable"},
145-
)
142+
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
143+
return LivenessResponse(alive=False, reason="database unreachable")
146144
return LivenessResponse(alive=True)

ols/app/models/config.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
BaseModel,
1212
Field,
1313
FilePath,
14+
NonNegativeInt,
1415
PositiveInt,
1516
PrivateAttr,
1617
field_validator,
@@ -52,6 +53,21 @@ def validate_tool_round_cap_fraction_config(v: float) -> float:
5253
return v
5354

5455

56+
def validate_liveness_db_failure_threshold(raw_value: Any) -> int:
57+
"""Validate ``liveness_db_failure_threshold`` for ``OLSConfig``."""
58+
try:
59+
threshold = int(raw_value)
60+
except (TypeError, ValueError) as e:
61+
raise checks.InvalidConfigurationError(
62+
f"liveness_db_failure_threshold must be a positive integer, got {raw_value!r}"
63+
) from e
64+
if threshold < 1:
65+
raise checks.InvalidConfigurationError(
66+
f"liveness_db_failure_threshold must be at least 1, got {threshold}"
67+
)
68+
return threshold
69+
70+
5571
class ModelParameters(BaseModel):
5672
"""Model parameters."""
5773

@@ -798,9 +814,9 @@ class PostgresConfig(BaseModel):
798814
gss_encmode: str = constants.POSTGRES_CACHE_GSSENCMODE
799815
ca_cert_path: Optional[FilePath] = None
800816
max_entries: PositiveInt = constants.POSTGRES_CACHE_MAX_ENTRIES
801-
statement_timeout: int = constants.POSTGRES_STATEMENT_TIMEOUT
802-
lock_timeout: int = constants.POSTGRES_LOCK_TIMEOUT
803-
health_check_interval: int = constants.CACHE_HEALTH_CHECK_INTERVAL
817+
statement_timeout: NonNegativeInt = constants.POSTGRES_STATEMENT_TIMEOUT
818+
lock_timeout: NonNegativeInt = constants.POSTGRES_LOCK_TIMEOUT
819+
health_check_interval: PositiveInt = constants.CACHE_HEALTH_CHECK_INTERVAL
804820
tls_security_profile: Optional["TLSSecurityProfile"] = None
805821

806822
def __init__(self, **data: Any) -> None:
@@ -1239,8 +1255,10 @@ def __init__(
12391255
"offload_storage_path", constants.DEFAULT_OFFLOAD_STORAGE_PATH
12401256
)
12411257

1242-
self.liveness_db_failure_threshold = data.get(
1243-
"liveness_db_failure_threshold", constants.LIVENESS_DB_FAILURE_THRESHOLD
1258+
self.liveness_db_failure_threshold = validate_liveness_db_failure_threshold(
1259+
data.get(
1260+
"liveness_db_failure_threshold", constants.LIVENESS_DB_FAILURE_THRESHOLD
1261+
)
12441262
)
12451263

12461264
def _propagate_tls_profile(self) -> None:

ols/app/models/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ class LivenessResponse(BaseModel):
475475
476476
Attributes:
477477
alive: If app is alive.
478+
reason: Optional reason when not alive.
478479
479480
Example:
480481
```python
@@ -483,6 +484,7 @@ class LivenessResponse(BaseModel):
483484
"""
484485

485486
alive: bool
487+
reason: Optional[str] = None
486488

487489
# provides examples for /docs endpoint
488490
model_config = {

ols/src/cache/postgres_cache.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def _ddl_statements(self) -> list[str]:
184184
def _mark_unhealthy(self) -> None:
185185
"""Mark health status as unhealthy (dual-feed from cache operations)."""
186186
with self._health_lock:
187+
self._consecutive_failures += 1
187188
self._health_status = False
188189

189190
def _connect_health(self) -> None:
@@ -203,10 +204,15 @@ def _connect_health(self) -> None:
203204
"sslmode": config.ssl_mode,
204205
"sslrootcert": config.ca_cert_path,
205206
"gssencmode": config.gss_encmode,
207+
"connect_timeout": 10,
206208
**libpq_tls_params(config.tls_security_profile),
207209
}
208210
self._health_connection = psycopg2.connect(**connect_kwargs)
209211
self._health_connection.autocommit = True
212+
with self._health_connection.cursor() as cursor:
213+
cursor.execute(
214+
"SET statement_timeout = %s", (str(config.statement_timeout),)
215+
)
210216

211217
def _health_check_loop(self) -> None:
212218
"""Background loop that periodically checks DB connectivity."""
@@ -348,7 +354,7 @@ def insert_or_append(
348354
self._safe_rollback()
349355
raise
350356
except psycopg2.DatabaseError as e:
351-
self.connection.rollback()
357+
self._safe_rollback()
352358
logger.error("PostgresCache.insert_or_append: %s", e)
353359
raise CacheError("PostgresCache.insert_or_append", e) from e
354360
finally:
@@ -387,7 +393,7 @@ def delete(
387393
self._safe_rollback()
388394
raise
389395
except psycopg2.DatabaseError as e:
390-
self.connection.rollback()
396+
self._safe_rollback()
391397
logger.error("PostgresCache.delete: %s", e)
392398
raise CacheError("PostgresCache.delete", e) from e
393399
finally:

ols/utils/postgres.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,12 @@ def connect(self) -> None:
104104
raise
105105
self.connection.autocommit = True
106106
cursor = self.connection.cursor()
107-
cursor.execute("SET statement_timeout = %s", (str(config.statement_timeout),))
108-
cursor.close()
107+
try:
108+
cursor.execute(
109+
"SET statement_timeout = %s", (str(config.statement_timeout),)
110+
)
111+
finally:
112+
cursor.close()
109113

110114
def connected(self) -> bool:
111115
"""Check if the connection to Postgres is alive."""

tests/unit/app/endpoints/test_health.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,9 @@ def test_readiness_probe_get_method_cache_not_ready():
222222

223223
def test_liveness_probe_get_method():
224224
"""Test the liveness_probe function when no postgres cache is configured."""
225-
response = liveness_probe_get_method()
226-
assert response == LivenessResponse(alive=True)
225+
mock_response = Mock()
226+
result = liveness_probe_get_method(mock_response)
227+
assert result == LivenessResponse(alive=True)
227228

228229

229230
def test_liveness_probe_returns_alive_when_postgres_healthy():
@@ -239,8 +240,9 @@ def test_liveness_probe_returns_alive_when_postgres_healthy():
239240
patch.object(config, "_conversation_cache", cache),
240241
patch.object(config.ols_config, "liveness_db_failure_threshold", 3),
241242
):
242-
response = liveness_probe_get_method()
243-
assert response == LivenessResponse(alive=True)
243+
mock_response = Mock()
244+
result = liveness_probe_get_method(mock_response)
245+
assert result == LivenessResponse(alive=True)
244246

245247

246248
def test_liveness_probe_returns_503_when_postgres_unhealthy():
@@ -257,10 +259,10 @@ def test_liveness_probe_returns_503_when_postgres_unhealthy():
257259
patch.object(config, "_conversation_cache", cache),
258260
patch.object(config.ols_config, "liveness_db_failure_threshold", 3),
259261
):
260-
with pytest.raises(HTTPException) as exc_info:
261-
liveness_probe_get_method()
262-
assert exc_info.value.status_code == 503
263-
assert exc_info.value.detail["reason"] == "database unreachable"
262+
mock_response = Mock()
263+
result = liveness_probe_get_method(mock_response)
264+
assert mock_response.status_code == 503
265+
assert result == LivenessResponse(alive=False, reason="database unreachable")
264266

265267

266268
def test_liveness_probe_returns_alive_when_below_threshold():
@@ -276,5 +278,6 @@ def test_liveness_probe_returns_alive_when_below_threshold():
276278
patch.object(config, "_conversation_cache", cache),
277279
patch.object(config.ols_config, "liveness_db_failure_threshold", 3),
278280
):
279-
response = liveness_probe_get_method()
280-
assert response == LivenessResponse(alive=True)
281+
mock_response = Mock()
282+
result = liveness_probe_get_method(mock_response)
283+
assert result == LivenessResponse(alive=True)

tests/unit/cache/test_postgres_cache.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Unit tests for PostgresCache class."""
22

33
import json
4+
from typing import Generator
45
from unittest.mock import MagicMock, call, patch
56

67
import psycopg2
@@ -17,7 +18,7 @@
1718

1819

1920
@pytest.fixture(autouse=True)
20-
def _suppress_health_loop():
21+
def _suppress_health_loop() -> Generator[None, None, None]:
2122
"""Prevent the background health-check thread from making real DB calls."""
2223
with patch.object(PostgresCache, "_health_check_loop"):
2324
yield
@@ -789,7 +790,7 @@ def test_cleanup_method_when_clean_performed():
789790
mock_cursor.execute.assert_has_calls(calls, any_order=False)
790791

791792

792-
def test_ready_returns_health_status():
793+
def test_ready_returns_health_status() -> None:
793794
"""Test that ready() returns the background health loop status."""
794795
with patch("psycopg2.connect"):
795796
config = PostgresConfig()
@@ -808,7 +809,7 @@ def test_ready_returns_health_status():
808809
assert cache.ready() is True
809810

810811

811-
def test_mark_unhealthy():
812+
def test_mark_unhealthy() -> None:
812813
"""Test that _mark_unhealthy sets health status to False."""
813814
with patch("psycopg2.connect"):
814815
config = PostgresConfig()
@@ -819,7 +820,7 @@ def test_mark_unhealthy():
819820
assert cache.ready() is False
820821

821822

822-
def test_lock_timeout():
823+
def test_lock_timeout() -> None:
823824
"""Test that lock acquisition timeout raises CacheError."""
824825
with patch("psycopg2.connect"):
825826
config = PostgresConfig(lock_timeout=0)
@@ -834,7 +835,7 @@ def test_lock_timeout():
834835
cache._tx_lock.release()
835836

836837

837-
def test_health_check_loop_recovers():
838+
def test_health_check_loop_recovers() -> None:
838839
"""Test that the health check loop sets health status on success."""
839840
with patch("psycopg2.connect"):
840841
config = PostgresConfig()
@@ -851,7 +852,7 @@ def test_health_check_loop_recovers():
851852
assert cache.ready() is True
852853

853854

854-
def test_consecutive_failures_tracked():
855+
def test_consecutive_failures_tracked() -> None:
855856
"""Test that consecutive failures are tracked."""
856857
with patch("psycopg2.connect"):
857858
config = PostgresConfig()

tests/unit/utils/test_postgres.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def do_work_database_error(self) -> str:
7878
"""Raise DatabaseError (SQL error, no retry)."""
7979
raise psycopg2.DatabaseError("SQL syntax error")
8080

81-
def test_auto_reconnects_when_disconnected(self):
81+
def test_auto_reconnects_when_disconnected(self) -> None:
8282
"""Decorator calls connect() when not connected."""
8383
c = self.Connectable()
8484
c.disconnect()
@@ -88,15 +88,15 @@ def test_auto_reconnects_when_disconnected(self):
8888
assert c.connected() is True
8989
assert result == "done"
9090

91-
def test_does_not_reconnect_when_connected(self):
91+
def test_does_not_reconnect_when_connected(self) -> None:
9292
"""Decorator skips connect() when already connected."""
9393
c = self.Connectable()
9494
c.connect()
9595
with patch.object(c, "connect") as mock_connect:
9696
c.do_work()
9797
mock_connect.assert_not_called()
9898

99-
def test_propagates_exception_after_reconnect(self):
99+
def test_propagates_exception_after_reconnect(self) -> None:
100100
"""Decorator reconnects then lets the wrapped exception propagate."""
101101
c = self.Connectable(raise_on_call=True)
102102
c.disconnect()
@@ -105,38 +105,40 @@ def test_propagates_exception_after_reconnect(self):
105105
c.do_work()
106106
assert c.connected() is True
107107

108-
def test_operational_error_triggers_reconnect_and_retry(self):
108+
def test_operational_error_triggers_reconnect_and_retry(self) -> None:
109109
"""OperationalError causes reconnect + retry, succeeding on second attempt."""
110110
c = self.Connectable()
111111
c.connect()
112112
result = c.do_work_operational_error()
113113
assert result == "recovered"
114114
assert c._unhealthy_marked is True
115115

116-
def test_interface_error_triggers_reconnect_and_retry(self):
116+
def test_interface_error_triggers_reconnect_and_retry(self) -> None:
117117
"""InterfaceError causes reconnect + retry, succeeding on second attempt."""
118118
c = self.Connectable()
119119
c.connect()
120120
result = c.do_work_interface_error()
121121
assert result == "recovered"
122122
assert c._unhealthy_marked is True
123123

124-
def test_database_error_wraps_in_cache_error_no_retry(self):
124+
def test_database_error_wraps_in_cache_error_no_retry(self) -> None:
125125
"""DatabaseError is wrapped in CacheError without reconnect attempt."""
126126
c = self.Connectable()
127127
c.connect()
128-
with pytest.raises(CacheError, match="SQL syntax error"):
129-
c.do_work_database_error()
128+
with patch.object(c, "connect") as mock_connect:
129+
with pytest.raises(CacheError, match="SQL syntax error"):
130+
c.do_work_database_error()
131+
mock_connect.assert_not_called()
132+
assert c._unhealthy_marked is False
130133

131-
def test_connection_error_reconnect_failure_raises_cache_error(self):
134+
def test_connection_error_reconnect_failure_raises_cache_error(self) -> None:
132135
"""When reconnect fails after OperationalError, CacheError is raised."""
133136
c = self.Connectable()
134137
c.connect()
135-
# Make connect() fail on reconnect attempt
136138
original_connect = c.connect
137139
call_count = [0]
138140

139-
def failing_connect():
141+
def failing_connect() -> None:
140142
call_count[0] += 1
141143
if call_count[0] > 0:
142144
raise psycopg2.OperationalError("cannot connect")

0 commit comments

Comments
 (0)