Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff 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 number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
from hyperping.models import (
DEFAULT_REGIONS,
AddIncidentUpdateRequest,
Alert,
AlertHistory,
AlertType,
DnsRecordType,
EscalationPolicy,
EscalationStep,
Expand Down Expand 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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