Skip to content
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,7 @@ classifiers = [
"Environment :: Console",
]
dependencies = [
"httpx>=0.27,<1.0",
"httpx2>=2.4,<3.0",
"pydantic>=2.0,<3.0",
]

Expand Down
5 changes: 5 additions & 0 deletions src/hyperping/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,9 @@
from hyperping.models import (
DEFAULT_REGIONS,
AddIncidentUpdateRequest,
Alert,
AlertHistory,
AlertType,
DnsRecordType,
EscalationPolicy,
EscalationStep,
Expand DownExpand Up@@ -121,6 +123,9 @@
"HyperpingNotFoundError",
"HyperpingRateLimitError",
"HyperpingValidationError",
# Alert models (PY-10: provisional, reconcile when alerts endpoint ships)
"Alert",
"AlertType",
# Monitor enums
"HttpMethod",
"MonitorFrequency",
Expand Down
4 changes: 3 additions & 1 deletion src/hyperping/_async_client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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,
Expand DownExpand Up@@ -58,6 +59,7 @@ class AsyncHyperpingClient(
AsyncOutagesMixin,
AsyncStatusPagesMixin,
AsyncHealthchecksMixin,
AsyncStreamingMixin,
):
"""Async client for interacting with the Hyperping API.

Expand Down
2 changes: 1 addition & 1 deletion src/hyperping/_async_mcp_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
113 changes: 113 additions & 0 deletions src/hyperping/_async_streaming_mixin.py
Original file line numberDiff line numberDiff line change
@@ -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)
15 changes: 3 additions & 12 deletions src/hyperping/_internals.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/hyperping/_mcp_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/hyperping/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 (
Expand Down
4 changes: 4 additions & 0 deletions src/hyperping/models/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -86,6 +87,9 @@
)

__all__ = [
# Alert models (PY-10: provisional, reconcile when alerts endpoint ships)
"Alert",
"AlertType",
# Shared primitives
"LocalizedText",
"RequestHeader",
Expand Down
36 changes: 36 additions & 0 deletions src/hyperping/models/_alert_models.py
Original file line numberDiff line numberDiff line change
@@ -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(...)
12 changes: 12 additions & 0 deletions tests/unit/conftest.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_async_client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import httpx2 as httpx
import pytest
import pytest_asyncio

Expand Down
4 changes: 3 additions & 1 deletion tests/unit/test_async_mcp_client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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()
Expand Down
Loading