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.
- {hyperping-1.7.0 → hyperping-1.8.1}/CHANGELOG.md +65 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/PKG-INFO +5 -1
- {hyperping-1.7.0 → hyperping-1.8.1}/pyproject.toml +7 -1
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/__init__.py +4 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_client.py +45 -8
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_maintenance_mixin.py +38 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_client.py +52 -1
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_mcp_transport.py +19 -5
- hyperping-1.8.1/src/hyperping/_internals.py +241 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_maintenance_mixin.py +63 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_mcp_transport.py +20 -5
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_utils.py +17 -3
- hyperping-1.8.1/src/hyperping/_version.py +1 -0
- hyperping-1.8.1/src/hyperping/cli/_app.py +52 -0
- hyperping-1.8.1/src/hyperping/cli/_config.py +24 -0
- hyperping-1.8.1/src/hyperping/cli/_incidents.py +87 -0
- hyperping-1.8.1/src/hyperping/cli/_monitors.py +90 -0
- hyperping-1.8.1/src/hyperping/cli/_output.py +58 -0
- hyperping-1.8.1/src/hyperping/cli/_statuspages.py +57 -0
- hyperping-1.8.1/src/hyperping/cli/_tenant.py +75 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/client.py +36 -5
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/exceptions.py +18 -5
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/mcp_client.py +52 -1
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/__init__.py +7 -1
- hyperping-1.8.1/src/hyperping/models/_integration_models.py +27 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_oncall_models.py +34 -3
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_statuspage_models.py +12 -1
- hyperping-1.8.1/tests/unit/__init__.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_client.py +151 -10
- hyperping-1.8.1/tests/unit/test_cli_config.py +28 -0
- hyperping-1.8.1/tests/unit/test_cli_incidents.py +121 -0
- hyperping-1.8.1/tests/unit/test_cli_monitors.py +95 -0
- hyperping-1.8.1/tests/unit/test_cli_statuspages.py +67 -0
- hyperping-1.8.1/tests/unit/test_cli_tenant.py +90 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_maintenance.py +74 -1
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_mcp_client.py +153 -29
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_mcp_transport.py +8 -3
- hyperping-1.8.1/tests/unit/test_security_base_url.py +163 -0
- hyperping-1.8.1/tests/unit/test_security_breaker_cap.py +116 -0
- hyperping-1.8.1/tests/unit/test_security_exception_redaction.py +343 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_statuspages.py +24 -0
- hyperping-1.7.0/src/hyperping/_internals.py +0 -31
- hyperping-1.7.0/src/hyperping/_version.py +0 -1
- hyperping-1.7.0/src/hyperping/models/_integration_models.py +0 -14
- {hyperping-1.7.0 → hyperping-1.8.1}/.gitignore +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/CONTRIBUTING.md +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/LICENSE +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/README.md +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/SECURITY.md +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.7.0/tests → hyperping-1.8.1/src/hyperping/cli}/__init__.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/src/hyperping/py.typed +0 -0
- {hyperping-1.7.0/tests/unit → hyperping-1.8.1/tests}/__init__.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/conftest.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_mcp_transport.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_async_preexisting.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_outages.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.1}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
- {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.
|
|
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
|
+
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
|
|
37
|
-
|
|
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 = (
|
|
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:
|
|
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 =
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
312
|
+
response_body=None,
|
|
299
313
|
) from exc
|
|
300
314
|
|
|
301
315
|
async def close(self) -> None:
|