Skip to content

Commit 6fec1c8

Browse files
Surface OpenSearch errors in readiness check
Replace the previous ping-based readiness probe with a cluster.health() call so transport/auth/connection errors are propagated instead of swallowed. Track the last error and include structured details (error_type, status_code, info) in per-attempt logs, and embed the last error in the final OpenSearchNotReadyError message. Add unit tests to verify green/yellow success, auth failures surface status_code/info, and connection errors without status_code are reported.
1 parent 1a059d2 commit 6fec1c8

2 files changed

Lines changed: 121 additions & 24 deletions

File tree

src/utils/opensearch_utils.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ async def wait_for_opensearch(
7171
Raises:
7272
OpenSearchNotReadyError: If OpenSearch fails to become ready within the retry limit.
7373
"""
74+
last_error: str | None = None
7475
for attempt in range(max_retries):
7576
display_attempt: int = attempt + 1
7677

@@ -81,34 +82,34 @@ async def wait_for_opensearch(
8182
)
8283

8384
try:
84-
# Simple ping to check connection
85-
if await opensearch_client.ping():
86-
# Also check cluster health
87-
health = await opensearch_client.cluster.health()
88-
status = health.get("status")
89-
if status in ["green", "yellow"]:
90-
logger.info(
91-
"Successfully verified that OpenSearch is ready.",
92-
attempt=display_attempt,
93-
status=status,
94-
)
95-
return
96-
else:
97-
logger.warning(
98-
"OpenSearch is up but cluster health is red.",
99-
attempt=display_attempt,
100-
status=status,
101-
)
102-
else:
103-
logger.warning(
104-
"OpenSearch ping failed.",
85+
# cluster.health() (GET /_cluster/health) raises on
86+
# connection/auth/transport errors, unlike ping() (HEAD /), which
87+
# swallows them and returns False — hiding the real cause (e.g. a
88+
# rejected JWT, a 403, or a connection refusal).
89+
health = await opensearch_client.cluster.health()
90+
status = health.get("status")
91+
if status in ["green", "yellow"]:
92+
logger.info(
93+
"Successfully verified that OpenSearch is ready.",
10594
attempt=display_attempt,
95+
status=status,
10696
)
97+
return
98+
last_error = f"cluster health status={status}"
99+
logger.warning(
100+
"OpenSearch is up but cluster health is red.",
101+
attempt=display_attempt,
102+
status=status,
103+
)
107104
except Exception as e:
105+
last_error = f"{type(e).__name__}: {e}"
108106
logger.warning(
109-
"OpenSearch is not ready.",
107+
"OpenSearch readiness check failed.",
110108
attempt=display_attempt,
109+
error_type=type(e).__name__,
110+
status_code=getattr(e, "status_code", None),
111111
error=str(e),
112+
info=getattr(e, "info", None),
112113
)
113114

114115
if attempt < max_retries - 1:
@@ -123,8 +124,8 @@ async def wait_for_opensearch(
123124

124125
await asyncio.sleep(delay)
125126

126-
message: str = "Failed to verify whether OpenSearch is ready."
127-
logger.error(message)
127+
message: str = f"Failed to verify whether OpenSearch is ready. Last error: {last_error}"
128+
logger.error(message, last_error=last_error)
128129
raise OpenSearchNotReadyError(message)
129130

130131

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""wait_for_opensearch must surface the real failure reason.
2+
3+
Previously the readiness probe gated on ``client.ping()`` (HEAD /), which
4+
opensearchpy implements by swallowing transport/auth/connection errors and
5+
returning ``False`` — collapsing a rejected JWT (401/403), a connection
6+
refusal, and a TLS error into a single uninformative "ping failed" log.
7+
8+
These tests pin the new behaviour: the probe calls ``cluster.health()``
9+
(which raises), the per-attempt warning carries ``status_code``/``info``, and
10+
the final ``OpenSearchNotReadyError`` message embeds the last error.
11+
"""
12+
13+
import sys
14+
from pathlib import Path
15+
from unittest.mock import AsyncMock, MagicMock
16+
17+
import pytest
18+
19+
ROOT = Path(__file__).resolve().parent.parent.parent
20+
SRC = ROOT / "src"
21+
if str(SRC) not in sys.path:
22+
sys.path.insert(0, str(SRC))
23+
24+
from utils.opensearch_utils import ( # noqa: E402
25+
OpenSearchNotReadyError,
26+
wait_for_opensearch,
27+
)
28+
29+
30+
class _FakeTransportError(Exception):
31+
"""Mimics opensearchpy's TransportError: carries status_code + info."""
32+
33+
def __init__(self, status_code, error, info):
34+
super().__init__(error)
35+
self.status_code = status_code
36+
self.error = error
37+
self.info = info
38+
39+
40+
def _client_with_health(health_side_effect):
41+
client = MagicMock()
42+
client.cluster.health = AsyncMock(side_effect=health_side_effect)
43+
return client
44+
45+
46+
@pytest.mark.asyncio
47+
async def test_ready_when_health_green():
48+
client = _client_with_health([{"status": "green"}])
49+
# Should return without raising; no sleeps on first-attempt success.
50+
await wait_for_opensearch(client, max_retries=1)
51+
client.cluster.health.assert_awaited_once()
52+
53+
54+
@pytest.mark.asyncio
55+
async def test_auth_failure_is_surfaced(monkeypatch):
56+
err = _FakeTransportError(403, "AuthorizationException", {"reason": "invalid jwt"})
57+
client = _client_with_health(err)
58+
59+
captured = []
60+
monkeypatch.setattr(
61+
"utils.opensearch_utils.logger.warning",
62+
lambda msg, **kw: captured.append((msg, kw)),
63+
)
64+
65+
with pytest.raises(OpenSearchNotReadyError) as excinfo:
66+
# tiny delays so the retry loop doesn't actually sleep meaningfully
67+
await wait_for_opensearch(client, max_retries=2, base_delay=0.0, max_delay=0.0)
68+
69+
# The real reason must reach the final exception message...
70+
assert "403" in str(excinfo.value) or "AuthorizationException" in str(excinfo.value)
71+
# ...and the per-attempt warning must carry structured detail.
72+
assert captured, "expected at least one readiness-failure warning"
73+
_, kw = captured[0]
74+
assert kw["status_code"] == 403
75+
assert kw["info"] == {"reason": "invalid jwt"}
76+
assert kw["error_type"] == "_FakeTransportError"
77+
78+
79+
@pytest.mark.asyncio
80+
async def test_connection_error_without_status_code(monkeypatch):
81+
# A plain ConnectionError-style failure has no status_code attribute.
82+
client = _client_with_health(ConnectionError("connection refused"))
83+
84+
captured = []
85+
monkeypatch.setattr(
86+
"utils.opensearch_utils.logger.warning",
87+
lambda msg, **kw: captured.append((msg, kw)),
88+
)
89+
90+
with pytest.raises(OpenSearchNotReadyError) as excinfo:
91+
await wait_for_opensearch(client, max_retries=1, base_delay=0.0, max_delay=0.0)
92+
93+
assert "connection refused" in str(excinfo.value)
94+
_, kw = captured[0]
95+
assert kw["status_code"] is None
96+
assert kw["error_type"] == "ConnectionError"

0 commit comments

Comments
 (0)