hyperping 1.7.0__tar.gz → 1.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {hyperping-1.7.0 → hyperping-1.8.1}/CHANGELOG.md +65 -0
  2. {hyperping-1.7.0 → hyperping-1.8.1}/PKG-INFO +5 -1
  3. {hyperping-1.7.0 → hyperping-1.8.1}/pyproject.toml +7 -1
  4. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/__init__.py +4 -0
  5. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_client.py +45 -8
  6. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_maintenance_mixin.py +38 -0
  7. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_client.py +52 -1
  8. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_transport.py +19 -5
  9. hyperping-1.8.1/src/hyperping/_internals.py +241 -0
  10. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_maintenance_mixin.py +63 -0
  11. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_mcp_transport.py +20 -5
  12. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_utils.py +17 -3
  13. hyperping-1.8.1/src/hyperping/_version.py +1 -0
  14. hyperping-1.8.1/src/hyperping/cli/_app.py +52 -0
  15. hyperping-1.8.1/src/hyperping/cli/_config.py +24 -0
  16. hyperping-1.8.1/src/hyperping/cli/_incidents.py +87 -0
  17. hyperping-1.8.1/src/hyperping/cli/_monitors.py +90 -0
  18. hyperping-1.8.1/src/hyperping/cli/_output.py +58 -0
  19. hyperping-1.8.1/src/hyperping/cli/_statuspages.py +57 -0
  20. hyperping-1.8.1/src/hyperping/cli/_tenant.py +75 -0
  21. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/client.py +36 -5
  22. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/exceptions.py +18 -5
  23. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/mcp_client.py +52 -1
  24. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/__init__.py +7 -1
  25. hyperping-1.8.1/src/hyperping/models/_integration_models.py +27 -0
  26. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_oncall_models.py +34 -3
  27. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_statuspage_models.py +12 -1
  28. hyperping-1.8.1/tests/unit/__init__.py +0 -0
  29. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_client.py +151 -10
  30. hyperping-1.8.1/tests/unit/test_cli_config.py +28 -0
  31. hyperping-1.8.1/tests/unit/test_cli_incidents.py +121 -0
  32. hyperping-1.8.1/tests/unit/test_cli_monitors.py +95 -0
  33. hyperping-1.8.1/tests/unit/test_cli_statuspages.py +67 -0
  34. hyperping-1.8.1/tests/unit/test_cli_tenant.py +90 -0
  35. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_maintenance.py +74 -1
  36. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_mcp_client.py +153 -29
  37. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_mcp_transport.py +8 -3
  38. hyperping-1.8.1/tests/unit/test_security_base_url.py +163 -0
  39. hyperping-1.8.1/tests/unit/test_security_breaker_cap.py +116 -0
  40. hyperping-1.8.1/tests/unit/test_security_exception_redaction.py +343 -0
  41. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_statuspages.py +24 -0
  42. hyperping-1.7.0/src/hyperping/_internals.py +0 -31
  43. hyperping-1.7.0/src/hyperping/_version.py +0 -1
  44. hyperping-1.7.0/src/hyperping/models/_integration_models.py +0 -14
  45. {hyperping-1.7.0 → hyperping-1.8.1}/.gitignore +0 -0
  46. {hyperping-1.7.0 → hyperping-1.8.1}/CONTRIBUTING.md +0 -0
  47. {hyperping-1.7.0 → hyperping-1.8.1}/LICENSE +0 -0
  48. {hyperping-1.7.0 → hyperping-1.8.1}/README.md +0 -0
  49. {hyperping-1.7.0 → hyperping-1.8.1}/SECURITY.md +0 -0
  50. {hyperping-1.7.0 → hyperping-1.8.1}/scripts/verify_endpoints.py +0 -0
  51. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  52. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_incidents_mixin.py +0 -0
  53. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_monitors_mixin.py +0 -0
  54. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_outages_mixin.py +0 -0
  55. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_statuspages_mixin.py +0 -0
  56. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_circuit_breaker.py +0 -0
  57. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_healthchecks_mixin.py +0 -0
  58. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_incidents_mixin.py +0 -0
  59. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_monitor_constants.py +0 -0
  60. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_monitors_mixin.py +0 -0
  61. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_outages_mixin.py +0 -0
  62. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_protocols.py +0 -0
  63. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_statuspages_mixin.py +0 -0
  64. {hyperping-1.7.0/tests → hyperping-1.8.1/src/hyperping/cli}/__init__.py +0 -0
  65. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/endpoints.py +0 -0
  66. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_healthcheck_models.py +0 -0
  67. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_incident_models.py +0 -0
  68. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_maintenance_models.py +0 -0
  69. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_monitor_models.py +0 -0
  70. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_observability_models.py +0 -0
  71. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_outage_models.py +0 -0
  72. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_reporting_models.py +0 -0
  73. {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/py.typed +0 -0
  74. {hyperping-1.7.0/tests/unit → hyperping-1.8.1/tests}/__init__.py +0 -0
  75. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/conftest.py +0 -0
  76. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_client.py +0 -0
  77. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_transport.py +0 -0
  78. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_preexisting.py +0 -0
  79. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_client_coverage.py +0 -0
  80. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_healthchecks.py +0 -0
  81. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_incidents.py +0 -0
  82. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_monitors.py +0 -0
  83. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_outages.py +0 -0
  84. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_pagination.py +0 -0
  85. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
  86. {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_sdk_surface.py +0 -0
@@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [1.8.1] - 2026-07-14
11
+
12
+ ### Fixed
13
+
14
+ - **`list_status_pages()` returned an empty list against the live v2 API.** The
15
+ `StatusPage` model required a `subdomain` field, but the API returns the hosted
16
+ subdomain under the key `hostedsubdomain` (and omits it entirely for custom-domain
17
+ pages). Every record failed validation and was silently skipped. `subdomain` is now
18
+ optional and aliased to `hostedsubdomain`; `hostname` and `url` are also parsed.
19
+
20
+ ### Added
21
+
22
+ - **`create_maintenance()` now guards against the silent status-page overflow.**
23
+ Hyperping's v1 maintenance-windows API accepts a create with more than 51 status
24
+ pages (returns a uuid) but never persists the window. `create_maintenance` now raises
25
+ `HyperpingValidationError` when `statuspages` exceeds `MAX_STATUSPAGES_PER_MAINTENANCE`
26
+ (51), instead of returning a phantom window. Split larger sets across multiple windows.
27
+ - **`create_maintenance_windows()`** (sync + async): convenience helper that splits a
28
+ large `statuspages` list into consecutive windows of at most 51 pages, so callers can
29
+ cover more pages than one window allows. Each window carries the full monitor set (the
30
+ API requires at least one monitor per window). `MAX_STATUSPAGES_PER_MAINTENANCE` is
31
+ exported for callers that chunk themselves (e.g. a broadcast-to-all-tenants command).
32
+
33
+ ## [1.8.0] - 2026-05-31
34
+
35
+ 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.
36
+
37
+ ### Security
38
+
39
+ - `validate_base_url` is now applied at every constructor that accepts a `base_url` or `mcp_url`: both the sync and async REST clients, both MCP transports, and the high-level MCP clients. The validator rejects non-`https` URLs by default, rejects URLs that carry userinfo (`user:pass@host` or bare `user@host`), and rejects URLs with a query string or fragment. The `Authorization: Bearer` header therefore cannot reach an attacker-controlled host through a misconfigured base URL.
40
+ - `HyperpingAPIError.response_body` is now recursively redacted at construction time. Sensitive keys (authorization, tokens, cookies, set-cookie, request headers, request body, emails, webhooks) are replaced with `[REDACTED]`. Free-form string values are scrubbed for `Bearer <token>` and `sk_<token>` shapes. The recursion is bounded to prevent `RecursionError` from pathological payloads. Applies to all subclasses, including `HyperpingRateLimitError`.
41
+ - `HyperpingAPIError` formatted messages now strip C0 control bytes (preserving `\t` and `\n`) and cap at 256 characters, so a server-supplied error string carrying ANSI escapes or a 1 KB blob cannot poison terminal output or downstream log pipelines.
42
+ - The MCP transport no longer captures the first 500 bytes of server response as `response_body["raw"]` on rate-limit (429), generic HTTP error, or JSON-parse failure paths. Subscriber emails or webhook URLs that the server may echo cannot leak through that channel.
43
+ - `_utils.parse_list` no longer logs the full `pydantic.ValidationError` string on per-item parse failures (Pydantic v2 includes the offending input by default). It now logs only the exception class and field locations.
44
+ - The `breaker_key_fn` callback is now used through an LRU-bounded map. A custom callback that returns unbounded unique strings can no longer leak memory in long-running processes: the per-endpoint breaker map is capped at 1024 entries and evicts oldest on overflow. Applies to both sync and async clients.
45
+
46
+ ### Added
47
+
48
+ - `allow_insecure: bool = False` keyword argument on every constructor that accepts a `base_url` or `mcp_url`. When set to `True`, `http://` URLs are permitted and an `InsecureTransportWarning` is emitted. Provided for local-development workflows; not recommended for production.
49
+ - `InsecureTransportWarning` warning class exported from `hyperping`.
50
+
51
+ ### Changed
52
+
53
+ - `_parse_retry_after` documents that it intentionally supports only the delta-seconds form of the `Retry-After` header. HTTP-date values fall through to exponential backoff rather than raising. Trade-off is documented in the docstring; no behavioral change for callers passing the integer form.
54
+
55
+ ### Upgrade Notes
56
+
57
+ If your code instantiates `HyperpingClient` (or any MCP client) against a `http://localhost` URL for local development or against a mock server, the constructor will now raise `ValueError`. Pass `allow_insecure=True` to opt in, and expect an `InsecureTransportWarning` at runtime:
58
+
59
+ ```python
60
+ from hyperping import HyperpingClient
61
+
62
+ client = HyperpingClient(
63
+ api_key="sk_test_xxx",
64
+ base_url="http://localhost:8000",
65
+ allow_insecure=True,
66
+ )
67
+ ```
68
+
69
+ Production callers using `https://api.hyperping.io` are unaffected.
70
+
71
+ URLs that previously carried embedded credentials (`https://user:pass@api.example.com`) or a trailing query string / fragment will now also raise at construction time. Refactor to pass credentials through `api_key` and to keep query strings out of the base URL.
72
+
8
73
  ## [1.7.0] - 2026-05-21
9
74
 
10
75
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.7.0
3
+ Version: 1.8.1
4
4
  Summary: Python SDK for the Hyperping uptime monitoring and incident management API
5
5
  Project-URL: Homepage, https://github.com/develeap/hyperping-python
6
6
  Project-URL: Documentation, https://github.com/develeap/hyperping-python#readme
@@ -12,6 +12,7 @@ License: MIT
12
12
  License-File: LICENSE
13
13
  Keywords: api-client,httpx,hyperping,incident,monitoring,pydantic,python,sdk,status-page,uptime
14
14
  Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Environment :: Console
15
16
  Classifier: Intended Audience :: Developers
16
17
  Classifier: License :: OSI Approved :: MIT License
17
18
  Classifier: Operating System :: OS Independent
@@ -25,6 +26,8 @@ Classifier: Typing :: Typed
25
26
  Requires-Python: >=3.11
26
27
  Requires-Dist: httpx<1.0,>=0.27
27
28
  Requires-Dist: pydantic<3.0,>=2.0
29
+ Provides-Extra: cli
30
+ Requires-Dist: typer[all]<1.0,>=0.12; extra == 'cli'
28
31
  Provides-Extra: dev
29
32
  Requires-Dist: mypy>=1.10; extra == 'dev'
30
33
  Requires-Dist: pip-audit>=2.7; extra == 'dev'
@@ -33,6 +36,7 @@ Requires-Dist: pytest-cov; extra == 'dev'
33
36
  Requires-Dist: pytest>=9.0.3; extra == 'dev'
34
37
  Requires-Dist: respx>=0.21; extra == 'dev'
35
38
  Requires-Dist: ruff>=0.4; extra == 'dev'
39
+ Requires-Dist: typer[all]<1.0,>=0.12; extra == 'dev'
36
40
  Description-Content-Type: text/markdown
37
41
 
38
42
  # hyperping
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperping"
7
- version = "1.7.0"
7
+ version = "1.8.1"
8
8
  description = "Python SDK for the Hyperping uptime monitoring and incident management API"
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  license = {text = "MIT"}
@@ -23,6 +23,7 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  "Topic :: Internet :: WWW/HTTP",
25
25
  "Typing :: Typed",
26
+ "Environment :: Console",
26
27
  ]
27
28
  dependencies = [
28
29
  "httpx>=0.27,<1.0",
@@ -30,6 +31,7 @@ dependencies = [
30
31
  ]
31
32
 
32
33
  [project.optional-dependencies]
34
+ cli = ["typer[all]>=0.12,<1.0"]
33
35
  dev = [
34
36
  "pytest>=9.0.3",
35
37
  "pytest-cov",
@@ -38,8 +40,12 @@ dev = [
38
40
  "mypy>=1.10",
39
41
  "pydantic",
40
42
  "pip-audit>=2.7",
43
+ "typer[all]>=0.12,<1.0",
41
44
  ]
42
45
 
46
+ [project.scripts]
47
+ hyp = "hyperping.cli._app:app"
48
+
43
49
  [project.urls]
44
50
  Homepage = "https://github.com/develeap/hyperping-python"
45
51
  Documentation = "https://github.com/develeap/hyperping-python#readme"
@@ -16,6 +16,7 @@ Quick start::
16
16
 
17
17
  from hyperping._async_client import AsyncHyperpingClient
18
18
  from hyperping._async_mcp_client import AsyncHyperpingMcpClient
19
+ from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
19
20
  from hyperping._version import __version__
20
21
  from hyperping.client import (
21
22
  CircuitBreaker,
@@ -44,6 +45,7 @@ from hyperping.models import (
44
45
  AlertHistory,
45
46
  DnsRecordType,
46
47
  EscalationPolicy,
48
+ EscalationStep,
47
49
  Healthcheck,
48
50
  HealthcheckCreate,
49
51
  HealthcheckUpdate,
@@ -149,6 +151,7 @@ __all__ = [
149
151
  "IncidentStatus",
150
152
  "IncidentUpdateCreate",
151
153
  # Maintenance
154
+ "MAX_STATUSPAGES_PER_MAINTENANCE",
152
155
  "Maintenance",
153
156
  "MaintenanceCreate",
154
157
  "MaintenanceUpdate",
@@ -168,6 +171,7 @@ __all__ = [
168
171
  "ProbeLogResponse",
169
172
  # On-call
170
173
  "OnCallSchedule",
174
+ "EscalationStep",
171
175
  "EscalationPolicy",
172
176
  "TeamMember",
173
177
  # Integrations
@@ -15,6 +15,7 @@ import asyncio
15
15
  import logging
16
16
  import random
17
17
  import threading
18
+ from collections import OrderedDict
18
19
  from collections.abc import Callable
19
20
  from typing import Any
20
21
  from urllib.parse import urlsplit
@@ -33,8 +34,13 @@ from hyperping._circuit_breaker import (
33
34
  CircuitBreakerConfig,
34
35
  CircuitState,
35
36
  )
36
- from hyperping._internals import DEFAULT_USER_AGENT, RETRY_AFTER_MAX, sanitize_for_log
37
- from hyperping.client import DEFAULT_RETRY_CONFIG, RetryConfig
37
+ from hyperping._internals import (
38
+ DEFAULT_USER_AGENT,
39
+ RETRY_AFTER_MAX,
40
+ sanitize_for_log,
41
+ validate_base_url,
42
+ )
43
+ from hyperping.client import _ENDPOINT_BREAKERS_MAX, DEFAULT_RETRY_CONFIG, RetryConfig
38
44
  from hyperping.endpoints import API_BASE, Endpoint
39
45
  from hyperping.exceptions import (
40
46
  HyperpingAPIError,
@@ -79,6 +85,7 @@ class AsyncHyperpingClient(
79
85
  user_agent: str | None = None,
80
86
  per_endpoint_circuit_breaker: bool = False,
81
87
  breaker_key_fn: Callable[[str], str] | None = None,
88
+ allow_insecure: bool = False,
82
89
  ) -> None:
83
90
  """Initialize the async Hyperping API client.
84
91
 
@@ -106,14 +113,17 @@ class AsyncHyperpingClient(
106
113
  if not raw_key or not raw_key.strip():
107
114
  raise ValueError("api_key must be a non-empty string")
108
115
  self._api_key = SecretStr(raw_key) if isinstance(api_key, str) else api_key
109
- self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
116
+ self.base_url = validate_base_url(
117
+ base_url or self.DEFAULT_BASE_URL,
118
+ allow_insecure=allow_insecure,
119
+ )
110
120
  self.timeout = timeout
111
121
  self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
112
122
  self._circuit_breaker_config = circuit_breaker_config
113
123
  self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
114
124
  self._per_endpoint_circuit_breaker = per_endpoint_circuit_breaker
115
125
  self._breaker_key_fn = breaker_key_fn
116
- self._endpoint_breakers: dict[str, CircuitBreaker] = {}
126
+ self._endpoint_breakers: OrderedDict[str, CircuitBreaker] = OrderedDict()
117
127
  self._endpoint_breakers_lock = threading.Lock()
118
128
 
119
129
  self._client = httpx.AsyncClient(
@@ -167,17 +177,39 @@ class AsyncHyperpingClient(
167
177
  return pure
168
178
 
169
179
  def _breaker_for(self, path: str) -> CircuitBreaker:
170
- """Return the breaker that governs ``path`` (shared, or per-endpoint)."""
180
+ """Return the breaker that governs ``path`` (shared, or per-endpoint).
181
+
182
+ The critical section under ``_endpoint_breakers_lock`` is purely
183
+ CPU-bound (a single ``OrderedDict.get`` / ``__setitem__`` /
184
+ ``move_to_end`` / ``popitem``) and never awaits, so wrapping it in a
185
+ ``threading.Lock`` does not block the event loop in practice; the
186
+ loop only "stalls" for the duration of one dict operation, which is
187
+ well below the resolution of any asyncio scheduling decision.
188
+
189
+ We keep ``threading.Lock`` (rather than ``asyncio.Lock``) so the same
190
+ breaker map remains safe if a caller drives the async client from
191
+ multiple OS threads (e.g. via ``loop.run_in_executor`` or a thread
192
+ pool that re-enters the SDK). Switching to ``asyncio.Lock`` would
193
+ make the per-endpoint path correct only on the loop that owns the
194
+ lock; ``threading.Lock`` is correct in both cases. Regression
195
+ coverage:
196
+ ``tests/unit/test_security_breaker_cap.py
197
+ ::test_async_breaker_lock_does_not_deadlock_under_gather``.
198
+ """
171
199
  if not self._per_endpoint_circuit_breaker:
172
200
  return self._circuit_breaker
173
201
  key = self._resolve_breaker_key(path)
174
- # threading.Lock here is intentional: see HyperpingClient._breaker_for
175
- # for the rationale (works under both pure-asyncio and mixed-thread use).
176
202
  with self._endpoint_breakers_lock:
177
203
  breaker = self._endpoint_breakers.get(key)
178
204
  if breaker is None:
179
205
  breaker = CircuitBreaker(self._circuit_breaker_config)
180
206
  self._endpoint_breakers[key] = breaker
207
+ # Evict LRU once the cap is hit to bound memory under a
208
+ # pathological breaker_key_fn (see HyperpingClient).
209
+ while len(self._endpoint_breakers) > _ENDPOINT_BREAKERS_MAX:
210
+ self._endpoint_breakers.popitem(last=False)
211
+ else:
212
+ self._endpoint_breakers.move_to_end(key)
181
213
  return breaker
182
214
 
183
215
  def circuit_breaker_state_for(self, path: str) -> CircuitState:
@@ -220,7 +252,12 @@ class AsyncHyperpingClient(
220
252
  return {"error": response.text or "Unknown error"}
221
253
 
222
254
  def _parse_retry_after(self, response: httpx.Response) -> int | None:
223
- """Extract and parse the ``Retry-After`` header value."""
255
+ """Extract and parse the ``Retry-After`` header value.
256
+
257
+ Only the delta-seconds form (RFC 7231 7.1.3) is parsed; HTTP-date
258
+ is intentionally not supported (see
259
+ :meth:`HyperpingClient._parse_retry_after` for rationale).
260
+ """
224
261
  retry_after = response.headers.get("Retry-After")
225
262
  if not retry_after:
226
263
  return None
@@ -9,9 +9,11 @@ from __future__ import annotations
9
9
  import logging
10
10
  from datetime import UTC, datetime
11
11
 
12
+ from hyperping._maintenance_mixin import MAX_STATUSPAGES_PER_MAINTENANCE
12
13
  from hyperping._protocols import _AsyncClientProtocol
13
14
  from hyperping._utils import expect_dict, parse_list, unwrap_list, validate_id
14
15
  from hyperping.endpoints import Endpoint
16
+ from hyperping.exceptions import HyperpingValidationError
15
17
  from hyperping.models import (
16
18
  Maintenance,
17
19
  MaintenanceCreate,
@@ -82,6 +84,15 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
82
84
  v1 API returns {"uuid": "..."} on create, not the full maintenance object.
83
85
  The full maintenance window is fetched after creation.
84
86
  """
87
+ n_statuspages = len(maintenance.statuspages or [])
88
+ if n_statuspages > MAX_STATUSPAGES_PER_MAINTENANCE:
89
+ raise HyperpingValidationError(
90
+ f"A maintenance window can reference at most "
91
+ f"{MAX_STATUSPAGES_PER_MAINTENANCE} status pages, but {n_statuspages} "
92
+ f"were supplied. Above this limit Hyperping's API accepts the create "
93
+ f"(returns a uuid) but silently fails to persist the window. Split the "
94
+ f"status pages across multiple maintenance windows."
95
+ )
85
96
  payload = maintenance.model_dump(exclude_none=True, by_alias=True, mode="json")
86
97
  response = expect_dict(
87
98
  await self._request("POST", Endpoint.MAINTENANCE, json=payload),
@@ -91,6 +102,33 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
91
102
  return await self.get_maintenance(response["uuid"])
92
103
  return Maintenance.model_validate(response)
93
104
 
105
+ async def create_maintenance_windows(
106
+ self,
107
+ maintenance: MaintenanceCreate,
108
+ *,
109
+ chunk_size: int = MAX_STATUSPAGES_PER_MAINTENANCE,
110
+ ) -> list[Maintenance]:
111
+ """Create one or more windows, splitting status pages into chunks.
112
+
113
+ Async mirror of
114
+ :meth:`~hyperping._maintenance_mixin.MaintenanceMixin.create_maintenance_windows`.
115
+ """
116
+ if not 1 <= chunk_size <= MAX_STATUSPAGES_PER_MAINTENANCE:
117
+ raise HyperpingValidationError(
118
+ f"chunk_size must be between 1 and "
119
+ f"{MAX_STATUSPAGES_PER_MAINTENANCE}, got {chunk_size}."
120
+ )
121
+ pages = list(maintenance.statuspages or [])
122
+ if len(pages) <= chunk_size:
123
+ return [await self.create_maintenance(maintenance)]
124
+ windows: list[Maintenance] = []
125
+ for start in range(0, len(pages), chunk_size):
126
+ chunk = maintenance.model_copy(
127
+ update={"statuspages": pages[start : start + chunk_size]}
128
+ )
129
+ windows.append(await self.create_maintenance(chunk))
130
+ return windows
131
+
94
132
  async def update_maintenance(
95
133
  self,
96
134
  maintenance_id: str,
@@ -21,7 +21,7 @@ from pydantic import SecretStr
21
21
  from hyperping._async_mcp_transport import AsyncMcpTransport
22
22
  from hyperping.endpoints import MCP_URL
23
23
  from hyperping.models._integration_models import Integration
24
- from hyperping.models._monitor_models import Monitor
24
+ from hyperping.models._monitor_models import Monitor, MonitorCreate
25
25
  from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse
26
26
  from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember
27
27
  from hyperping.models._outage_models import OutageTimeline
@@ -50,11 +50,13 @@ class AsyncHyperpingMcpClient:
50
50
  api_key: str | SecretStr,
51
51
  base_url: str = MCP_URL,
52
52
  timeout: float = 30.0,
53
+ allow_insecure: bool = False,
53
54
  ) -> None:
54
55
  self._transport = AsyncMcpTransport(
55
56
  api_key=api_key,
56
57
  base_url=base_url,
57
58
  timeout=timeout,
59
+ allow_insecure=allow_insecure,
58
60
  )
59
61
 
60
62
  # ==================== Internal ====================
@@ -268,3 +270,52 @@ class AsyncHyperpingMcpClient:
268
270
  data = await self._call("search_monitors_by_name", {"query": query})
269
271
  raw = data if isinstance(data, list) else []
270
272
  return [Monitor.model_validate(m) for m in raw]
273
+
274
+ async def create_monitor(self, monitor: MonitorCreate) -> Monitor:
275
+ """Create a new monitor.
276
+
277
+ Args:
278
+ monitor: Monitor configuration.
279
+ """
280
+ return Monitor.model_validate(
281
+ await self._call("create_monitor", monitor.model_dump(exclude_none=True))
282
+ )
283
+
284
+ async def update_monitor(self, monitor_uuid: str, **kwargs: Any) -> Monitor:
285
+ """Update an existing monitor.
286
+
287
+ Args:
288
+ monitor_uuid: Monitor UUID.
289
+ **kwargs: Fields to update.
290
+ """
291
+ return Monitor.model_validate(
292
+ await self._call("update_monitor", {"uuid": monitor_uuid, **kwargs})
293
+ )
294
+
295
+ async def pause_monitor(self, monitor_uuid: str) -> Monitor:
296
+ """Pause a monitor.
297
+
298
+ Args:
299
+ monitor_uuid: Monitor UUID.
300
+ """
301
+ return Monitor.model_validate(
302
+ await self._call("pause_monitor", {"uuid": monitor_uuid})
303
+ )
304
+
305
+ async def resume_monitor(self, monitor_uuid: str) -> Monitor:
306
+ """Resume a paused monitor.
307
+
308
+ Args:
309
+ monitor_uuid: Monitor UUID.
310
+ """
311
+ return Monitor.model_validate(
312
+ await self._call("resume_monitor", {"uuid": monitor_uuid})
313
+ )
314
+
315
+ async def delete_monitor(self, monitor_uuid: str) -> None:
316
+ """Delete a monitor.
317
+
318
+ Args:
319
+ monitor_uuid: Monitor UUID.
320
+ """
321
+ await self._call("delete_monitor", {"uuid": monitor_uuid})
@@ -12,6 +12,7 @@ from typing import Any
12
12
  import httpx
13
13
  from pydantic import SecretStr
14
14
 
15
+ from hyperping._internals import validate_base_url
15
16
  from hyperping._version import __version__
16
17
  from hyperping.endpoints import MCP_URL
17
18
  from hyperping.exceptions import (
@@ -53,9 +54,14 @@ class AsyncMcpTransport:
53
54
  base_url: str = MCP_URL,
54
55
  timeout: float = 30.0,
55
56
  max_retries: int = 2,
57
+ allow_insecure: bool = False,
56
58
  ) -> None:
57
59
  token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
58
- self._url = base_url.rstrip("/")
60
+ self._url = validate_base_url(
61
+ base_url,
62
+ allow_insecure=allow_insecure,
63
+ param_name="base_url",
64
+ )
59
65
  self._client = httpx.AsyncClient(
60
66
  headers={
61
67
  "Authorization": f"Bearer {token}",
@@ -116,11 +122,13 @@ class AsyncMcpTransport:
116
122
  retry_after = int(raw_retry)
117
123
  except ValueError:
118
124
  pass
125
+ # Drop the raw body for the same reason as the sync transport:
126
+ # the structured exception still carries retry_after.
119
127
  raise HyperpingRateLimitError(
120
128
  "Rate limit exceeded",
121
129
  retry_after=retry_after,
122
130
  status_code=429,
123
- response_body={"raw": resp.text[:500]},
131
+ response_body=None,
124
132
  )
125
133
  if resp.status_code in (400, 422):
126
134
  raise HyperpingValidationError(
@@ -128,10 +136,14 @@ class AsyncMcpTransport:
128
136
  status_code=resp.status_code,
129
137
  )
130
138
  if resp.status_code != 200:
139
+ # Drop the raw body for the same reason as the 429 path: the server
140
+ # may echo subscriber emails, webhook URLs, or other PII in free-
141
+ # form error text that the structured key-based redactor cannot
142
+ # match. The status code in the exception is enough for callers.
131
143
  raise HyperpingAPIError(
132
144
  f"MCP server returned HTTP {resp.status_code}",
133
145
  status_code=resp.status_code,
134
- response_body={"raw": resp.text[:500]},
146
+ response_body=None,
135
147
  )
136
148
 
137
149
  # HTTP 200. Parse the body so we classify JSON-RPC errors (including
@@ -145,7 +157,7 @@ class AsyncMcpTransport:
145
157
  raise HyperpingAPIError(
146
158
  "MCP server returned 200 with non-JSON body",
147
159
  status_code=200,
148
- response_body={"raw": resp.text[:500]},
160
+ response_body=None,
149
161
  ) from None
150
162
 
151
163
  if isinstance(data, dict) and "error" in data:
@@ -292,10 +304,12 @@ class AsyncMcpTransport:
292
304
  try:
293
305
  return json.loads(text)
294
306
  except json.JSONDecodeError as exc:
307
+ # Server-controlled ``text`` may carry PII; drop it instead of
308
+ # embedding the first 500 bytes into the exception.
295
309
  raise HyperpingAPIError(
296
310
  f"Failed to parse MCP tool response: {exc}",
297
311
  status_code=200,
298
- response_body={"raw": text[:500]},
312
+ response_body=None,
299
313
  ) from exc
300
314
 
301
315
  async def close(self) -> None: