Skip to content

Commit d696416

Browse files
committed
Add a class for curl-like probing of user apps and associated tests.
1 parent cdfe5ec commit d696416

5 files changed

Lines changed: 239 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ python3 -m serve_event_listener.main --namespace <some-namespace> --label-select
6767
### Running the unit tests
6868
6969
```bash
70-
python -m unittest discover -s tests
70+
python -m unittest discover -s tests/unit/
7171
```
7272
7373
### Running the integration tests

serve_event_listener/http_client/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def _request(
2525
token_fetcher: Optional[Callable[[], str]] = None, # optional
2626
auth_scheme: str = "Token", # can easily change to Bearer in the future
2727
sleep_fn: Callable[[float], None] = time.sleep, # overridable in tests
28+
**request_kwargs, # pass-through for requests.Session.request
2829
) -> Optional[requests.Response]:
2930
merged_headers = {**(session.headers or {}), **(headers or {})}
3031

@@ -49,6 +50,7 @@ def _request(
4950
headers=merged_headers or None,
5051
verify=verify,
5152
timeout=timeout,
53+
**request_kwargs, # can forward extras (allow_redirects, stream, etc.)
5254
)
5355
code = resp.status_code
5456
last = resp

serve_event_listener/probing.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Curl-like availability checks of user apps."""
2+
3+
import logging
4+
import socket
5+
from dataclasses import dataclass
6+
from typing import Optional
7+
from urllib.parse import urlparse
8+
9+
import requests
10+
11+
from serve_event_listener.http_client import get as http_get
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
@dataclass
17+
class ProbeResult:
18+
"""Outcome of an availability probe."""
19+
status: str # "Running" | "Unknown" | "NotFound"
20+
port80_status: Optional[int] = None
21+
note: str = "" # short diagnostic
22+
23+
24+
class AppAvailabilityProbe:
25+
"""
26+
Curl-like availability check using only a port-80 URL.
27+
28+
Classification:
29+
- NotFound: DNS cannot resolve the host
30+
- Running: HTTP status 2xx or 3xx
31+
- Unknown: DNS resolves, but no 2xx/3xx (e.g., connection refused, timeout, 4xx/5xx)
32+
(treating 4xx/5xx as 'Unknown' surfaces misconfig/errors; adjust if desired)
33+
"""
34+
35+
def __init__(
36+
self,
37+
session: requests.Session,
38+
*,
39+
verify_tls: bool = True,
40+
timeout: tuple[float, float] = (1.5, 3.0),
41+
backoff_seconds: tuple[float, ...] = (0.5, 1.0),
42+
) -> None:
43+
self.session = session
44+
self.verify_tls = verify_tls
45+
self.timeout = timeout
46+
self.backoff_seconds = backoff_seconds
47+
48+
def _dns_resolves(self, url: str) -> bool:
49+
host = urlparse(url).hostname
50+
if not host:
51+
return False
52+
try:
53+
socket.getaddrinfo(host, None)
54+
return True
55+
except socket.gaierror:
56+
return False
57+
58+
def probe_url(self, port80_url: str) -> ProbeResult:
59+
"""Probe a single HTTP URL and classify availability."""
60+
# (c) NotFound — DNS cannot resolve
61+
if not self._dns_resolves(port80_url):
62+
return ProbeResult(status="NotFound", note="DNS resolution failed")
63+
64+
# Try GET; http_get returns None on network errors/timeouts
65+
resp = http_get(
66+
self.session,
67+
port80_url,
68+
verify=self.verify_tls,
69+
timeout=self.timeout,
70+
backoff_seconds=self.backoff_seconds,
71+
allow_redirects=True,
72+
)
73+
code = resp.status_code if resp is not None else None
74+
75+
# (a) Running — server responds OK/redirect
76+
if code is not None and 200 <= code < 400:
77+
return ProbeResult(status="Running", port80_status=code, note="HTTP 2xx/3xx")
78+
79+
# (b) Unknown — DNS ok but no success (refused/timeout/4xx/5xx)
80+
return ProbeResult(
81+
status="Unknown",
82+
port80_status=code,
83+
note="DNS ok; no 2xx/3xx (refused/timeout/4xx/5xx)",
84+
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Integration tests for AppAvailabilityProbe (real DNS + HTTP)."""
2+
3+
import os
4+
import unittest
5+
6+
from serve_event_listener.http_client import make_session
7+
from serve_event_listener.probing import AppAvailabilityProbe
8+
from tests.integration.base import IntegrationTestCase
9+
10+
11+
class TestAppAvailabilityProbeIntegration(IntegrationTestCase):
12+
"""Probe real endpoints to classify Running / Unknown / NotFound."""
13+
14+
def setUp(self):
15+
"""Build a real Session and a probe with short timeouts."""
16+
self.session = make_session(total_retries=2)
17+
self.probe = AppAvailabilityProbe(
18+
self.session,
19+
verify_tls=True,
20+
timeout=(0.5, 1.0),
21+
backoff_seconds=(0.2, 0.4),
22+
)
23+
24+
def test_running_on_live_endpoint(self):
25+
"""Returns Running on a healthy live endpoint (base or PROBE_URL)."""
26+
url = (
27+
os.getenv("PROBE_URL")
28+
or os.getenv("BASE_URL")
29+
)
30+
if not url:
31+
raise unittest.SkipTest("Set PROBE_URL or BASE_URL")
32+
if not url.endswith("api/are-you-there/"):
33+
# Hit the /api/are-you-there/ endpoint
34+
url = f'{url.rstrip("/")}/api/are-you-there/'
35+
res = self.probe.probe_url(url)
36+
# Most healthy bases should be 2xx/3xx → Running. If your base needs auth and
37+
# returns 401, adjust this assertion or provide PROBE_URL that is public.
38+
self.assertEqual(res.status, "Running", f"Unexpected result for {url}: {res}")
39+
40+
def test_notfound_on_nonexistent_host(self):
41+
"""Returns NotFound on a guaranteed-nonexistent domain."""
42+
res = self.probe.probe_url("http://no-such-host.invalid/")
43+
self.assertEqual(res.status, "NotFound")
44+
45+
def test_unknown_on_local_refused(self):
46+
"""Returns Unknown when host resolves but connection is refused."""
47+
# Port 9 is typically closed; adjust if your CI environment differs.
48+
res = self.probe.probe_url("http://127.0.0.1:9/")
49+
self.assertEqual(res.status, "Unknown")

tests/unit/test_probing.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Unit tests for AppAvailabilityProbe (DNS + HTTP classification)."""
2+
3+
import socket
4+
import unittest
5+
from unittest.mock import MagicMock, patch
6+
7+
import requests
8+
9+
from serve_event_listener.probing import AppAvailabilityProbe, ProbeResult
10+
11+
12+
class TestAppAvailabilityProbe(unittest.TestCase):
13+
"""Classify results based on mocked DNS and HTTP outcomes."""
14+
15+
def setUp(self):
16+
"""Create a probe with a fake Session and short timeouts."""
17+
self.session = MagicMock(spec=requests.Session)
18+
self.probe = AppAvailabilityProbe(
19+
self.session,
20+
verify_tls=True,
21+
timeout=(0.2, 0.5),
22+
backoff_seconds=(0.05, 0.1),
23+
)
24+
25+
@patch("serve_event_listener.probing.http_get")
26+
@patch("serve_event_listener.probing.socket.getaddrinfo")
27+
def test_notfound_when_dns_fails(self, mock_dns, mock_http_get):
28+
"""Returns NotFound when DNS cannot resolve the host."""
29+
mock_dns.side_effect = socket.gaierror()
30+
res = self.probe.probe_url("http://no-such-host.invalid/")
31+
self.assertIsInstance(res, ProbeResult)
32+
self.assertEqual(res.status, "NotFound")
33+
mock_http_get.assert_not_called()
34+
35+
@patch("serve_event_listener.probing.http_get")
36+
@patch("serve_event_listener.probing.socket.getaddrinfo")
37+
def test_running_on_200(self, mock_dns, mock_http_get):
38+
"""Returns Running on HTTP 200."""
39+
mock_dns.return_value = [(None,)]
40+
mock_http_get.return_value = MagicMock(status_code=200)
41+
42+
res = self.probe.probe_url("http://example.com/")
43+
self.assertEqual(res.status, "Running")
44+
self.assertEqual(res.port80_status, 200)
45+
46+
# Arguments are forwarded correctly
47+
_, kwargs = mock_http_get.call_args
48+
self.assertTrue(kwargs.get("allow_redirects"))
49+
self.assertEqual(kwargs.get("timeout"), (0.2, 0.5))
50+
51+
@patch("serve_event_listener.probing.http_get")
52+
@patch("serve_event_listener.probing.socket.getaddrinfo")
53+
def test_running_on_3xx(self, mock_dns, mock_http_get):
54+
"""Returns Running on HTTP 302 redirect."""
55+
mock_dns.return_value = [(None,)]
56+
mock_http_get.return_value = MagicMock(status_code=302)
57+
res = self.probe.probe_url("http://example.com/")
58+
self.assertEqual(res.status, "Running")
59+
self.assertEqual(res.port80_status, 302)
60+
61+
@patch("serve_event_listener.probing.http_get")
62+
@patch("serve_event_listener.probing.socket.getaddrinfo")
63+
def test_unknown_on_network_error(self, mock_dns, mock_http_get):
64+
"""Returns Unknown when HTTP wrapper returns None (timeout/refused)."""
65+
mock_dns.return_value = [(None,)]
66+
mock_http_get.return_value = None
67+
res = self.probe.probe_url("http://example.com/")
68+
self.assertEqual(res.status, "Unknown")
69+
self.assertIsNone(res.port80_status)
70+
71+
@patch("serve_event_listener.probing.http_get")
72+
@patch("serve_event_listener.probing.socket.getaddrinfo")
73+
def test_unknown_on_5xx(self, mock_dns, mock_http_get):
74+
"""Returns Unknown on server 5xx."""
75+
mock_dns.return_value = [(None,)]
76+
mock_http_get.return_value = MagicMock(status_code=503)
77+
res = self.probe.probe_url("http://example.com/")
78+
self.assertEqual(res.status, "Unknown")
79+
self.assertEqual(res.port80_status, 503)
80+
81+
@patch("serve_event_listener.probing.http_get")
82+
@patch("serve_event_listener.probing.socket.getaddrinfo")
83+
def test_unknown_on_4xx(self, mock_dns, mock_http_get):
84+
"""Returns Unknown on client 4xx."""
85+
mock_dns.return_value = [(None,)]
86+
mock_http_get.return_value = MagicMock(status_code=404)
87+
res = self.probe.probe_url("http://example.com/")
88+
self.assertEqual(res.status, "Unknown")
89+
self.assertEqual(res.port80_status, 404)
90+
91+
@patch("serve_event_listener.probing.http_get")
92+
@patch("serve_event_listener.probing.socket.getaddrinfo")
93+
def test_notfound_when_url_has_no_host(self, mock_dns, mock_http_get):
94+
"""Returns NotFound when URL has no hostname."""
95+
# urlparse(host) -> None triggers NotFound before DNS call
96+
res = self.probe.probe_url("http:///nohost")
97+
self.assertEqual(res.status, "NotFound")
98+
mock_dns.assert_not_called()
99+
mock_http_get.assert_not_called()
100+
101+
102+
if __name__ == "__main__":
103+
unittest.main()

0 commit comments

Comments
 (0)