hyperping 1.7.0__tar.gz → 1.8.0__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.0}/CHANGELOG.md +42 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/PKG-INFO +1 -1
- {hyperping-1.7.0 → hyperping-1.8.0}/pyproject.toml +1 -1
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_client.py +45 -8
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_mcp_client.py +2 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_mcp_transport.py +19 -5
- hyperping-1.8.0/src/hyperping/_internals.py +241 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_mcp_transport.py +20 -5
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_utils.py +17 -3
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/client.py +36 -5
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/exceptions.py +18 -5
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/mcp_client.py +2 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_mcp_client.py +9 -20
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_mcp_transport.py +8 -3
- hyperping-1.8.0/tests/unit/test_security_base_url.py +163 -0
- hyperping-1.8.0/tests/unit/test_security_breaker_cap.py +116 -0
- hyperping-1.8.0/tests/unit/test_security_exception_redaction.py +343 -0
- hyperping-1.7.0/src/hyperping/_internals.py +0 -31
- {hyperping-1.7.0 → hyperping-1.8.0}/.gitignore +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/LICENSE +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/README.md +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/SECURITY.md +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/__init__.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_maintenance_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/_version.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/__init__.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_integration_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_oncall_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/__init__.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_mcp_client.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_mcp_transport.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_async_preexisting.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_outages.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_sdk_surface.py +0 -0
- {hyperping-1.7.0 → hyperping-1.8.0}/tests/unit/test_statuspages.py +0 -0
|
@@ -5,6 +5,48 @@ 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.0] - 2026-05-31
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- `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.
|
|
17
|
+
- `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`.
|
|
18
|
+
- `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.
|
|
19
|
+
- 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.
|
|
20
|
+
- `_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.
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `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.
|
|
26
|
+
- `InsecureTransportWarning` warning class exported from `hyperping`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- `_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.
|
|
31
|
+
|
|
32
|
+
### Upgrade Notes
|
|
33
|
+
|
|
34
|
+
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:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from hyperping import HyperpingClient
|
|
38
|
+
|
|
39
|
+
client = HyperpingClient(
|
|
40
|
+
api_key="sk_test_xxx",
|
|
41
|
+
base_url="http://localhost:8000",
|
|
42
|
+
allow_insecure=True,
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Production callers using `https://api.hyperping.io` are unaffected.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
8
50
|
## [1.7.0] - 2026-05-21
|
|
9
51
|
|
|
10
52
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperping
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hyperping"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.8.0"
|
|
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"}
|
|
@@ -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
|
|
@@ -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 ====================
|
|
@@ -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:
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Shared internal constants and helpers used by both sync and async clients.
|
|
2
|
+
|
|
3
|
+
Not part of the public API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import warnings
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from hyperping._version import __version__
|
|
14
|
+
|
|
15
|
+
# Maximum time to honour a server-requested Retry-After value (5 minutes)
|
|
16
|
+
RETRY_AFTER_MAX = 300.0
|
|
17
|
+
|
|
18
|
+
# Default User-Agent header value
|
|
19
|
+
DEFAULT_USER_AGENT = f"hyperping-python/{__version__}"
|
|
20
|
+
|
|
21
|
+
# Known JSON body keys whose values must not appear in debug logs (M15)
|
|
22
|
+
_SENSITIVE_LOG_KEYS = frozenset(
|
|
23
|
+
{"authorization", "x-api-key", "api_key", "request_headers", "request_body"}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InsecureTransportWarning(UserWarning):
|
|
28
|
+
"""Warning emitted when the client is configured to use plaintext HTTP.
|
|
29
|
+
|
|
30
|
+
Plaintext transport ships the Bearer API key in clear text on every
|
|
31
|
+
request. Allowed only as an explicit opt-in (``allow_insecure=True``)
|
|
32
|
+
for local development and integration testing.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_base_url(
|
|
37
|
+
url: str,
|
|
38
|
+
*,
|
|
39
|
+
allow_insecure: bool = False,
|
|
40
|
+
param_name: str = "base_url",
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Validate that *url* is a safe, well-formed API base URL.
|
|
43
|
+
|
|
44
|
+
Rejects:
|
|
45
|
+
- non-string / empty input
|
|
46
|
+
- URLs that don't parse to ``scheme://host`` form
|
|
47
|
+
- URLs with userinfo (``user:pass@host``, ``user@host``, ``@host``, etc.);
|
|
48
|
+
credentials in URLs are a common exfiltration vector and are never
|
|
49
|
+
legitimate for this SDK. Even an empty userinfo segment is rejected
|
|
50
|
+
because ``urlsplit`` reports it as ``username == ""`` (falsy), which
|
|
51
|
+
would slip past a simple truthiness check.
|
|
52
|
+
- URLs carrying a query string or fragment; a base URL must be limited
|
|
53
|
+
to ``scheme://host[/path]`` so attacker-controlled ``?api_key=...``
|
|
54
|
+
values cannot be smuggled into every subsequent request.
|
|
55
|
+
- non-``https`` schemes, unless *allow_insecure* is ``True``
|
|
56
|
+
|
|
57
|
+
When *allow_insecure* permits an ``http://`` URL, an
|
|
58
|
+
:class:`InsecureTransportWarning` is emitted so the operator sees the
|
|
59
|
+
downgrade in their logs.
|
|
60
|
+
|
|
61
|
+
Returns the URL with any trailing slash stripped. Raises ``ValueError``
|
|
62
|
+
on any rejection.
|
|
63
|
+
"""
|
|
64
|
+
if not isinstance(url, str) or not url.strip():
|
|
65
|
+
raise ValueError(f"{param_name} must be a non-empty string")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
parts = urlsplit(url.strip())
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ValueError(f"{param_name} is not a parseable URL: {url!r}") from exc
|
|
71
|
+
|
|
72
|
+
if parts.scheme not in ("http", "https"):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"{param_name} must use the https scheme (got {parts.scheme!r} in {url!r})"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ``urlsplit`` accepts strings without ``//`` (e.g. ``not a url``) and
|
|
78
|
+
# produces an empty netloc; reject any URL without a hostname.
|
|
79
|
+
if not parts.hostname:
|
|
80
|
+
raise ValueError(f"{param_name} must include a host (got {url!r})")
|
|
81
|
+
|
|
82
|
+
# Reject any userinfo, including the empty / partial forms
|
|
83
|
+
# (``https://@host``, ``https://:@host``). ``parts.username`` is an empty
|
|
84
|
+
# string in those cases, so the previous ``or`` truthiness guard let them
|
|
85
|
+
# through. Checking the raw authority for ``@`` is exhaustive.
|
|
86
|
+
if (
|
|
87
|
+
"@" in parts.netloc
|
|
88
|
+
or parts.username is not None
|
|
89
|
+
or parts.password is not None
|
|
90
|
+
):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{param_name} must not embed userinfo (credentials) in the URL"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if parts.query or parts.fragment:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"{param_name} must not carry a query string or fragment "
|
|
98
|
+
f"(got {url!r})"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if parts.scheme == "http":
|
|
102
|
+
if not allow_insecure:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"{param_name} uses the insecure http scheme; pass allow_insecure=True "
|
|
105
|
+
f"to opt in to plaintext transport (development only)"
|
|
106
|
+
)
|
|
107
|
+
warnings.warn(
|
|
108
|
+
f"{param_name}={url!r} uses plaintext http; the Bearer API key "
|
|
109
|
+
"will be transmitted in clear text",
|
|
110
|
+
InsecureTransportWarning,
|
|
111
|
+
stacklevel=3,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return url.rstrip("/")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sanitize_for_log(data: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
118
|
+
"""Return a copy of *data* with sensitive values replaced by ``[REDACTED]``.
|
|
119
|
+
|
|
120
|
+
Prevents tokens and header values from leaking into DEBUG-level log output.
|
|
121
|
+
"""
|
|
122
|
+
if data is None:
|
|
123
|
+
return None
|
|
124
|
+
return {k: "[REDACTED]" if k.lower() in _SENSITIVE_LOG_KEYS else v for k, v in data.items()}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Keys whose values are redacted recursively from any structured payload that
|
|
128
|
+
# may end up attached to an exception. Over-redact when uncertain: it is
|
|
129
|
+
# always safer to drop a string from a server response than to ship a secret
|
|
130
|
+
# into a logging pipeline.
|
|
131
|
+
_SENSITIVE_RESPONSE_KEYS = frozenset(
|
|
132
|
+
{
|
|
133
|
+
"authorization",
|
|
134
|
+
"x-api-key",
|
|
135
|
+
"api_key",
|
|
136
|
+
"apikey",
|
|
137
|
+
"token",
|
|
138
|
+
"access_token",
|
|
139
|
+
"refresh_token",
|
|
140
|
+
"secret",
|
|
141
|
+
"password",
|
|
142
|
+
"cookie",
|
|
143
|
+
"set-cookie",
|
|
144
|
+
"session",
|
|
145
|
+
"subscriber_email",
|
|
146
|
+
"subscribers",
|
|
147
|
+
"email",
|
|
148
|
+
"webhook",
|
|
149
|
+
"webhook_url",
|
|
150
|
+
"webhookurl",
|
|
151
|
+
"request_headers",
|
|
152
|
+
"request_body",
|
|
153
|
+
"headers",
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Maximum length of an embedded error message in formatted exception output.
|
|
158
|
+
_ERROR_MESSAGE_MAX_LEN = 256
|
|
159
|
+
# Control-byte stripper: drop C0 controls except TAB and LF. CR is dropped to
|
|
160
|
+
# avoid log-injection line splicing.
|
|
161
|
+
_CONTROL_BYTES_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
|
162
|
+
|
|
163
|
+
# Token-shaped substrings to scrub from server-supplied string values. Captures
|
|
164
|
+
# Bearer-style ("Bearer sk_xxx" / "Bearer eyJ...") and bare sk_-prefixed keys
|
|
165
|
+
# (the documented Hyperping API key shape).
|
|
166
|
+
_TOKEN_VALUE_RES: tuple[re.Pattern[str], ...] = (
|
|
167
|
+
re.compile(r"(?i)bearer\s+\S+"),
|
|
168
|
+
re.compile(r"\bsk_[A-Za-z0-9_\-]{4,}"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _scrub_token_strings(value: str) -> str:
|
|
173
|
+
"""Replace Bearer / sk_-prefixed tokens inside a free-form string."""
|
|
174
|
+
for pattern in _TOKEN_VALUE_RES:
|
|
175
|
+
value = pattern.sub("[REDACTED]", value)
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Maximum nesting depth the redactor will descend into before substituting a
|
|
180
|
+
# truncation sentinel. A malicious / malformed server could otherwise return
|
|
181
|
+
# a deeply nested JSON payload that blows the interpreter recursion limit
|
|
182
|
+
# inside ``HyperpingAPIError.__init__`` (masking the original HTTP failure).
|
|
183
|
+
# 32 is comfortably deeper than any real-world API response.
|
|
184
|
+
_REDACT_MAX_DEPTH = 32
|
|
185
|
+
|
|
186
|
+
# Marker substituted in place of a subtree that exceeds the depth cap. Stable
|
|
187
|
+
# shape so callers / tests can detect the truncation.
|
|
188
|
+
_REDACT_TRUNCATED_SENTINEL = {"_truncated": "max depth reached"}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def redact_response_body(value: Any, _depth: int = 0) -> Any:
|
|
192
|
+
"""Recursively redact sensitive keys from a parsed JSON response body.
|
|
193
|
+
|
|
194
|
+
Mirrors the key list used by :func:`sanitize_for_log` but applied at any
|
|
195
|
+
depth so server payloads that echo request headers or carry subscriber
|
|
196
|
+
PII can be attached to exceptions without leaking secrets through
|
|
197
|
+
``logging.exception`` / traceback printing.
|
|
198
|
+
|
|
199
|
+
Lists and tuples are walked element-wise; primitive values are returned
|
|
200
|
+
unchanged. The structure is copied; the input is not mutated.
|
|
201
|
+
|
|
202
|
+
Recursion is capped at :data:`_REDACT_MAX_DEPTH`; deeper subtrees are
|
|
203
|
+
replaced with :data:`_REDACT_TRUNCATED_SENTINEL` so a pathological 5000-
|
|
204
|
+
level payload cannot raise ``RecursionError`` inside the exception
|
|
205
|
+
constructor.
|
|
206
|
+
"""
|
|
207
|
+
if _depth >= _REDACT_MAX_DEPTH:
|
|
208
|
+
# Return a fresh copy so callers cannot mutate the shared sentinel.
|
|
209
|
+
return dict(_REDACT_TRUNCATED_SENTINEL)
|
|
210
|
+
if isinstance(value, dict):
|
|
211
|
+
redacted: dict[Any, Any] = {}
|
|
212
|
+
for k, v in value.items():
|
|
213
|
+
key_str = str(k).lower() if isinstance(k, str) else k
|
|
214
|
+
if isinstance(key_str, str) and key_str in _SENSITIVE_RESPONSE_KEYS:
|
|
215
|
+
redacted[k] = "[REDACTED]"
|
|
216
|
+
else:
|
|
217
|
+
redacted[k] = redact_response_body(v, _depth + 1)
|
|
218
|
+
return redacted
|
|
219
|
+
if isinstance(value, list):
|
|
220
|
+
return [redact_response_body(v, _depth + 1) for v in value]
|
|
221
|
+
if isinstance(value, tuple):
|
|
222
|
+
return tuple(redact_response_body(v, _depth + 1) for v in value)
|
|
223
|
+
if isinstance(value, str):
|
|
224
|
+
return _scrub_token_strings(value)
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def sanitize_error_message(message: str) -> str:
|
|
229
|
+
"""Strip control bytes and clamp length on an exception message.
|
|
230
|
+
|
|
231
|
+
Server-supplied error strings can carry ANSI escapes (terminal-injection)
|
|
232
|
+
or arbitrarily long payloads; both are unsafe to forward into log lines
|
|
233
|
+
or terminals verbatim. TAB and LF are preserved so multi-line validation
|
|
234
|
+
errors keep their shape.
|
|
235
|
+
"""
|
|
236
|
+
if not isinstance(message, str):
|
|
237
|
+
message = str(message)
|
|
238
|
+
cleaned = _CONTROL_BYTES_RE.sub("", message)
|
|
239
|
+
if len(cleaned) > _ERROR_MESSAGE_MAX_LEN:
|
|
240
|
+
cleaned = cleaned[: _ERROR_MESSAGE_MAX_LEN - 3] + "..."
|
|
241
|
+
return cleaned
|
|
@@ -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 McpTransport:
|
|
|
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.Client(
|
|
60
66
|
headers={
|
|
61
67
|
"Authorization": f"Bearer {token}",
|
|
@@ -116,11 +122,14 @@ class McpTransport:
|
|
|
116
122
|
retry_after = int(raw)
|
|
117
123
|
except ValueError:
|
|
118
124
|
pass
|
|
125
|
+
# Drop the raw body: the server may echo request fields here, and
|
|
126
|
+
# the structured exception already conveys "Rate limit exceeded"
|
|
127
|
+
# plus retry_after for the caller's back-off logic.
|
|
119
128
|
raise HyperpingRateLimitError(
|
|
120
129
|
"Rate limit exceeded",
|
|
121
130
|
retry_after=retry_after,
|
|
122
131
|
status_code=429,
|
|
123
|
-
response_body=
|
|
132
|
+
response_body=None,
|
|
124
133
|
)
|
|
125
134
|
if resp.status_code in (400, 422):
|
|
126
135
|
raise HyperpingValidationError(
|
|
@@ -128,10 +137,14 @@ class McpTransport:
|
|
|
128
137
|
status_code=resp.status_code,
|
|
129
138
|
)
|
|
130
139
|
if resp.status_code != 200:
|
|
140
|
+
# Drop the raw body for the same reason as the 429 path: the server
|
|
141
|
+
# may echo subscriber emails, webhook URLs, or other PII in free-
|
|
142
|
+
# form error text that the structured key-based redactor cannot
|
|
143
|
+
# match. The status code in the exception is enough for callers.
|
|
131
144
|
raise HyperpingAPIError(
|
|
132
145
|
f"MCP server returned HTTP {resp.status_code}",
|
|
133
146
|
status_code=resp.status_code,
|
|
134
|
-
response_body=
|
|
147
|
+
response_body=None,
|
|
135
148
|
)
|
|
136
149
|
|
|
137
150
|
# HTTP 200. Parse the body so we classify JSON-RPC errors (including
|
|
@@ -145,7 +158,7 @@ class McpTransport:
|
|
|
145
158
|
raise HyperpingAPIError(
|
|
146
159
|
"MCP server returned 200 with non-JSON body",
|
|
147
160
|
status_code=200,
|
|
148
|
-
response_body=
|
|
161
|
+
response_body=None,
|
|
149
162
|
) from None
|
|
150
163
|
|
|
151
164
|
if isinstance(data, dict) and "error" in data:
|
|
@@ -293,10 +306,12 @@ class McpTransport:
|
|
|
293
306
|
try:
|
|
294
307
|
return json.loads(text)
|
|
295
308
|
except json.JSONDecodeError as exc:
|
|
309
|
+
# Server-controlled ``text`` may carry PII; drop it instead of
|
|
310
|
+
# embedding the first 500 bytes into the exception.
|
|
296
311
|
raise HyperpingAPIError(
|
|
297
312
|
f"Failed to parse MCP tool response: {exc}",
|
|
298
313
|
status_code=200,
|
|
299
|
-
response_body=
|
|
314
|
+
response_body=None,
|
|
300
315
|
) from exc
|
|
301
316
|
|
|
302
317
|
def close(self) -> None:
|
|
@@ -115,9 +115,23 @@ def parse_list(
|
|
|
115
115
|
results.append(model_cls.model_validate(item)) # type: ignore[attr-defined]
|
|
116
116
|
except (ValueError, ValidationError) as exc:
|
|
117
117
|
skipped += 1
|
|
118
|
-
# Log
|
|
119
|
-
#
|
|
120
|
-
|
|
118
|
+
# Log a structural summary only. Pydantic's full ValidationError
|
|
119
|
+
# string includes the offending input value, which can echo
|
|
120
|
+
# sensitive data (subscriber emails, custom auth headers, etc.).
|
|
121
|
+
if isinstance(exc, ValidationError):
|
|
122
|
+
locations = [".".join(str(p) for p in err.get("loc", ())) for err in exc.errors()]
|
|
123
|
+
logger.warning(
|
|
124
|
+
"Failed to parse %s data: %s at %s",
|
|
125
|
+
label,
|
|
126
|
+
type(exc).__name__,
|
|
127
|
+
locations,
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
logger.warning(
|
|
131
|
+
"Failed to parse %s data: %s",
|
|
132
|
+
label,
|
|
133
|
+
type(exc).__name__,
|
|
134
|
+
)
|
|
121
135
|
|
|
122
136
|
if skipped:
|
|
123
137
|
logger.warning(
|