From fedae0466f94bf635489866feb5a8f5e1f77952e Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Tue, 14 Jul 2026 13:43:46 +0300 Subject: [PATCH 1/3] fix(statuspage): parse live v2 shape; guard maintenance status-page overflow list_status_pages() returned [] against the live v2 API: the StatusPage model required 'subdomain', but the API returns the hosted subdomain under 'hostedsubdomain' (and omits it for custom-domain pages), so every record failed validation and was silently dropped. Make subdomain optional + aliased to hostedsubdomain; also parse hostname and url. create_maintenance() silently phantom-failed with >51 status pages: the API returns a uuid but never persists the window. Guard with a clear HyperpingValidationError (MAX_STATUSPAGES_PER_MAINTENANCE=51, verified live). Bump 1.8.0 -> 1.8.1; add tests for both. --- CHANGELOG.md | 18 ++++++++++++++++ pyproject.toml | 2 +- src/hyperping/_maintenance_mixin.py | 19 +++++++++++++++++ src/hyperping/models/_statuspage_models.py | 13 +++++++++++- tests/unit/test_maintenance.py | 22 +++++++++++++++++++- tests/unit/test_statuspages.py | 24 ++++++++++++++++++++++ 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 394cb80..4dd90ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ 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. + ## [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. diff --git a/pyproject.toml b/pyproject.toml index 9e9540c..3c8f58b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/hyperping/_maintenance_mixin.py b/src/hyperping/_maintenance_mixin.py index 839c6b4..70e4dbd 100644 --- a/src/hyperping/_maintenance_mixin.py +++ b/src/hyperping/_maintenance_mixin.py @@ -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__) @@ -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), diff --git a/src/hyperping/models/_statuspage_models.py b/src/hyperping/models/_statuspage_models.py index cd048ed..f07937f 100644 --- a/src/hyperping/models/_statuspage_models.py +++ b/src/hyperping/models/_statuspage_models.py @@ -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" ) diff --git a/tests/unit/test_maintenance.py b/tests/unit/test_maintenance.py index 81aaa94..a0692c4 100644 --- a/tests/unit/test_maintenance.py +++ b/tests/unit/test_maintenance.py @@ -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, @@ -199,6 +199,26 @@ 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_update_maintenance(self, client: HyperpingClient) -> None: """Test updating a maintenance window (read-modify-write).""" diff --git a/tests/unit/test_statuspages.py b/tests/unit/test_statuspages.py index 26d7213..bba2c7c 100644 --- a/tests/unit/test_statuspages.py +++ b/tests/unit/test_statuspages.py @@ -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 = { From 4de99c54f7cc044acc255195d61f342f89a7a9c5 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Tue, 14 Jul 2026 14:05:31 +0300 Subject: [PATCH 2/3] feat(maintenance): add create_maintenance_windows() to chunk status pages Callers that need to cover more than 51 status pages (e.g. a broadcast to all tenant status pages) hit the per-window limit that create_maintenance() now rejects. create_maintenance_windows() splits the statuspages list into consecutive windows of at most 51 pages, each carrying the full monitor set (the API requires >=1 monitor per window). Added on both the sync and async clients; MAX_STATUSPAGES_PER_MAINTENANCE is exported for callers that chunk themselves. Also adds the >51 guard to the async create_maintenance for parity. --- CHANGELOG.md | 5 +++ src/hyperping/__init__.py | 2 + src/hyperping/_async_maintenance_mixin.py | 38 ++++++++++++++++ src/hyperping/_maintenance_mixin.py | 44 +++++++++++++++++++ tests/unit/test_maintenance.py | 53 +++++++++++++++++++++++ 5 files changed, 142 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd90ca..38fb3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index f8665ce..1e79ce0 100644 --- a/src/hyperping/__init__.py +++ b/src/hyperping/__init__.py @@ -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, @@ -150,6 +151,7 @@ "IncidentStatus", "IncidentUpdateCreate", # Maintenance + "MAX_STATUSPAGES_PER_MAINTENANCE", "Maintenance", "MaintenanceCreate", "MaintenanceUpdate", diff --git a/src/hyperping/_async_maintenance_mixin.py b/src/hyperping/_async_maintenance_mixin.py index 7cbc4fc..8e6dedc 100644 --- a/src/hyperping/_async_maintenance_mixin.py +++ b/src/hyperping/_async_maintenance_mixin.py @@ -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, @@ -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), @@ -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, diff --git a/src/hyperping/_maintenance_mixin.py b/src/hyperping/_maintenance_mixin.py index 70e4dbd..1bd5a8b 100644 --- a/src/hyperping/_maintenance_mixin.py +++ b/src/hyperping/_maintenance_mixin.py @@ -118,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, diff --git a/tests/unit/test_maintenance.py b/tests/unit/test_maintenance.py index a0692c4..caac3a3 100644 --- a/tests/unit/test_maintenance.py +++ b/tests/unit/test_maintenance.py @@ -219,6 +219,59 @@ def test_create_maintenance_rejects_too_many_statuspages( ) ) + @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).""" From 2b1464b1d0ab42d8a2cabf3f4abafd95d502ad95 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Tue, 14 Jul 2026 15:00:48 +0300 Subject: [PATCH 3/3] chore: bump __version__ to 1.8.1 (was lagging at 1.7.0) --- src/hyperping/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hyperping/_version.py b/src/hyperping/_version.py index 14d9d2f..2d986fc 100644 --- a/src/hyperping/_version.py +++ b/src/hyperping/_version.py @@ -1 +1 @@ -__version__ = "1.7.0" +__version__ = "1.8.1"