diff --git a/pyproject.toml b/pyproject.toml index 9e9540c..af584e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Environment :: Console", ] dependencies = [ - "httpx>=0.27,<1.0", + "httpx2>=2.4,<3.0", "pydantic>=2.0,<3.0", ] diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index f8665ce..a74c66c 100644 --- a/src/hyperping/__init__.py +++ b/src/hyperping/__init__.py @@ -41,7 +41,9 @@ from hyperping.models import ( DEFAULT_REGIONS, AddIncidentUpdateRequest, + Alert, AlertHistory, + AlertType, DnsRecordType, EscalationPolicy, EscalationStep, @@ -121,6 +123,9 @@ "HyperpingNotFoundError", "HyperpingRateLimitError", "HyperpingValidationError", + # Alert models (PY-10: provisional, reconcile when alerts endpoint ships) + "Alert", + "AlertType", # Monitor enums "HttpMethod", "MonitorFrequency", diff --git a/src/hyperping/_async_client.py b/src/hyperping/_async_client.py index 23e1dea..6cf128d 100644 --- a/src/hyperping/_async_client.py +++ b/src/hyperping/_async_client.py @@ -20,7 +20,7 @@ from typing import Any from urllib.parse import urlsplit -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._async_healthchecks_mixin import AsyncHealthchecksMixin @@ -29,6 +29,7 @@ from hyperping._async_monitors_mixin import AsyncMonitorsMixin from hyperping._async_outages_mixin import AsyncOutagesMixin from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin +from hyperping._async_streaming_mixin import AsyncStreamingMixin from hyperping._circuit_breaker import ( CircuitBreaker, CircuitBreakerConfig, @@ -58,6 +59,7 @@ class AsyncHyperpingClient( AsyncOutagesMixin, AsyncStatusPagesMixin, AsyncHealthchecksMixin, + AsyncStreamingMixin, ): """Async client for interacting with the Hyperping API. diff --git a/src/hyperping/_async_mcp_transport.py b/src/hyperping/_async_mcp_transport.py index 6be0272..ca60bd1 100644 --- a/src/hyperping/_async_mcp_transport.py +++ b/src/hyperping/_async_mcp_transport.py @@ -9,7 +9,7 @@ import time from typing import Any -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._internals import validate_base_url diff --git a/src/hyperping/_async_streaming_mixin.py b/src/hyperping/_async_streaming_mixin.py new file mode 100644 index 0000000..476115b --- /dev/null +++ b/src/hyperping/_async_streaming_mixin.py @@ -0,0 +1,113 @@ +"""Async streaming helpers for event-driven integrations (PY-10). + +Provides poll-based AsyncIterator helpers on top of existing REST endpoints. +Public signatures are stable; only the poll internals change when Hyperping +ships SSE or a discrete alerts endpoint. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from hyperping._protocols import _AsyncClientProtocol +from hyperping._utils import parse_list, unwrap_list, validate_id +from hyperping.endpoints import Endpoint +from hyperping.models import IncidentUpdate, Monitor +from hyperping.models._alert_models import Alert, AlertType + +if TYPE_CHECKING: + from hyperping.models import Incident + +logger = logging.getLogger(__name__) + + +class AsyncStreamingMixin(_AsyncClientProtocol): + """Poll-based streaming helpers for alert and incident monitoring.""" + + if TYPE_CHECKING: + + async def get_incident(self, incident_id: str) -> Incident: ... + + async def stream_alerts(self, *, poll_interval: float = 30.0) -> AsyncIterator[Alert]: + """Stream monitor state-transition events. + + Polls ``GET /v1/monitors`` at *poll_interval* seconds. Yields an + :class:`~hyperping.models.Alert` on each up/down state change. + The first poll establishes the baseline and yields nothing. + + Rate-limit note: at the default 30-second interval this uses + 2 requests/min, roughly 0.67% of the 300 req/min account limit. + + Args: + poll_interval: Seconds between polls. Defaults to 30.0. + + Yields: + :class:`~hyperping.models.Alert` on each monitor state transition. + + Note: + This implementation is provisional. The ``Alert`` model fields are + inferred from the monitors list endpoint. When Hyperping ships a + dedicated alerts endpoint, the model will be reconciled and the + poll internals replaced with SSE or long-poll without any change + to this method's signature. + """ + baseline: dict[str, bool] = {} + + while True: + response = await self._request("GET", Endpoint.MONITORS) + monitors = parse_list(unwrap_list(response, "monitors"), Monitor, "monitor") + + for monitor in monitors: + uuid = monitor.uuid + down = monitor.down + + if uuid in baseline and baseline[uuid] != down: + alert_type = AlertType.DOWN if down else AlertType.UP + yield Alert( + monitor_uuid=uuid, + monitor_name=monitor.name, + type=alert_type, + timestamp=datetime.now(UTC).isoformat(), + ) + + baseline[uuid] = down + + await asyncio.sleep(poll_interval) + + async def stream_incident_updates( + self, incident_uuid: str, *, poll_interval: float = 30.0 + ) -> AsyncIterator[IncidentUpdate]: + """Stream new updates for an incident. + + Polls ``GET /v3/incidents/{uuid}`` at *poll_interval* seconds. Yields + each :class:`~hyperping.models.IncidentUpdate` exactly once, deduped + by update UUID. All updates present on the first poll are yielded + immediately; only new updates are yielded on subsequent polls. + + Args: + incident_uuid: UUID of the incident to watch. + poll_interval: Seconds between polls. Defaults to 30.0. + + Yields: + :class:`~hyperping.models.IncidentUpdate` for each new update. + + Raises: + ValueError: If *incident_uuid* contains unsafe characters. + HyperpingNotFoundError: If the incident does not exist (first poll). + """ + validate_id(incident_uuid, "incident_uuid") + seen: set[str] = set() + + while True: + incident = await self.get_incident(incident_uuid) + + for update in incident.updates: + if update.uuid not in seen: + seen.add(update.uuid) + yield update + + await asyncio.sleep(poll_interval) diff --git a/src/hyperping/_internals.py b/src/hyperping/_internals.py index a32e4ab..c1356cf 100644 --- a/src/hyperping/_internals.py +++ b/src/hyperping/_internals.py @@ -83,20 +83,11 @@ def validate_base_url( # (``https://@host``, ``https://:@host``). ``parts.username`` is an empty # string in those cases, so the previous ``or`` truthiness guard let them # through. Checking the raw authority for ``@`` is exhaustive. - if ( - "@" in parts.netloc - or parts.username is not None - or parts.password is not None - ): - raise ValueError( - f"{param_name} must not embed userinfo (credentials) in the URL" - ) + if "@" in parts.netloc or parts.username is not None or parts.password is not None: + raise ValueError(f"{param_name} must not embed userinfo (credentials) in the URL") if parts.query or parts.fragment: - raise ValueError( - f"{param_name} must not carry a query string or fragment " - f"(got {url!r})" - ) + raise ValueError(f"{param_name} must not carry a query string or fragment (got {url!r})") if parts.scheme == "http": if not allow_insecure: diff --git a/src/hyperping/_mcp_transport.py b/src/hyperping/_mcp_transport.py index 9353d3e..a9cb5ad 100644 --- a/src/hyperping/_mcp_transport.py +++ b/src/hyperping/_mcp_transport.py @@ -9,7 +9,7 @@ import time from typing import Any -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._internals import validate_base_url diff --git a/src/hyperping/client.py b/src/hyperping/client.py index 87953f2..97096e7 100644 --- a/src/hyperping/client.py +++ b/src/hyperping/client.py @@ -18,7 +18,7 @@ from typing import Any from urllib.parse import urlsplit -import httpx +import httpx2 as httpx from pydantic import SecretStr from hyperping._circuit_breaker import ( diff --git a/src/hyperping/models/__init__.py b/src/hyperping/models/__init__.py index 40b287c..204ce0a 100644 --- a/src/hyperping/models/__init__.py +++ b/src/hyperping/models/__init__.py @@ -9,6 +9,7 @@ be removed in v0.3.0. """ +from hyperping.models._alert_models import Alert, AlertType from hyperping.models._healthcheck_models import ( Healthcheck, HealthcheckCreate, @@ -86,6 +87,9 @@ ) __all__ = [ + # Alert models (PY-10: provisional, reconcile when alerts endpoint ships) + "Alert", + "AlertType", # Shared primitives "LocalizedText", "RequestHeader", diff --git a/src/hyperping/models/_alert_models.py b/src/hyperping/models/_alert_models.py new file mode 100644 index 0000000..219add4 --- /dev/null +++ b/src/hyperping/models/_alert_models.py @@ -0,0 +1,36 @@ +"""Alert models for streaming helpers (PY-10). + +This module is provisional. The Alert model fields are derived from the monitors +list endpoint. When Hyperping ships a discrete alerts endpoint, the model will +be reconciled with the real API shape. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + + +class AlertType(StrEnum): + """Alert transition type.""" + + DOWN = "down" + UP = "up" + DEGRADED = "degraded" + + +class Alert(BaseModel): + """A monitor state-transition event yielded by stream_alerts. + + Provisional model: fields are inferred from the monitors list endpoint. + When Hyperping ships a dedicated alerts endpoint, this model will be + reconciled against the real API shape. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True, frozen=True) + + monitor_uuid: str = Field(..., alias="monitorUuid") + monitor_name: str = Field(..., alias="monitorName") + type: AlertType = Field(...) + timestamp: str = Field(...) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 86c2219..9869263 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,10 +3,22 @@ from collections.abc import Generator import pytest +from respx.mocks import HTTPCoreMocker from hyperping.client import HyperpingClient, RetryConfig from hyperping.endpoints import API_BASE +# httpx2 uses httpcore2 instead of httpcore; extend respx's default mocker so +# that @respx.mock intercepts requests made through httpx2 clients. +HTTPCoreMocker.add_targets( + "httpcore2._sync.connection.HTTPConnection", + "httpcore2._sync.connection_pool.ConnectionPool", + "httpcore2._sync.http_proxy.HTTPProxy", + "httpcore2._async.connection.AsyncHTTPConnection", + "httpcore2._async.connection_pool.AsyncConnectionPool", + "httpcore2._async.http_proxy.AsyncHTTPProxy", +) + @pytest.fixture def client() -> Generator[HyperpingClient, None, None]: diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index c6dee53..a0bfc9a 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import httpx2 as httpx import pytest import pytest_asyncio diff --git a/tests/unit/test_async_mcp_client.py b/tests/unit/test_async_mcp_client.py index 46e78aa..dece189 100644 --- a/tests/unit/test_async_mcp_client.py +++ b/tests/unit/test_async_mcp_client.py @@ -362,7 +362,9 @@ async def test_ensure_initialized_delegates_to_transport(): async def test_ensure_initialized_propagates_rate_limit(): client = make_client() client._transport.initialize.side_effect = HyperpingRateLimitError( - "rate limited on initialize", retry_after=30, status_code=200, + "rate limited on initialize", + retry_after=30, + status_code=200, ) with pytest.raises(HyperpingRateLimitError) as exc_info: await client.ensure_initialized() diff --git a/tests/unit/test_async_mcp_transport.py b/tests/unit/test_async_mcp_transport.py index 55124aa..6b28eec 100644 --- a/tests/unit/test_async_mcp_transport.py +++ b/tests/unit/test_async_mcp_transport.py @@ -458,9 +458,7 @@ async def test_initialize_is_idempotent(): async def test_initialize_rate_limit_latches_cooloff(monkeypatch): """After a rate-limited initialize, further call_tool calls short-circuit.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_response = httpx.Response( 200, @@ -494,9 +492,7 @@ async def test_initialize_rate_limit_latches_cooloff(monkeypatch): async def test_initialize_cooloff_clears_after_deadline(monkeypatch): """Once the cool-off elapses, async initialize is attempted again.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_body = { "jsonrpc": "2.0", @@ -542,7 +538,9 @@ async def test_rate_limit_is_not_retried_by_call_tool(): """call_tool's transient retry loop must NOT retry HyperpingRateLimitError.""" route = respx.post(MCP_URL).mock( return_value=httpx.Response( - 429, text="Rate limited", headers={"retry-after": "5"}, + 429, + text="Rate limited", + headers={"retry-after": "5"}, ), ) transport = AsyncMcpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=3) @@ -583,14 +581,14 @@ async def test_jsonrpc_rate_limit_is_not_retried_by_call_tool(): @pytest.mark.parametrize( "message, expected", [ - ('Hyperping MCP rate limit exceeded. Retry after 32s.', 32), + ("Hyperping MCP rate limit exceeded. Retry after 32s.", 32), ('Rate limit exceeded for "initialize". Retry-After: 30s', 30), - ('Rate limit exceeded. retry after 30 seconds.', 30), - ('Rate limit exceeded. RETRY AFTER 7', 7), - ('Rate limit exceeded. Retry after 0s', 0), - ('Rate limit exceeded. Retry after 1.5s', 1), - ('Rate limit exceeded.', None), - ('Rate limit exceeded. Try again later.', None), + ("Rate limit exceeded. retry after 30 seconds.", 30), + ("Rate limit exceeded. RETRY AFTER 7", 7), + ("Rate limit exceeded. Retry after 0s", 0), + ("Rate limit exceeded. Retry after 1.5s", 1), + ("Rate limit exceeded.", None), + ("Rate limit exceeded. Try again later.", None), ], ) @respx.mock @@ -643,9 +641,7 @@ async def test_jsonrpc_rate_limit_marker_requires_exceeded(): @respx.mock async def test_notifications_initialized_rate_limit_classified(monkeypatch): """A 200 + -32000 on notifications/initialized raises HyperpingRateLimitError.""" - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: 1000.0 - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: 1000.0) respx.post(MCP_URL).mock( side_effect=[ INIT_RESPONSE, @@ -677,9 +673,7 @@ async def test_notifications_initialized_rate_limit_classified(monkeypatch): async def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): """A latch armed by HTTP 429 must short-circuit with status_code=429.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 429, @@ -701,9 +695,7 @@ async def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): async def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): """A latch armed by JSON-RPC -32000 short-circuits with status_code=200.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -734,9 +726,7 @@ async def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): async def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): """When server advertises Retry-After: 30, short-circuit returns 30, not 31.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -776,9 +766,7 @@ async def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): async def test_jsonrpc_rate_limit_with_retry_after_zero_does_not_latch(monkeypatch): """retry_after=0 from server means retry-now; do not set a 30s default.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._async_mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( side_effect=[ httpx.Response( diff --git a/tests/unit/test_client_coverage.py b/tests/unit/test_client_coverage.py index 3dd75e7..153de20 100644 --- a/tests/unit/test_client_coverage.py +++ b/tests/unit/test_client_coverage.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import httpcore2 import httpx import pytest import respx @@ -67,9 +68,9 @@ def test_ping_api_error_wraps(self) -> None: @respx.mock def test_ping_timeout_wraps(self) -> None: - """ping() wraps httpx.TimeoutException.""" + """ping() wraps httpx2.TimeoutException.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.TimeoutException("timed out") + side_effect=httpcore2.ConnectTimeout("timed out") ) c = HyperpingClient( api_key="sk_test", @@ -83,7 +84,7 @@ def test_ping_timeout_wraps(self) -> None: def test_ping_request_error_wraps(self) -> None: """ping() wraps httpx.RequestError.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.ConnectError("connection refused") + side_effect=httpcore2.ConnectError("connection refused") ) c = HyperpingClient( api_key="sk_test", @@ -250,7 +251,7 @@ class TestTimeoutRetry: def test_timeout_retries_then_raises(self) -> None: """Timeout after all retries raises HyperpingAPIError.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.TimeoutException("timed out") + side_effect=httpcore2.ConnectTimeout("timed out") ) with patch("hyperping.client.time.sleep"): c = HyperpingClient( @@ -265,7 +266,7 @@ def test_timeout_retries_then_raises(self) -> None: def test_request_error_retries_then_raises(self) -> None: """Connection error after all retries raises HyperpingAPIError.""" respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock( - side_effect=httpx.ConnectError("connection refused") + side_effect=httpcore2.ConnectError("connection refused") ) with patch("hyperping.client.time.sleep"): c = HyperpingClient( diff --git a/tests/unit/test_mcp_transport.py b/tests/unit/test_mcp_transport.py index 7a0bc59..8659994 100644 --- a/tests/unit/test_mcp_transport.py +++ b/tests/unit/test_mcp_transport.py @@ -539,9 +539,7 @@ def test_initialize_is_idempotent(): def test_initialize_rate_limit_latches_cooloff(monkeypatch): """After a rate-limited initialize, further call_tool calls short-circuit.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_response = httpx.Response( 200, @@ -576,9 +574,7 @@ def test_initialize_rate_limit_latches_cooloff(monkeypatch): def test_initialize_cooloff_clears_after_deadline(monkeypatch): """Once the cool-off elapses, initialize is attempted again.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) rl_body = { "jsonrpc": "2.0", @@ -638,7 +634,9 @@ def test_rate_limit_is_not_retried_by_call_tool(): """call_tool's transient retry loop must NOT retry HyperpingRateLimitError.""" route = respx.post(MCP_URL).mock( return_value=httpx.Response( - 429, text="Rate limited", headers={"retry-after": "5"}, + 429, + text="Rate limited", + headers={"retry-after": "5"}, ), ) transport = McpTransport(api_key="sk_test", base_url=MCP_URL, max_retries=3) @@ -714,14 +712,14 @@ def test_six_fresh_clients_under_jsonrpc_rate_limit_all_fail_typed(): @pytest.mark.parametrize( "message, expected", [ - ('Hyperping MCP rate limit exceeded. Retry after 32s.', 32), + ("Hyperping MCP rate limit exceeded. Retry after 32s.", 32), ('Rate limit exceeded for "initialize". Retry-After: 30s', 30), - ('Rate limit exceeded. retry after 30 seconds.', 30), - ('Rate limit exceeded. RETRY AFTER 7', 7), - ('Rate limit exceeded. Retry after 0s', 0), - ('Rate limit exceeded. Retry after 1.5s', 1), # sub-second floored - ('Rate limit exceeded.', None), # no advertised value - ('Rate limit exceeded. Try again later.', None), # graceful + ("Rate limit exceeded. retry after 30 seconds.", 30), + ("Rate limit exceeded. RETRY AFTER 7", 7), + ("Rate limit exceeded. Retry after 0s", 0), + ("Rate limit exceeded. Retry after 1.5s", 1), # sub-second floored + ("Rate limit exceeded.", None), # no advertised value + ("Rate limit exceeded. Try again later.", None), # graceful ], ) @respx.mock @@ -777,9 +775,7 @@ def test_jsonrpc_rate_limit_marker_requires_exceeded(): @respx.mock def test_notifications_initialized_rate_limit_classified(monkeypatch): """A 200 + -32000 on notifications/initialized raises HyperpingRateLimitError.""" - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: 1000.0 - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: 1000.0) respx.post(MCP_URL).mock( side_effect=[ # initialize succeeds @@ -818,9 +814,7 @@ def test_notifications_initialized_rate_limit_classified(monkeypatch): def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): """A latch armed by HTTP 429 must short-circuit with status_code=429.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 429, @@ -843,9 +837,7 @@ def test_cooloff_short_circuit_preserves_429_status_code(monkeypatch): def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): """A latch armed by JSON-RPC -32000 short-circuits with status_code=200.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -876,9 +868,7 @@ def test_cooloff_short_circuit_preserves_200_status_code(monkeypatch): def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): """When server advertises Retry-After: 30, short-circuit returns 30, not 31.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) respx.post(MCP_URL).mock( return_value=httpx.Response( 200, @@ -921,9 +911,7 @@ def test_cooloff_short_circuit_uses_math_ceil_not_plus_one(monkeypatch): def test_jsonrpc_rate_limit_with_retry_after_zero_does_not_latch(monkeypatch): """retry_after=0 from server means retry-now; do not set a 30s default.""" fake_now = {"t": 1000.0} - monkeypatch.setattr( - "hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"] - ) + monkeypatch.setattr("hyperping._mcp_transport.time.monotonic", lambda: fake_now["t"]) ok_init = httpx.Response( 200, json={"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26"}}, diff --git a/tests/unit/test_security_base_url.py b/tests/unit/test_security_base_url.py index 92263a1..7de0234 100644 --- a/tests/unit/test_security_base_url.py +++ b/tests/unit/test_security_base_url.py @@ -67,8 +67,9 @@ def test_http_allowed_with_explicit_opt_in_emits_warning(ctor): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") client = ctor("http://localhost:8080", allow_insecure=True) - assert any("insecure" in str(w.message).lower() or "http" in str(w.message).lower() - for w in caught), "expected a security warning when allow_insecure=True" + assert any( + "insecure" in str(w.message).lower() or "http" in str(w.message).lower() for w in caught + ), "expected a security warning when allow_insecure=True" # Clean up where applicable. closer = getattr(client, "close", None) if closer is not None and not callable(getattr(closer, "__await__", None)): diff --git a/tests/unit/test_security_exception_redaction.py b/tests/unit/test_security_exception_redaction.py index 1492946..41d27cd 100644 --- a/tests/unit/test_security_exception_redaction.py +++ b/tests/unit/test_security_exception_redaction.py @@ -144,8 +144,7 @@ def test_mcp_transport_5xx_does_not_leak_raw_server_body() -> None: respx.post(MCP_URL).mock( return_value=httpx.Response( 500, - text='{"echo":{"subscriber_email":"victim@example.com"},' - '"hint":"Bearer sk_secret_mcp"}', + text='{"echo":{"subscriber_email":"victim@example.com"},"hint":"Bearer sk_secret_mcp"}', ) ) transport = McpTransport(api_key="sk_test", base_url=MCP_URL) diff --git a/tests/unit/test_streaming.py b/tests/unit/test_streaming.py new file mode 100644 index 0000000..96f39dd --- /dev/null +++ b/tests/unit/test_streaming.py @@ -0,0 +1,474 @@ +"""Tests for async streaming helpers (PY-10).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio + +from hyperping._async_client import AsyncHyperpingClient +from hyperping.client import RetryConfig +from hyperping.exceptions import HyperpingNotFoundError +from hyperping.models import Incident, IncidentUpdate, LocalizedText +from hyperping.models._alert_models import Alert, AlertType + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _monitor_dict(uuid: str, name: str, *, down: bool) -> dict: + return { + "uuid": uuid, + "name": name, + "url": "https://example.com", + "protocol": "http", + "down": down, + "paused": False, + } + + +def _incident(uuid: str, updates: list[dict]) -> Incident: + return Incident( + uuid=uuid, + title=LocalizedText(en="Test Incident"), + type="incident", + statuspages=["sp_1"], + updates=[ + IncidentUpdate( + uuid=u["uuid"], + date=u["date"], + text=LocalizedText(en=u["text"]), + type=u["type"], + ) + for u in updates + ], + ) + + +async def _drain(gen, *, until_cancelled: bool = False) -> list: + """Collect items from an async generator, catching CancelledError.""" + items: list = [] + try: + async for item in gen: + items.append(item) + except asyncio.CancelledError: + if not until_cancelled: + raise + return items + + +@pytest_asyncio.fixture +async def async_client() -> AsyncHyperpingClient: + client = AsyncHyperpingClient( + api_key="sk_test_key", + retry_config=RetryConfig(max_retries=0), + ) + yield client + await client.close() + + +# --------------------------------------------------------------------------- +# Alert model tests +# --------------------------------------------------------------------------- + + +class TestAlertModel: + def test_alert_model_frozen(self) -> None: + a = Alert( + monitor_uuid="mon_1", + monitor_name="Test", + type=AlertType.DOWN, + timestamp="2026-01-01T00:00:00+00:00", + ) + with pytest.raises(Exception): + a.monitor_uuid = "mon_2" # type: ignore[misc] + + def test_alert_model_extra_fields_allowed(self) -> None: + a = Alert( + monitor_uuid="mon_1", + monitor_name="Test", + type=AlertType.DOWN, + timestamp="2026-01-01T00:00:00+00:00", + extra_field="ignored", + ) + assert a.monitor_uuid == "mon_1" + + def test_alert_type_enum_values(self) -> None: + assert AlertType.DOWN == "down" + assert AlertType.UP == "up" + assert AlertType.DEGRADED == "degraded" + + def test_alert_accepts_alias_names(self) -> None: + a = Alert( + monitorUuid="mon_alias", + monitorName="Alias Test", + type=AlertType.UP, + timestamp="2026-01-01T00:00:00+00:00", + ) + assert a.monitor_uuid == "mon_alias" + assert a.monitor_name == "Alias Test" + + +# --------------------------------------------------------------------------- +# stream_alerts tests +# --------------------------------------------------------------------------- + + +class TestStreamAlerts: + async def test_stream_alerts_no_alert_on_first_poll( + self, async_client: AsyncHyperpingClient + ) -> None: + """First poll establishes baseline; nothing yielded.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + + with ( + patch.object(async_client, "_request", new=AsyncMock(return_value=monitor_data)), + patch("asyncio.sleep", new=AsyncMock(side_effect=asyncio.CancelledError)), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert results == [] + + async def test_stream_alerts_yields_down_alert_on_transition( + self, async_client: AsyncHyperpingClient + ) -> None: + """Monitor flips from up to down; yields Alert(type='down').""" + poll1 = [_monitor_dict("mon_1", "Test", down=False)] + poll2 = [_monitor_dict("mon_1", "Test", down=True)] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(side_effect=[poll1, poll2])), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert len(results) == 1 + assert isinstance(results[0], Alert) + assert results[0].type == AlertType.DOWN + assert results[0].monitor_uuid == "mon_1" + assert results[0].monitor_name == "Test" + + async def test_stream_alerts_yields_up_alert_on_recovery( + self, async_client: AsyncHyperpingClient + ) -> None: + """Monitor recovers from down to up; yields Alert(type='up').""" + poll1 = [_monitor_dict("mon_1", "Test", down=True)] + poll2 = [_monitor_dict("mon_1", "Test", down=False)] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(side_effect=[poll1, poll2])), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert len(results) == 1 + assert results[0].type == AlertType.UP + + async def test_stream_alerts_no_alert_when_state_unchanged( + self, async_client: AsyncHyperpingClient + ) -> None: + """Two identical polls produce no alerts.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "_request", + new=AsyncMock(side_effect=[monitor_data, monitor_data]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert results == [] + + async def test_stream_alerts_multiple_monitors( + self, async_client: AsyncHyperpingClient + ) -> None: + """Two monitors each flip independently; one alert per transition.""" + poll1 = [ + _monitor_dict("mon_1", "Alpha", down=False), + _monitor_dict("mon_2", "Beta", down=False), + ] + poll2 = [ + _monitor_dict("mon_1", "Alpha", down=True), + _monitor_dict("mon_2", "Beta", down=True), + ] + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(side_effect=[poll1, poll2])), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_alerts(poll_interval=0.0), until_cancelled=True + ) + + assert len(results) == 2 + uuids = {a.monitor_uuid for a in results} + assert uuids == {"mon_1", "mon_2"} + assert all(a.type == AlertType.DOWN for a in results) + + async def test_stream_alerts_respects_poll_interval( + self, async_client: AsyncHyperpingClient + ) -> None: + """asyncio.sleep is called with the configured interval.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + recorded_intervals: list[float] = [] + + async def capturing_sleep(interval: float) -> None: + recorded_intervals.append(interval) + if len(recorded_intervals) >= 1: + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(return_value=monitor_data)), + patch("asyncio.sleep", new=capturing_sleep), + ): + await _drain(async_client.stream_alerts(poll_interval=42.0), until_cancelled=True) + + assert recorded_intervals[0] == 42.0 + + async def test_stream_alerts_custom_poll_interval( + self, async_client: AsyncHyperpingClient + ) -> None: + """Non-default poll_interval is forwarded to asyncio.sleep.""" + monitor_data = [_monitor_dict("mon_1", "Test", down=False)] + recorded_intervals: list[float] = [] + + async def capturing_sleep(interval: float) -> None: + recorded_intervals.append(interval) + raise asyncio.CancelledError + + with ( + patch.object(async_client, "_request", new=AsyncMock(return_value=monitor_data)), + patch("asyncio.sleep", new=capturing_sleep), + ): + await _drain(async_client.stream_alerts(poll_interval=5.0), until_cancelled=True) + + assert recorded_intervals[0] == 5.0 + + +# --------------------------------------------------------------------------- +# stream_incident_updates tests +# --------------------------------------------------------------------------- + + +class TestStreamIncidentUpdates: + async def test_stream_incident_updates_yields_new_update( + self, async_client: AsyncHyperpingClient + ) -> None: + """A new update appearing between polls is yielded.""" + upd1 = { + "uuid": "upd_1", + "date": "2026-01-01T00:00:00Z", + "text": "first", + "type": "investigating", + } + upd2 = { + "uuid": "upd_2", + "date": "2026-01-01T01:00:00Z", + "text": "second", + "type": "resolved", + } + + incident_v1 = _incident("inci_1", [upd1]) + incident_v2 = _incident("inci_1", [upd1, upd2]) + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=[incident_v1, incident_v2]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=0.0), + until_cancelled=True, + ) + + uuids = [r.uuid for r in results] + assert "upd_2" in uuids + + async def test_stream_incident_updates_no_yield_for_seen_updates( + self, async_client: AsyncHyperpingClient + ) -> None: + """Updates seen on a previous poll are not re-yielded.""" + upd1 = { + "uuid": "upd_1", + "date": "2026-01-01T00:00:00Z", + "text": "first", + "type": "investigating", + } + incident = _incident("inci_1", [upd1]) + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=[incident, incident]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=0.0), + until_cancelled=True, + ) + + assert len([r for r in results if r.uuid == "upd_1"]) == 1 + + async def test_stream_incident_updates_multiple_new_updates( + self, async_client: AsyncHyperpingClient + ) -> None: + """Two new updates appearing at once are both yielded.""" + upd1 = { + "uuid": "upd_1", + "date": "2026-01-01T00:00:00Z", + "text": "first", + "type": "investigating", + } + upd2 = { + "uuid": "upd_2", + "date": "2026-01-01T01:00:00Z", + "text": "second", + "type": "identified", + } + upd3 = { + "uuid": "upd_3", + "date": "2026-01-01T02:00:00Z", + "text": "third", + "type": "resolved", + } + + incident_v1 = _incident("inci_1", [upd1]) + incident_v2 = _incident("inci_1", [upd1, upd2, upd3]) + + sleep_calls = 0 + + async def controlled_sleep(_interval: float) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=[incident_v1, incident_v2]), + ), + patch("asyncio.sleep", new=controlled_sleep), + ): + results = await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=0.0), + until_cancelled=True, + ) + + new_uuids = {r.uuid for r in results} - {"upd_1"} + assert new_uuids == {"upd_2", "upd_3"} + + async def test_stream_incident_updates_invalid_uuid_raises( + self, async_client: AsyncHyperpingClient + ) -> None: + """Invalid UUID format raises ValueError before polling starts.""" + with pytest.raises(ValueError, match="Invalid"): + async for _ in async_client.stream_incident_updates("not/a/valid/id"): + pass + + async def test_stream_incident_updates_not_found_raises( + self, async_client: AsyncHyperpingClient + ) -> None: + """Incident not found raises HyperpingNotFoundError on first poll.""" + with patch.object( + async_client, + "get_incident", + new=AsyncMock(side_effect=HyperpingNotFoundError("not found", status_code=404)), + ): + with pytest.raises(HyperpingNotFoundError): + async for _ in async_client.stream_incident_updates("inci_missing"): + pass + + async def test_stream_incident_updates_respects_poll_interval( + self, async_client: AsyncHyperpingClient + ) -> None: + """asyncio.sleep is called with the configured interval.""" + incident = _incident("inci_1", []) + recorded_intervals: list[float] = [] + + async def capturing_sleep(interval: float) -> None: + recorded_intervals.append(interval) + raise asyncio.CancelledError + + with ( + patch.object( + async_client, + "get_incident", + new=AsyncMock(return_value=incident), + ), + patch("asyncio.sleep", new=capturing_sleep), + ): + await _drain( + async_client.stream_incident_updates("inci_1", poll_interval=15.0), + until_cancelled=True, + ) + + assert recorded_intervals[0] == 15.0 diff --git a/uv.lock b/uv.lock index a9774e8..f4435d2 100644 --- a/uv.lock +++ b/uv.lock @@ -327,6 +327,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/9b/2b1d1833a58236d1f6ee755e027a3917da0db59cc9708554cefc440ee8b6/httpcore2-2.4.0.tar.gz", hash = "sha256:3093a8ab8980d9f910b9cb4351df9186a0ad2350a6284a9107ac9a362a584422", size = 64618, upload-time = "2026-06-11T06:35:53.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/72/4fdf2306143a92a471fad9f3655aa542d43aa9188a7c9534e82c9aecf837/httpcore2-2.4.0-py3-none-any.whl", hash = "sha256:5218779da5d6e3c2013ac706121abfb3815d450e0613495c0de50264dce58242", size = 80151, upload-time = "2026-06-11T06:35:50.89Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -342,12 +355,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/60/b43ced4ccf26e95b396dbf67051d3e5042b645917d4da0469dd82a3bdd4f/httpx2-2.4.0.tar.gz", hash = "sha256:32e0734b61eb0824b3f56a9e98d6d92d381a3ef12c0045aa917ee63df6c411ef", size = 81691, upload-time = "2026-06-11T06:35:54.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/45/82bc57c3d9c3314f663b67cc057f1c017a6450685dde513f4f8db5cf431f/httpx2-2.4.0-py3-none-any.whl", hash = "sha256:425acd99297829599decf6701386dd84db3542597d36d3e2e4def930ecd57fd9", size = 74941, upload-time = "2026-06-11T06:35:52.235Z" }, +] + [[package]] name = "hyperping" version = "1.8.0" source = { editable = "." } dependencies = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "pydantic" }, ] @@ -373,7 +402,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.27,<1.0" }, + { name = "httpx2", specifier = ">=2.4,<3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, @@ -392,11 +421,11 @@ dev = [{ name = "pytest-asyncio", specifier = ">=0.23.0" }] [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1046,6 +1075,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.26.7"