hyperping 1.6.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.6.0 → hyperping-1.8.0}/.gitignore +1 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/CHANGELOG.md +79 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/PKG-INFO +54 -1
- {hyperping-1.6.0 → hyperping-1.8.0}/README.md +53 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/SECURITY.md +3 -2
- {hyperping-1.6.0 → hyperping-1.8.0}/pyproject.toml +2 -2
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_client.py +45 -8
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_mcp_client.py +23 -0
- hyperping-1.8.0/src/hyperping/_async_mcp_transport.py +322 -0
- hyperping-1.8.0/src/hyperping/_internals.py +241 -0
- hyperping-1.8.0/src/hyperping/_mcp_transport.py +324 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_utils.py +17 -3
- hyperping-1.8.0/src/hyperping/_version.py +1 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/client.py +36 -5
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/exceptions.py +34 -8
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/mcp_client.py +25 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_async_mcp_client.py +46 -0
- hyperping-1.8.0/tests/unit/test_async_mcp_transport.py +806 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_mcp_client.py +77 -0
- hyperping-1.8.0/tests/unit/test_mcp_transport.py +964 -0
- 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.6.0/src/hyperping/_async_mcp_transport.py +0 -199
- hyperping-1.6.0/src/hyperping/_internals.py +0 -31
- hyperping-1.6.0/src/hyperping/_mcp_transport.py +0 -201
- hyperping-1.6.0/src/hyperping/_version.py +0 -1
- hyperping-1.6.0/tests/unit/test_async_mcp_transport.py +0 -302
- hyperping-1.6.0/tests/unit/test_mcp_transport.py +0 -333
- {hyperping-1.6.0 → hyperping-1.8.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/LICENSE +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_maintenance_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_integration_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_oncall_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_async_preexisting.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_outages.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_sdk_surface.py +0 -0
- {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_statuspages.py +0 -0
|
@@ -5,6 +5,85 @@ 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
|
+
|
|
50
|
+
## [1.7.0] - 2026-05-21
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- `ensure_initialized()` on `HyperpingMcpClient` and `AsyncHyperpingMcpClient` for
|
|
55
|
+
startup health checks. Performs the MCP handshake now if it hasn't happened yet
|
|
56
|
+
and raises `HyperpingRateLimitError` if the server's `initialize` cap is hit.
|
|
57
|
+
- New "MCP rate limits and connection lifecycle" section in README documenting
|
|
58
|
+
Hyperping's stateless MCP server, the undocumented `initialize` cap, and the
|
|
59
|
+
recommended client lifetime per process.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- MCP rate-limit errors that the server returns as HTTP 200 with JSON-RPC
|
|
64
|
+
`error.code = -32000` (notably the `initialize` per-minute cap) are now
|
|
65
|
+
classified as `HyperpingRateLimitError` with `retry_after` parsed from the
|
|
66
|
+
message, instead of a generic `HyperpingAPIError`. Existing HTTP 429 handling is
|
|
67
|
+
unchanged.
|
|
68
|
+
- After a rate-limit on `initialize`, the MCP transport latches a cool-off so
|
|
69
|
+
subsequent `call_tool` invocations short-circuit with `HyperpingRateLimitError`
|
|
70
|
+
until the advertised `retry_after` elapses, instead of issuing further HTTP
|
|
71
|
+
requests that would burn more slots from the bucket.
|
|
72
|
+
- TOCTOU race in lazy `initialize` where two concurrent first calls on the same
|
|
73
|
+
`HyperpingMcpClient` could each POST `initialize`. The handshake is now
|
|
74
|
+
performed under a dedicated lock with a double-checked flag, including a
|
|
75
|
+
lockless fast path so post-handshake `call_tool` does not contend on it.
|
|
76
|
+
- Cool-off short-circuit now preserves the originating status code (200 for
|
|
77
|
+
JSON-RPC `-32000`, 429 for HTTP 429) so callers can distinguish buckets, and
|
|
78
|
+
`retry_after` uses `math.ceil` to avoid over-reporting by one second.
|
|
79
|
+
- JSON-RPC rate-limit signals returned on the `notifications/initialized` leg
|
|
80
|
+
are now classified as `HyperpingRateLimitError` (previously they were
|
|
81
|
+
silently treated as a successful notification).
|
|
82
|
+
- Rate-limit detection requires the message to contain `"rate limit exceeded"`
|
|
83
|
+
(the observed phrasing) to avoid false positives on unrelated server messages
|
|
84
|
+
that happen to mention `"rate limit"`. The `Retry-After` parser now also
|
|
85
|
+
accepts `Retry-After:` and `retry after N seconds` variants.
|
|
86
|
+
|
|
8
87
|
## [1.6.0] - 2026-05-06
|
|
9
88
|
|
|
10
89
|
### 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
|
|
@@ -230,6 +230,59 @@ The MCP client uses the same API key as `HyperpingClient`. All methods return
|
|
|
230
230
|
plain dicts/lists; use the exported Pydantic models (e.g., `OnCallSchedule`,
|
|
231
231
|
`EscalationPolicy`) for validation if needed.
|
|
232
232
|
|
|
233
|
+
### MCP rate limits and connection lifecycle
|
|
234
|
+
|
|
235
|
+
The Hyperping MCP server (`https://api.hyperping.io/v1/mcp`) is
|
|
236
|
+
[documented by Hyperping as stateless over HTTP](https://hyperping.com/mcp)
|
|
237
|
+
and rate-limits per API key. The publicly documented limit is 300 requests per
|
|
238
|
+
minute shared with the REST API
|
|
239
|
+
([rate-limit docs](https://hyperping.com/docs/monitoring/api-rate-limits)), but
|
|
240
|
+
the server also enforces a separate, undocumented cap on the `initialize`
|
|
241
|
+
handshake (observed around 5/minute). Because every new `HyperpingMcpClient`
|
|
242
|
+
instance must perform the MCP `initialize` handshake on its first call,
|
|
243
|
+
instantiating the client in a hot path or running several short-lived processes
|
|
244
|
+
against one key will trip this cap.
|
|
245
|
+
|
|
246
|
+
Operational guidance:
|
|
247
|
+
|
|
248
|
+
- **Create one `HyperpingMcpClient` per process and reuse it.** Do not instantiate
|
|
249
|
+
it inside a loop. The first call performs the handshake; subsequent calls reuse
|
|
250
|
+
it for the life of the client.
|
|
251
|
+
- **Catch `HyperpingRateLimitError` and honour `retry_after`.** Rate-limit signals
|
|
252
|
+
arrive two ways: as HTTP 429 (with a standard `Retry-After` header) and as a
|
|
253
|
+
JSON-RPC server error (`code: -32000`, HTTP 200) on `initialize`. Both surface as
|
|
254
|
+
`HyperpingRateLimitError` with `retry_after` parsed from whichever signal was
|
|
255
|
+
used. The `status_code` attribute is `429` or `200`, matching the underlying
|
|
256
|
+
signal; cool-off short-circuits preserve the originating status code so callers
|
|
257
|
+
can disambiguate the two buckets.
|
|
258
|
+
- **Use `ensure_initialized()` for startup health checks.** Calling it once on
|
|
259
|
+
service boot lets you fail fast if the key is already at the `initialize` cap,
|
|
260
|
+
instead of failing on the first business call.
|
|
261
|
+
- **Several workloads on one key collide on the `initialize` cap.** A weekly cron,
|
|
262
|
+
a watchdog daemon, and a developer running the CLI cannot all warm up the same
|
|
263
|
+
API key inside one minute. Use one long-lived process per workload, or separate
|
|
264
|
+
API keys per workload if your plan allows.
|
|
265
|
+
- **After a rate-limit on `initialize`, the SDK latches a cool-off** so that
|
|
266
|
+
subsequent `call_tool` invocations on the same client fail fast with
|
|
267
|
+
`HyperpingRateLimitError` (no extra HTTP traffic) until `retry_after` elapses.
|
|
268
|
+
This prevents accidentally burning more slots from the bucket. The latch is
|
|
269
|
+
per-`HyperpingMcpClient` instance and per-process; it does not coordinate
|
|
270
|
+
across separate Python processes sharing the same API key, so multi-process
|
|
271
|
+
setups still need the workload-separation advice above.
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from hyperping import HyperpingMcpClient, HyperpingRateLimitError
|
|
275
|
+
|
|
276
|
+
mcp = HyperpingMcpClient(api_key="sk_...")
|
|
277
|
+
try:
|
|
278
|
+
mcp.ensure_initialized()
|
|
279
|
+
except HyperpingRateLimitError as e:
|
|
280
|
+
print(f"MCP cold-start rate-limited; retry in {e.retry_after}s")
|
|
281
|
+
raise
|
|
282
|
+
|
|
283
|
+
summary = mcp.get_status_summary()
|
|
284
|
+
```
|
|
285
|
+
|
|
233
286
|
### Healthchecks
|
|
234
287
|
|
|
235
288
|
```python
|
|
@@ -193,6 +193,59 @@ The MCP client uses the same API key as `HyperpingClient`. All methods return
|
|
|
193
193
|
plain dicts/lists; use the exported Pydantic models (e.g., `OnCallSchedule`,
|
|
194
194
|
`EscalationPolicy`) for validation if needed.
|
|
195
195
|
|
|
196
|
+
### MCP rate limits and connection lifecycle
|
|
197
|
+
|
|
198
|
+
The Hyperping MCP server (`https://api.hyperping.io/v1/mcp`) is
|
|
199
|
+
[documented by Hyperping as stateless over HTTP](https://hyperping.com/mcp)
|
|
200
|
+
and rate-limits per API key. The publicly documented limit is 300 requests per
|
|
201
|
+
minute shared with the REST API
|
|
202
|
+
([rate-limit docs](https://hyperping.com/docs/monitoring/api-rate-limits)), but
|
|
203
|
+
the server also enforces a separate, undocumented cap on the `initialize`
|
|
204
|
+
handshake (observed around 5/minute). Because every new `HyperpingMcpClient`
|
|
205
|
+
instance must perform the MCP `initialize` handshake on its first call,
|
|
206
|
+
instantiating the client in a hot path or running several short-lived processes
|
|
207
|
+
against one key will trip this cap.
|
|
208
|
+
|
|
209
|
+
Operational guidance:
|
|
210
|
+
|
|
211
|
+
- **Create one `HyperpingMcpClient` per process and reuse it.** Do not instantiate
|
|
212
|
+
it inside a loop. The first call performs the handshake; subsequent calls reuse
|
|
213
|
+
it for the life of the client.
|
|
214
|
+
- **Catch `HyperpingRateLimitError` and honour `retry_after`.** Rate-limit signals
|
|
215
|
+
arrive two ways: as HTTP 429 (with a standard `Retry-After` header) and as a
|
|
216
|
+
JSON-RPC server error (`code: -32000`, HTTP 200) on `initialize`. Both surface as
|
|
217
|
+
`HyperpingRateLimitError` with `retry_after` parsed from whichever signal was
|
|
218
|
+
used. The `status_code` attribute is `429` or `200`, matching the underlying
|
|
219
|
+
signal; cool-off short-circuits preserve the originating status code so callers
|
|
220
|
+
can disambiguate the two buckets.
|
|
221
|
+
- **Use `ensure_initialized()` for startup health checks.** Calling it once on
|
|
222
|
+
service boot lets you fail fast if the key is already at the `initialize` cap,
|
|
223
|
+
instead of failing on the first business call.
|
|
224
|
+
- **Several workloads on one key collide on the `initialize` cap.** A weekly cron,
|
|
225
|
+
a watchdog daemon, and a developer running the CLI cannot all warm up the same
|
|
226
|
+
API key inside one minute. Use one long-lived process per workload, or separate
|
|
227
|
+
API keys per workload if your plan allows.
|
|
228
|
+
- **After a rate-limit on `initialize`, the SDK latches a cool-off** so that
|
|
229
|
+
subsequent `call_tool` invocations on the same client fail fast with
|
|
230
|
+
`HyperpingRateLimitError` (no extra HTTP traffic) until `retry_after` elapses.
|
|
231
|
+
This prevents accidentally burning more slots from the bucket. The latch is
|
|
232
|
+
per-`HyperpingMcpClient` instance and per-process; it does not coordinate
|
|
233
|
+
across separate Python processes sharing the same API key, so multi-process
|
|
234
|
+
setups still need the workload-separation advice above.
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from hyperping import HyperpingMcpClient, HyperpingRateLimitError
|
|
238
|
+
|
|
239
|
+
mcp = HyperpingMcpClient(api_key="sk_...")
|
|
240
|
+
try:
|
|
241
|
+
mcp.ensure_initialized()
|
|
242
|
+
except HyperpingRateLimitError as e:
|
|
243
|
+
print(f"MCP cold-start rate-limited; retry in {e.retry_after}s")
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
summary = mcp.get_status_summary()
|
|
247
|
+
```
|
|
248
|
+
|
|
196
249
|
### Healthchecks
|
|
197
250
|
|
|
198
251
|
```python
|
|
@@ -6,8 +6,9 @@ We release patches for security vulnerabilities for the following versions:
|
|
|
6
6
|
|
|
7
7
|
| Version | Supported |
|
|
8
8
|
| ------- | ------------------ |
|
|
9
|
-
| 1.
|
|
10
|
-
|
|
|
9
|
+
| 1.7.x | :white_check_mark: |
|
|
10
|
+
| 1.6.x | :white_check_mark: |
|
|
11
|
+
| < 1.6 | :x: |
|
|
11
12
|
|
|
12
13
|
Older releases may receive a fix at maintainers' discretion when the issue is severe and an upgrade is not feasible. The latest 1.x release is always the recommended target.
|
|
13
14
|
|
|
@@ -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"}
|
|
@@ -51,7 +51,7 @@ Issues = "https://github.com/develeap/hyperping-python/issues"
|
|
|
51
51
|
packages = ["src/hyperping"]
|
|
52
52
|
|
|
53
53
|
[tool.hatch.build.targets.sdist]
|
|
54
|
-
exclude = [".claude/", ".github/", "dist/", "uv.lock", "BACKLOG.md"]
|
|
54
|
+
exclude = [".claude/", ".github/", ".worktrees/", "dist/", "docs/plans/", "uv.lock", "BACKLOG.md"]
|
|
55
55
|
|
|
56
56
|
[tool.pytest.ini_options]
|
|
57
57
|
testpaths = ["tests"]
|
|
@@ -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 ====================
|
|
@@ -63,6 +65,27 @@ class AsyncHyperpingMcpClient:
|
|
|
63
65
|
"""Call an MCP tool via the transport."""
|
|
64
66
|
return await self._transport.call_tool(tool, args or {})
|
|
65
67
|
|
|
68
|
+
async def ensure_initialized(self) -> None:
|
|
69
|
+
"""Perform the MCP handshake now if it hasn't happened yet.
|
|
70
|
+
|
|
71
|
+
Async counterpart to
|
|
72
|
+
:meth:`hyperping.mcp_client.HyperpingMcpClient.ensure_initialized`.
|
|
73
|
+
Idempotent.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
HyperpingRateLimitError: If the server rate-limits ``initialize``,
|
|
77
|
+
either via HTTP 429 or via the JSON-RPC ``-32000`` rate-limit
|
|
78
|
+
payload. Inspect ``.retry_after`` to back off.
|
|
79
|
+
HyperpingAuthError: If the API key is invalid (HTTP 401/403).
|
|
80
|
+
HyperpingNotFoundError: If the MCP endpoint URL is wrong
|
|
81
|
+
(HTTP 404).
|
|
82
|
+
HyperpingValidationError: If the server rejects the handshake
|
|
83
|
+
payload (HTTP 400/422; unusual on initialize).
|
|
84
|
+
HyperpingAPIError: Any other transport-level error (HTTP 5xx,
|
|
85
|
+
malformed body, etc.).
|
|
86
|
+
"""
|
|
87
|
+
await self._transport.initialize()
|
|
88
|
+
|
|
66
89
|
# ==================== Context Manager ====================
|
|
67
90
|
|
|
68
91
|
async def close(self) -> None:
|