Skip to content
Merged
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.8.1] - 2026-07-14

### Fixed

- **`list_status_pages()` returned an empty list against the live v2 API.** The
`StatusPage` model required a `subdomain` field, but the API returns the hosted
subdomain under the key `hostedsubdomain` (and omits it entirely for custom-domain
pages). Every record failed validation and was silently skipped. `subdomain` is now
optional and aliased to `hostedsubdomain`; `hostname` and `url` are also parsed.

### Added

- **`create_maintenance()` now guards against the silent status-page overflow.**
Hyperping's v1 maintenance-windows API accepts a create with more than 51 status
pages (returns a uuid) but never persists the window. `create_maintenance` now raises
`HyperpingValidationError` when `statuspages` exceeds `MAX_STATUSPAGES_PER_MAINTENANCE`
(51), instead of returning a phantom window. Split larger sets across multiple windows.
- **`create_maintenance_windows()`** (sync + async): convenience helper that splits a
large `statuspages` list into consecutive windows of at most 51 pages, so callers can
cover more pages than one window allows. Each window carries the full monitor set (the
API requires at least one monitor per window). `MAX_STATUSPAGES_PER_MAINTENANCE` is
exported for callers that chunk themselves (e.g. a broadcast-to-all-tenants command).

## [1.8.0] - 2026-05-31

This is a security-focused release. It closes several credential-leak and validation gaps surfaced by an independent audit. One change is breaking for local-development workflows; see Upgrade Notes below.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "hyperping"
version = "1.8.0"
version = "1.8.1"
description = "Python SDK for the Hyperping uptime monitoring and incident management API"
readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "MIT"}
Expand Down
2 changes: 2 additions & 0 deletions src/hyperping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from hyperping._async_client import AsyncHyperpingClient
from hyperping._async_mcp_client import AsyncHyperpingMcpClient
from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
from hyperping._version import __version__
from hyperping.client import (
CircuitBreaker,
Expand Down Expand Up @@ -150,6 +151,7 @@
"IncidentStatus",
"IncidentUpdateCreate",
# Maintenance
"MAX_STATUSPAGES_PER_MAINTENANCE",
"Maintenance",
"MaintenanceCreate",
"MaintenanceUpdate",
Expand Down
38 changes: 38 additions & 0 deletions src/hyperping/_async_maintenance_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import logging
from datetime import UTC, datetime

from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
from hyperping._protocols import _AsyncClientProtocol
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
from hyperping.endpoints import Endpoint
from hyperping.exceptions import HyperpingValidationError
from hyperping.models import (
Maintenance,
MaintenanceCreate,
Expand Down Expand Up @@ -82,6 +84,15 @@ async def create_maintenance(self, maintenance: MaintenanceCreate) -> Maintenanc
v1 API returns {"uuid": "..."} on create, not the full maintenance object.
The full maintenance window is fetched after creation.
"""
n_statuspages = len(maintenance.statuspages or [])
if n_statuspages > MAX_STATUSPAGES_PER_MAINTENANCE:
raise HyperpingValidationError(
f"A maintenance window can reference at most "
f"{MAX_STATUSPAGES_PER_MAINTENANCE} status pages, but {n_statuspages} "
f"were supplied. Above this limit Hyperping's API accepts the create "
f"(returns a uuid) but silently fails to persist the window. Split the "
f"status pages across multiple maintenance windows."
)
payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
response = expect_dict(
await self._request("POST", Endpoint.MAINTENANCE, json=payload),
Expand All @@ -91,6 +102,33 @@ async def create_maintenance(self, maintenance: MaintenanceCreate) -> Maintenanc
return await self.get_maintenance(response["uuid"])
return Maintenance.model_validate(response)

async def create_maintenance_windows(
self,
maintenance: MaintenanceCreate,
*,
chunk_size: int = MAX_STATUSPAGES_PER_MAINTENANCE,
) -> list[Maintenance]:
"""Create one or more windows, splitting status pages into chunks.

Async mirror of
:meth:`~hyperping._maintenance_mixin.MaintenanceMixin.create_maintenance_windows`.
"""
if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_MAINTENANCE:
raise HyperpingValidationError(
f"chunk_size must be between 1 and "
f"{MAX_STATUSPAGES_PER_MAINTENANCE}, got {chunk_size}."
)
pages = list(maintenance.statuspages or [])
if len(pages) <= chunk_size:
return [await self.create_maintenance(maintenance)]
windows: list[Maintenance] = []
for start in range(0, len(pages), chunk_size):
chunk = maintenance.model_copy(
update={"statuspages": pages[start : start + chunk_size]}
)
windows.append(await self.create_maintenance(chunk))
return windows

async def update_maintenance(
self,
maintenance_id: str,
Expand Down
63 changes: 63 additions & 0 deletions src/hyperping/_maintenance_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,22 @@
from hyperping._protocols import _ClientProtocol
from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
from hyperping.endpoints import Endpoint
from hyperping.exceptions import HyperpingValidationError
from hyperping.models import (
Maintenance,
MaintenanceCreate,
MaintenanceUpdate,
)

# Hyperping's v1 maintenance-windows API silently drops a create when the
# ``statuspages`` array exceeds this many entries: the POST still returns a
# ``{"uuid": ...}`` but the window is never persisted (the follow-up GET 404s
# and it is absent from the list). Empirically the cutoff is 51 (51 persists,
# 52+ vanishes), verified against the live API on 2026-07-14. Guard the create
# so callers get a clear error instead of a phantom window; split larger sets
# across multiple windows.
MAX_STATUSPAGES_PER_MAINTENANCE = 51

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -88,6 +98,15 @@ def create_maintenance(self, maintenance: MaintenanceCreate) -> Maintenance:
v1 API returns {"uuid": "..."} on create, not the full maintenance object.
The full maintenance window is fetched after creation.
"""
n_statuspages = len(maintenance.statuspages or [])
if n_statuspages > MAX_STATUSPAGES_PER_MAINTENANCE:
raise HyperpingValidationError(
f"A maintenance window can reference at most "
f"{MAX_STATUSPAGES_PER_MAINTENANCE} status pages, but {n_statuspages} "
f"were supplied. Above this limit Hyperping's API accepts the create "
f"(returns a uuid) but silently fails to persist the window. Split the "
f"status pages across multiple maintenance windows."
)
payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
response = expect_dict(
self._request("POST", Endpoint.MAINTENANCE, json=payload),
Expand All @@ -99,6 +118,50 @@ def create_maintenance(self, maintenance: MaintenanceCreate) -> Maintenance:
return self.get_maintenance(response["uuid"])
return Maintenance.model_validate(response)

def create_maintenance_windows(
self,
maintenance: MaintenanceCreate,
*,
chunk_size: int = MAX_STATUSPAGES_PER_MAINTENANCE,
) -> list[Maintenance]:
"""Create one or more windows, splitting status pages into chunks.

Hyperping silently fails to persist a window with more than
:data:`MAX_STATUSPAGES_PER_MAINTENANCE` status pages (see
:meth:`create_maintenance`). This helper splits a large ``statuspages``
list into consecutive windows of at most ``chunk_size`` pages so every
page is covered. Each window carries the full ``monitors`` set (the API
rejects a window with no monitors); overlapping monitor coverage across
the windows is harmless.

Args:
maintenance: Creation data; ``statuspages`` may exceed the limit.
chunk_size: Max status pages per window (1..
:data:`MAX_STATUSPAGES_PER_MAINTENANCE`).

Returns:
The created windows in page order (a single window when the pages
fit in one chunk).

Raises:
HyperpingValidationError: If ``chunk_size`` is out of range.
"""
if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_MAINTENANCE:
raise HyperpingValidationError(
f"chunk_size must be between 1 and "
f"{MAX_STATUSPAGES_PER_MAINTENANCE}, got {chunk_size}."
)
pages = list(maintenance.statuspages or [])
if len(pages) <= chunk_size:
return [self.create_maintenance(maintenance)]
windows: list[Maintenance] = []
for start in range(0, len(pages), chunk_size):
chunk = maintenance.model_copy(
update={"statuspages": pages[start : start + chunk_size]}
)
windows.append(self.create_maintenance(chunk))
return windows

def update_maintenance(
self,
maintenance_id: str,
Expand Down
2 changes: 1 addition & 1 deletion src/hyperping/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.7.0"
__version__ = "1.8.1"
13 changes: 12 additions & 1 deletion src/hyperping/models/_statuspage_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ class StatusPage(BaseModel):

uuid: str = Field(..., description="Status page UUID")
name: str = Field(..., description="Status page display name")
subdomain: str = Field(..., description="Status page subdomain")
# The v2 list/get API returns the hosted subdomain under the key
# ``hostedsubdomain`` and omits it entirely on some responses. It was
# previously modelled as a required ``subdomain`` field, which made every
# record fail validation and be silently dropped (list_status_pages
# returned []). Alias to the real key and make it optional.
subdomain: str | None = Field(
default=None,
alias="hostedsubdomain",
description="Hosted status page subdomain (API key: 'hostedsubdomain'; may be absent)",
)
hostname: str | None = Field(default=None, description="Custom domain hostname, if any")
url: str | None = Field(default=None, description="Public status page URL")
custom_domain: str | None = Field(
default=None, alias="customDomain", description="Custom domain"
)
Expand Down
75 changes: 74 additions & 1 deletion tests/unit/test_maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from hyperping.client import HyperpingClient
from hyperping.endpoints import API_BASE, Endpoint
from hyperping.exceptions import HyperpingNotFoundError
from hyperping.exceptions import HyperpingNotFoundError, HyperpingValidationError
from hyperping.models import (
Maintenance,
MaintenanceCreate,
Expand Down Expand Up @@ -199,6 +199,79 @@ def test_create_maintenance(self, client: HyperpingClient) -> None:
assert mw.uuid == "mw_new"
assert mw.name == "New Maintenance"

def test_create_maintenance_rejects_too_many_statuspages(
self, client: HyperpingClient
) -> None:
""">51 status pages must raise, not silently phantom-fail.

Hyperping's API accepts the create (returns a uuid) but never persists
the window above the limit; the client guards against it before the
POST, so no HTTP call is made.
"""
with pytest.raises(HyperpingValidationError, match="at most 51 status pages"):
client.create_maintenance(
MaintenanceCreate(
name="Too many pages",
start_date="2024-01-20T00:00:00Z",
end_date="2024-01-20T02:00:00Z",
monitors=["mon_1"],
statuspages=[f"sp_{i}" for i in range(52)],
)
)

@respx.mock
def test_create_maintenance_windows_chunks_statuspages(
self, client: HyperpingClient
) -> None:
""">51 status pages are split across multiple windows of <=chunk_size."""
import json

create_route = respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock(
return_value=httpx.Response(201, json={"uuid": "mw_x"})
)
respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_x").mock(
return_value=httpx.Response(
200,
json={
"uuid": "mw_x",
"name": "Big",
"start_date": "2024-01-20T00:00:00Z",
"end_date": "2024-01-20T02:00:00Z",
"monitors": ["mon_1"],
"statuspages": [],
},
)
)
windows = client.create_maintenance_windows(
MaintenanceCreate(
name="Big",
start_date="2024-01-20T00:00:00Z",
end_date="2024-01-20T02:00:00Z",
monitors=["mon_1"],
statuspages=[f"sp_{i}" for i in range(60)],
)
)
assert len(windows) == 2
assert create_route.call_count == 2
sizes = [len(json.loads(c.request.content)["statuspages"]) for c in create_route.calls]
assert sizes == [51, 9] # every chunk within the 51-page limit

def test_create_maintenance_windows_rejects_bad_chunk_size(
self, client: HyperpingClient
) -> None:
"""chunk_size outside 1..51 is rejected before any HTTP call."""
with pytest.raises(HyperpingValidationError, match="chunk_size"):
client.create_maintenance_windows(
MaintenanceCreate(
name="X",
start_date="2024-01-20T00:00:00Z",
end_date="2024-01-20T02:00:00Z",
monitors=["mon_1"],
statuspages=["sp_1"],
),
chunk_size=99,
)

@respx.mock
def test_update_maintenance(self, client: HyperpingClient) -> None:
"""Test updating a maintenance window (read-modify-write)."""
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_statuspages.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ def test_status_page_parse(self) -> None:
assert page.public is True
assert len(page.monitors) == 2

def test_status_page_parse_live_v2_shape(self) -> None:
"""Live v2 API returns 'hostedsubdomain' (not 'subdomain') plus hostname/url.

Regression: records were previously dropped because 'subdomain' was
required under the wrong key, so list_status_pages() returned [].
"""
data = {
"uuid": "sp_live1",
"name": "Menora",
"hostname": "menora-status.hyp.co.il",
"hostedsubdomain": "menora",
"url": "https://menora-status.hyp.co.il",
"password_protected": False,
}
page = StatusPage.model_validate(data)
assert page.uuid == "sp_live1"
assert page.subdomain == "menora" # populated via the hostedsubdomain alias
assert page.hostname == "menora-status.hyp.co.il"

def test_status_page_parse_without_subdomain(self) -> None:
"""Custom-domain pages omit the hosted subdomain entirely; must not error."""
page = StatusPage.model_validate({"uuid": "sp_x", "name": "X"})
assert page.subdomain is None

def test_status_page_with_custom_domain(self) -> None:
"""Test parsing a status page with custom domain."""
data = {
Expand Down