From bdb0530b03d11feb76ab1c68f339e502ab3e7ecb Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Tue, 14 Jul 2026 16:14:10 +0300 Subject: [PATCH] 1.9.0: rename CLI script hyp->hyperping; incident chunking/guard; partial-batch errors BREAKING: the bundled console script is renamed hyp -> hyperping. 'hyp' collides with the hyperping-automation CLI and silently shadowed it when both were installed; use 'hyperping' for the SDK CLI now. - create_incidents() (sync+async): chunk a broadcast incident's status pages to <=51/incident (MAX_STATUSPAGES_PER_INCIDENT); create_incident() guards the cap. - create_maintenance_windows()/create_incidents() raise HyperpingPartialBatchError (carrying the already-created objects) on mid-batch failure instead of orphaning them silently. - CHANGELOG documents the breaking Integration/CLI changes that shipped mislabeled in 1.8.0/1.8.1; this is the corrective minor release. - Tests: incident chunk/guard/partial, maintenance exactly-51 boundary + partial-failure, async chunking. 613 pass, 95% cov, ruff+mypy clean. --- CHANGELOG.md | 52 ++++++++++++++ pyproject.toml | 7 +- src/hyperping/__init__.py | 4 ++ src/hyperping/_async_incidents_mixin.py | 49 +++++++++++++ src/hyperping/_async_maintenance_mixin.py | 25 +++++-- src/hyperping/_incidents_mixin.py | 66 +++++++++++++++++- src/hyperping/_maintenance_mixin.py | 28 ++++++-- src/hyperping/_version.py | 2 +- src/hyperping/exceptions.py | 31 +++++++++ tests/unit/test_async_preexisting.py | 71 +++++++++++++++++++ tests/unit/test_incidents.py | 85 ++++++++++++++++++++++- tests/unit/test_maintenance.py | 78 ++++++++++++++++++++- 12 files changed, 480 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38fb3b1..c28b80a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.9.0] - 2026-07-14 + +Corrective minor release. It renames the bundled console script (breaking) and +retroactively documents breaking changes that shipped mislabeled in 1.8.0/1.8.1 +(see Upgrade Notes). If you pinned `~=1.8.0` or `~=1.8.1` you already received +those breaking changes silently; this entry explains them. + +### BREAKING + +- **The bundled console script is renamed `hyp` → `hyperping`.** 1.8.1 introduced + a `hyp` entry point for the SDK's CLI. `hyp` is the long-standing command of the + separate `hyperping-automation` tool; when both are installed the SDK's script + silently shadowed it (last-writer-wins on `bin/hyp`), breaking that tool's + commands and exposing the SDK's unguarded write commands under a familiar name. + Invoke the SDK CLI as `hyperping …` now. (Removing/renaming a console script is + a breaking change; it is the reason this is 1.9.0, not 1.8.2.) + +### Fixed + +- **`create_maintenance_windows` / `create_incidents` now surface partial failures.** + If a later chunk fails after earlier objects were created, they raise + `HyperpingPartialBatchError` carrying the already-created objects (`.created`, + `.completed`, `.total`) instead of discarding them, so callers can record or + clean up rather than orphaning windows/incidents silently. + +### Added + +- **`create_incidents()`** (sync + async): splits a broadcast incident's status + pages into chunks of at most `MAX_STATUSPAGES_PER_INCIDENT` (51), mirroring + `create_maintenance_windows`. `create_incident()` now raises + `HyperpingValidationError` above the cap instead of silently failing to persist. + NOTE: the 51 cap for incidents is assumed identical to maintenance (same + status-page attachment path) and has not been independently measured against + the live API. +- **`HyperpingPartialBatchError`** and **`MAX_STATUSPAGES_PER_INCIDENT`** exported + from the package root. + +### Upgrade Notes (breaking changes that shipped mislabeled in 1.8.0 / 1.8.1) + +These are not new in 1.9.0; they are documented here because 1.8.0/1.8.1 changed +them without an upgrade note, which is why consumers were caught out: + +- **1.8.0** reconciled the `Integration`, `EscalationPolicy`, and `TeamMember` + models against the production API: `Integration.active` was **removed** and the + integration-type field key is now **`channel`** (was `type`); a new + `EscalationStep` shape was introduced. Any code reading `Integration.active` or + sending `type=` breaks. +- **1.8.1** added the `hyp` console script (renamed here) and the status-page / + maintenance fixes; it was released as a patch despite the CLI addition. + +Guidance: pin `hyperping>=1.9.0,<2` and, if you consume the CLI, use `hyperping`. + ## [1.8.1] - 2026-07-14 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 3c8f58b..90d73ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hyperping" -version = "1.8.1" +version = "1.9.0" description = "Python SDK for the Hyperping uptime monitoring and incident management API" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "MIT"} @@ -44,7 +44,10 @@ dev = [ ] [project.scripts] -hyp = "hyperping.cli._app:app" +# Console script is 'hyperping' (NOT 'hyp'): 'hyp' collides with the long-lived +# hyperping-automation CLI (hyp_status) and, when both are installed, silently +# shadows it. See CHANGELOG 1.9.0 BREAKING notes. +hyperping = "hyperping.cli._app:app" [project.urls] Homepage = "https://github.com/develeap/hyperping-python" diff --git a/src/hyperping/__init__.py b/src/hyperping/__init__.py index 1e79ce0..de82be4 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._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE from hyperping._version import __version__ from hyperping.client import ( @@ -35,6 +36,7 @@ HyperpingAPIError, HyperpingAuthError, HyperpingNotFoundError, + HyperpingPartialBatchError, HyperpingRateLimitError, HyperpingValidationError, ) @@ -118,6 +120,7 @@ "APIVersion", # Exceptions "HyperpingAPIError", + "HyperpingPartialBatchError", "HyperpingAuthError", "HyperpingNotFoundError", "HyperpingRateLimitError", @@ -151,6 +154,7 @@ "IncidentStatus", "IncidentUpdateCreate", # Maintenance + "MAX_STATUSPAGES_PER_INCIDENT", "MAX_STATUSPAGES_PER_MAINTENANCE", "Maintenance", "MaintenanceCreate", diff --git a/src/hyperping/_async_incidents_mixin.py b/src/hyperping/_async_incidents_mixin.py index 59006f8..c29e58e 100644 --- a/src/hyperping/_async_incidents_mixin.py +++ b/src/hyperping/_async_incidents_mixin.py @@ -9,9 +9,15 @@ import logging from datetime import UTC, datetime +from hyperping._incidents_mixin import MAX_STATUSPAGES_PER_INCIDENT 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 ( + HyperpingAPIError, + HyperpingPartialBatchError, + HyperpingValidationError, +) from hyperping.models import ( AddIncidentUpdateRequest, Incident, @@ -81,6 +87,15 @@ async def create_incident(self, incident: IncidentCreate) -> Incident: v3 API returns {"message": "...", "uuid": "..."} on create, not the full incident object. The full incident is fetched after creation. """ + n_statuspages = len(incident.statuspages or []) + if n_statuspages > MAX_STATUSPAGES_PER_INCIDENT: + raise HyperpingValidationError( + f"An incident can reference at most {MAX_STATUSPAGES_PER_INCIDENT} " + f"status pages, but {n_statuspages} were supplied. Above this limit " + f"Hyperping's API is expected to accept the create (returns a uuid) but " + f"silently fail to persist it. Use create_incidents() to split the " + f"status pages across multiple incidents." + ) payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json") response = expect_dict( await self._request("POST", Endpoint.INCIDENTS, json=payload), @@ -90,6 +105,40 @@ async def create_incident(self, incident: IncidentCreate) -> Incident: return await self.get_incident(response["uuid"]) return Incident.model_validate(response) + async def create_incidents( + self, + incident: IncidentCreate, + *, + chunk_size: int = MAX_STATUSPAGES_PER_INCIDENT, + ) -> list[Incident]: + """Async mirror of + :meth:`~hyperping._incidents_mixin.IncidentsMixin.create_incidents`. + """ + if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_INCIDENT: + raise HyperpingValidationError( + f"chunk_size must be between 1 and " + f"{MAX_STATUSPAGES_PER_INCIDENT}, got {chunk_size}." + ) + pages = list(incident.statuspages or []) + if len(pages) <= chunk_size: + return [await self.create_incident(incident)] + chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)] + created: list[Incident] = [] + for idx, chunk_pages in enumerate(chunks): + chunk = incident.model_copy(update={"statuspages": chunk_pages}) + try: + created.append(await self.create_incident(chunk)) + except HyperpingAPIError as exc: + raise HyperpingPartialBatchError( + f"create_incidents failed on incident {idx + 1} of " + f"{len(chunks)}: {exc}. {len(created)} incident(s) were already " + f"created and remain live.", + created=created, + completed=len(created), + total=len(chunks), + ) from exc + return created + async def update_incident( self, incident_id: str, diff --git a/src/hyperping/_async_maintenance_mixin.py b/src/hyperping/_async_maintenance_mixin.py index 8e6dedc..9366c9b 100644 --- a/src/hyperping/_async_maintenance_mixin.py +++ b/src/hyperping/_async_maintenance_mixin.py @@ -13,7 +13,11 @@ 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.exceptions import ( + HyperpingAPIError, + HyperpingPartialBatchError, + HyperpingValidationError, +) from hyperping.models import ( Maintenance, MaintenanceCreate, @@ -121,12 +125,21 @@ async def create_maintenance_windows( pages = list(maintenance.statuspages or []) if len(pages) <= chunk_size: return [await self.create_maintenance(maintenance)] + chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)] 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)) + for idx, chunk_pages in enumerate(chunks): + chunk = maintenance.model_copy(update={"statuspages": chunk_pages}) + try: + windows.append(await self.create_maintenance(chunk)) + except HyperpingAPIError as exc: + raise HyperpingPartialBatchError( + f"create_maintenance_windows failed on window {idx + 1} of " + f"{len(chunks)}: {exc}. {len(windows)} window(s) were already " + f"created and remain live.", + created=windows, + completed=len(windows), + total=len(chunks), + ) from exc return windows async def update_maintenance( diff --git a/src/hyperping/_incidents_mixin.py b/src/hyperping/_incidents_mixin.py index af796ed..0bb5698 100644 --- a/src/hyperping/_incidents_mixin.py +++ b/src/hyperping/_incidents_mixin.py @@ -12,7 +12,11 @@ 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 HyperpingAPIError +from hyperping.exceptions import ( + HyperpingAPIError, + HyperpingPartialBatchError, + HyperpingValidationError, +) from hyperping.models import ( AddIncidentUpdateRequest, # canonical name (M18) Incident, @@ -24,6 +28,14 @@ logger = logging.getLogger(__name__) +# Incidents attach status pages via the same mechanism as maintenance windows, +# which the API caps at 51 per request (see MAX_STATUSPAGES_PER_MAINTENANCE): +# beyond the cap the create is accepted but silently not persisted. The +# incident cap has NOT been independently verified against the live API; it is +# assumed identical because the status-page attachment is the same backend +# path. Adjust if the incident endpoint is later measured to differ. +MAX_STATUSPAGES_PER_INCIDENT = 51 + class IncidentsMixin(_ClientProtocol): """Incident-related API operations.""" @@ -87,6 +99,15 @@ def create_incident(self, incident: IncidentCreate) -> Incident: v3 API returns {"message": "...", "uuid": "..."} on create, not the full incident object. The full incident is fetched after creation. """ + n_statuspages = len(incident.statuspages or []) + if n_statuspages > MAX_STATUSPAGES_PER_INCIDENT: + raise HyperpingValidationError( + f"An incident can reference at most {MAX_STATUSPAGES_PER_INCIDENT} " + f"status pages, but {n_statuspages} were supplied. Above this limit " + f"Hyperping's API is expected to accept the create (returns a uuid) but " + f"silently fail to persist it. Use create_incidents() to split the " + f"status pages across multiple incidents." + ) payload = incident.model_dump(exclude_none=True, by_alias=True, mode="json") response = expect_dict( self._request("POST", Endpoint.INCIDENTS, json=payload), @@ -98,6 +119,49 @@ def create_incident(self, incident: IncidentCreate) -> Incident: return self.get_incident(response["uuid"]) return Incident.model_validate(response) + def create_incidents( + self, + incident: IncidentCreate, + *, + chunk_size: int = MAX_STATUSPAGES_PER_INCIDENT, + ) -> list[Incident]: + """Create one or more incidents, splitting status pages into chunks. + + Mirrors :meth:`create_maintenance_windows`: a broadcast incident that + targets more status pages than the per-request cap is split into + consecutive incidents of at most ``chunk_size`` pages. Because the page + sets are disjoint, each status page still shows exactly one incident. + + Returns the created incidents in page order (a single incident when the + pages fit in one chunk). Raises :class:`HyperpingValidationError` for a + bad ``chunk_size`` and :class:`HyperpingPartialBatchError` if a later + chunk fails after earlier incidents were already created. + """ + if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_INCIDENT: + raise HyperpingValidationError( + f"chunk_size must be between 1 and " + f"{MAX_STATUSPAGES_PER_INCIDENT}, got {chunk_size}." + ) + pages = list(incident.statuspages or []) + if len(pages) <= chunk_size: + return [self.create_incident(incident)] + chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)] + created: list[Incident] = [] + for idx, chunk_pages in enumerate(chunks): + chunk = incident.model_copy(update={"statuspages": chunk_pages}) + try: + created.append(self.create_incident(chunk)) + except HyperpingAPIError as exc: + raise HyperpingPartialBatchError( + f"create_incidents failed on incident {idx + 1} of " + f"{len(chunks)}: {exc}. {len(created)} incident(s) were already " + f"created and remain live.", + created=created, + completed=len(created), + total=len(chunks), + ) from exc + return created + def update_incident( self, incident_id: str, diff --git a/src/hyperping/_maintenance_mixin.py b/src/hyperping/_maintenance_mixin.py index 1bd5a8b..63e53f0 100644 --- a/src/hyperping/_maintenance_mixin.py +++ b/src/hyperping/_maintenance_mixin.py @@ -12,7 +12,11 @@ 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.exceptions import ( + HyperpingAPIError, + HyperpingPartialBatchError, + HyperpingValidationError, +) from hyperping.models import ( Maintenance, MaintenanceCreate, @@ -154,12 +158,24 @@ def create_maintenance_windows( pages = list(maintenance.statuspages or []) if len(pages) <= chunk_size: return [self.create_maintenance(maintenance)] + chunks = [pages[i : i + chunk_size] for i in range(0, len(pages), chunk_size)] 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)) + for idx, chunk_pages in enumerate(chunks): + chunk = maintenance.model_copy(update={"statuspages": chunk_pages}) + try: + windows.append(self.create_maintenance(chunk)) + except HyperpingAPIError as exc: + # Earlier windows are already live and are NOT rolled back; hand + # them back so the caller can record or clean them up rather than + # orphaning them silently. + raise HyperpingPartialBatchError( + f"create_maintenance_windows failed on window {idx + 1} of " + f"{len(chunks)}: {exc}. {len(windows)} window(s) were already " + f"created and remain live.", + created=windows, + completed=len(windows), + total=len(chunks), + ) from exc return windows def update_maintenance( diff --git a/src/hyperping/_version.py b/src/hyperping/_version.py index 2d986fc..0a0a43a 100644 --- a/src/hyperping/_version.py +++ b/src/hyperping/_version.py @@ -1 +1 @@ -__version__ = "1.8.1" +__version__ = "1.9.0" diff --git a/src/hyperping/exceptions.py b/src/hyperping/exceptions.py index 4ca4f19..a6c61e6 100644 --- a/src/hyperping/exceptions.py +++ b/src/hyperping/exceptions.py @@ -113,3 +113,34 @@ def __init__( ) -> None: super().__init__(message, **kwargs) self.validation_errors = validation_errors or [] + + +class HyperpingPartialBatchError(HyperpingAPIError): + """Raised when a multi-item batch operation fails partway through. + + Used by helpers that split one logical request into several API calls + (e.g. :meth:`create_maintenance_windows`, :meth:`create_incidents` when the + status-page list exceeds the per-request cap). If an item fails after + earlier ones succeeded, the already-created objects are NOT rolled back; + they are attached here so the caller can record or clean them up. + + Args: + message: Human-readable error description. + created: The objects successfully created before the failure. + completed: How many items succeeded. + total: How many items were attempted in the batch. + **kwargs: Forwarded to :class:`HyperpingAPIError`. + """ + + def __init__( + self, + message: str, + created: list[Any] | None = None, + completed: int | None = None, + total: int | None = None, + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) + self.created = created or [] + self.completed = completed if completed is not None else len(self.created) + self.total = total diff --git a/tests/unit/test_async_preexisting.py b/tests/unit/test_async_preexisting.py index 0ef9573..e030220 100644 --- a/tests/unit/test_async_preexisting.py +++ b/tests/unit/test_async_preexisting.py @@ -714,3 +714,74 @@ async def test_incident_not_found(self, async_client): ) with pytest.raises(HyperpingNotFoundError): await async_client.get_incident("inc_x") + + +class TestAsyncBatchChunking: + """Async chunking helpers added in 1.9.0.""" + + @respx.mock + @pytest.mark.asyncio + async def test_create_maintenance_windows_chunks(self, async_client): + from hyperping.models import MaintenanceCreate + + 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": "B", + "start_date": "2024-01-20T00:00:00Z", + "end_date": "2024-01-20T02:00:00Z", + "monitors": ["mon_1"], + "statuspages": [], + }, + ) + ) + result = await async_client.create_maintenance_windows( + MaintenanceCreate( + name="B", + 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(result) == 2 + assert route.call_count == 2 + + @respx.mock + @pytest.mark.asyncio + async def test_create_incidents_chunks(self, async_client): + from hyperping.models import IncidentCreate, IncidentType, LocalizedText + + route = respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock( + return_value=httpx.Response(201, json={"message": "ok", "uuid": "inci_x"}) + ) + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inci_x").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "inci_x", + "date": "2024-01-15T10:00:00Z", + "title": {"en": "X"}, + "text": {"en": "Y"}, + "type": "incident", + "affectedComponents": [], + "statuspages": [], + "updates": [], + }, + ) + ) + result = await async_client.create_incidents( + IncidentCreate( + title=LocalizedText(en="X"), + text=LocalizedText(en="Y"), + type=IncidentType.INCIDENT, + statuspages=[f"sp_{i}" for i in range(60)], + ) + ) + assert len(result) == 2 + assert route.call_count == 2 diff --git a/tests/unit/test_incidents.py b/tests/unit/test_incidents.py index 91b686f..51bb314 100644 --- a/tests/unit/test_incidents.py +++ b/tests/unit/test_incidents.py @@ -8,7 +8,11 @@ from hyperping.client import HyperpingClient from hyperping.endpoints import API_BASE, Endpoint -from hyperping.exceptions import HyperpingNotFoundError +from hyperping.exceptions import ( + HyperpingNotFoundError, + HyperpingPartialBatchError, + HyperpingValidationError, +) from hyperping.models import ( AddIncidentUpdateRequest, Incident, @@ -292,3 +296,82 @@ def test_update_incident_not_found(self, client: HyperpingClient) -> None: "inci_missing", IncidentUpdateRequest(title=LocalizedText(en="Title")), ) + + +class TestCreateIncidents: + """create_incidents() chunking, guard, and partial-failure behavior.""" + + def _incident(self, n_pages: int) -> IncidentCreate: + return IncidentCreate( + title=LocalizedText(en="X"), + text=LocalizedText(en="Y"), + type=IncidentType.INCIDENT, + statuspages=[f"sp_{i}" for i in range(n_pages)], + ) + + def test_create_incident_rejects_over_cap(self, client: HyperpingClient) -> None: + with pytest.raises(HyperpingValidationError, match="at most 51 status pages"): + client.create_incident(self._incident(52)) + + def test_create_incidents_rejects_bad_chunk_size(self, client: HyperpingClient) -> None: + with pytest.raises(HyperpingValidationError, match="chunk_size"): + client.create_incidents(self._incident(1), chunk_size=99) + + @respx.mock + def test_create_incidents_chunks_statuspages(self, client: HyperpingClient) -> None: + import json + + route = respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock( + return_value=httpx.Response(201, json={"message": "ok", "uuid": "inci_x"}) + ) + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inci_x").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "inci_x", + "date": "2024-01-15T10:00:00Z", + "title": {"en": "X"}, + "text": {"en": "Y"}, + "type": "incident", + "affectedComponents": [], + "statuspages": [], + "updates": [], + }, + ) + ) + result = client.create_incidents(self._incident(60)) + assert len(result) == 2 + assert route.call_count == 2 + sizes = [len(json.loads(c.request.content)["statuspages"]) for c in route.calls] + assert sizes == [51, 9] + + @respx.mock + def test_create_incidents_partial_failure(self, client: HyperpingClient) -> None: + posts = {"n": 0} + + def post_side(request: httpx.Request) -> httpx.Response: + posts["n"] += 1 + if posts["n"] == 1: + return httpx.Response(201, json={"message": "ok", "uuid": "inci_1"}) + return httpx.Response(500, json={"error": "boom"}) + + respx.post(f"{API_BASE}{Endpoint.INCIDENTS}").mock(side_effect=post_side) + respx.get(f"{API_BASE}{Endpoint.INCIDENTS}/inci_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "inci_1", + "date": "2024-01-15T10:00:00Z", + "title": {"en": "X"}, + "text": {"en": "Y"}, + "type": "incident", + "affectedComponents": [], + "statuspages": [], + "updates": [], + }, + ) + ) + with pytest.raises(HyperpingPartialBatchError) as ei: + client.create_incidents(self._incident(60)) + assert len(ei.value.created) == 1 + assert ei.value.total == 2 diff --git a/tests/unit/test_maintenance.py b/tests/unit/test_maintenance.py index caac3a3..f509e0f 100644 --- a/tests/unit/test_maintenance.py +++ b/tests/unit/test_maintenance.py @@ -8,7 +8,11 @@ from hyperping.client import HyperpingClient from hyperping.endpoints import API_BASE, Endpoint -from hyperping.exceptions import HyperpingNotFoundError, HyperpingValidationError +from hyperping.exceptions import ( + HyperpingNotFoundError, + HyperpingPartialBatchError, + HyperpingValidationError, +) from hyperping.models import ( Maintenance, MaintenanceCreate, @@ -356,3 +360,75 @@ def test_is_monitor_in_maintenance(self, client: HyperpingClient) -> None: assert client.is_monitor_in_maintenance("mon_1") is True assert client.is_monitor_in_maintenance("mon_other") is False + + @respx.mock + def test_create_maintenance_windows_exactly_51_is_single_window( + self, client: HyperpingClient + ) -> None: + """51 pages is the accepted boundary — one window, not rejected, not split.""" + 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": "B", + "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="B", + 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(51)], + ) + ) + assert len(windows) == 1 + assert route.call_count == 1 + + @respx.mock + def test_create_maintenance_windows_partial_failure(self, client: HyperpingClient) -> None: + """A later-chunk failure raises HyperpingPartialBatchError with the created ones.""" + posts = {"n": 0} + + def post_side(request: httpx.Request) -> httpx.Response: + posts["n"] += 1 + if posts["n"] == 1: + return httpx.Response(201, json={"uuid": "mw_1"}) + return httpx.Response(500, json={"error": "boom"}) + + respx.post(f"{API_BASE}{Endpoint.MAINTENANCE}").mock(side_effect=post_side) + respx.get(f"{API_BASE}{Endpoint.MAINTENANCE}/mw_1").mock( + return_value=httpx.Response( + 200, + json={ + "uuid": "mw_1", + "name": "B", + "start_date": "2024-01-20T00:00:00Z", + "end_date": "2024-01-20T02:00:00Z", + "monitors": ["mon_1"], + "statuspages": [], + }, + ) + ) + with pytest.raises(HyperpingPartialBatchError) as ei: + client.create_maintenance_windows( + MaintenanceCreate( + name="B", + 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(ei.value.created) == 1 + assert ei.value.completed == 1 + assert ei.value.total == 2