hyperping 1.6.0__tar.gz → 1.7.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.7.0}/.gitignore +1 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/CHANGELOG.md +37 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/PKG-INFO +54 -1
- {hyperping-1.6.0 → hyperping-1.7.0}/README.md +53 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/SECURITY.md +3 -2
- {hyperping-1.6.0 → hyperping-1.7.0}/pyproject.toml +2 -2
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_mcp_client.py +21 -0
- hyperping-1.7.0/src/hyperping/_async_mcp_transport.py +308 -0
- hyperping-1.7.0/src/hyperping/_mcp_transport.py +309 -0
- hyperping-1.7.0/src/hyperping/_version.py +1 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/exceptions.py +16 -3
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/mcp_client.py +23 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_async_mcp_client.py +46 -0
- hyperping-1.7.0/tests/unit/test_async_mcp_transport.py +806 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_mcp_client.py +88 -0
- hyperping-1.7.0/tests/unit/test_mcp_transport.py +959 -0
- hyperping-1.6.0/src/hyperping/_async_mcp_transport.py +0 -199
- 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.7.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/LICENSE +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_client.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_internals.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_maintenance_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_utils.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/client.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_integration_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_oncall_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_async_preexisting.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_outages.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_sdk_surface.py +0 -0
- {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_statuspages.py +0 -0
|
@@ -5,6 +5,43 @@ 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
|
+
## [1.7.0] - 2026-05-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `ensure_initialized()` on `HyperpingMcpClient` and `AsyncHyperpingMcpClient` for
|
|
13
|
+
startup health checks. Performs the MCP handshake now if it hasn't happened yet
|
|
14
|
+
and raises `HyperpingRateLimitError` if the server's `initialize` cap is hit.
|
|
15
|
+
- New "MCP rate limits and connection lifecycle" section in README documenting
|
|
16
|
+
Hyperping's stateless MCP server, the undocumented `initialize` cap, and the
|
|
17
|
+
recommended client lifetime per process.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- MCP rate-limit errors that the server returns as HTTP 200 with JSON-RPC
|
|
22
|
+
`error.code = -32000` (notably the `initialize` per-minute cap) are now
|
|
23
|
+
classified as `HyperpingRateLimitError` with `retry_after` parsed from the
|
|
24
|
+
message, instead of a generic `HyperpingAPIError`. Existing HTTP 429 handling is
|
|
25
|
+
unchanged.
|
|
26
|
+
- After a rate-limit on `initialize`, the MCP transport latches a cool-off so
|
|
27
|
+
subsequent `call_tool` invocations short-circuit with `HyperpingRateLimitError`
|
|
28
|
+
until the advertised `retry_after` elapses, instead of issuing further HTTP
|
|
29
|
+
requests that would burn more slots from the bucket.
|
|
30
|
+
- TOCTOU race in lazy `initialize` where two concurrent first calls on the same
|
|
31
|
+
`HyperpingMcpClient` could each POST `initialize`. The handshake is now
|
|
32
|
+
performed under a dedicated lock with a double-checked flag, including a
|
|
33
|
+
lockless fast path so post-handshake `call_tool` does not contend on it.
|
|
34
|
+
- Cool-off short-circuit now preserves the originating status code (200 for
|
|
35
|
+
JSON-RPC `-32000`, 429 for HTTP 429) so callers can distinguish buckets, and
|
|
36
|
+
`retry_after` uses `math.ceil` to avoid over-reporting by one second.
|
|
37
|
+
- JSON-RPC rate-limit signals returned on the `notifications/initialized` leg
|
|
38
|
+
are now classified as `HyperpingRateLimitError` (previously they were
|
|
39
|
+
silently treated as a successful notification).
|
|
40
|
+
- Rate-limit detection requires the message to contain `"rate limit exceeded"`
|
|
41
|
+
(the observed phrasing) to avoid false positives on unrelated server messages
|
|
42
|
+
that happen to mention `"rate limit"`. The `Retry-After` parser now also
|
|
43
|
+
accepts `Retry-After:` and `retry after N seconds` variants.
|
|
44
|
+
|
|
8
45
|
## [1.6.0] - 2026-05-06
|
|
9
46
|
|
|
10
47
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperping
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.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.7.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"]
|
|
@@ -63,6 +63,27 @@ class AsyncHyperpingMcpClient:
|
|
|
63
63
|
"""Call an MCP tool via the transport."""
|
|
64
64
|
return await self._transport.call_tool(tool, args or {})
|
|
65
65
|
|
|
66
|
+
async def ensure_initialized(self) -> None:
|
|
67
|
+
"""Perform the MCP handshake now if it hasn't happened yet.
|
|
68
|
+
|
|
69
|
+
Async counterpart to
|
|
70
|
+
:meth:`hyperping.mcp_client.HyperpingMcpClient.ensure_initialized`.
|
|
71
|
+
Idempotent.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
HyperpingRateLimitError: If the server rate-limits ``initialize``,
|
|
75
|
+
either via HTTP 429 or via the JSON-RPC ``-32000`` rate-limit
|
|
76
|
+
payload. Inspect ``.retry_after`` to back off.
|
|
77
|
+
HyperpingAuthError: If the API key is invalid (HTTP 401/403).
|
|
78
|
+
HyperpingNotFoundError: If the MCP endpoint URL is wrong
|
|
79
|
+
(HTTP 404).
|
|
80
|
+
HyperpingValidationError: If the server rejects the handshake
|
|
81
|
+
payload (HTTP 400/422; unusual on initialize).
|
|
82
|
+
HyperpingAPIError: Any other transport-level error (HTTP 5xx,
|
|
83
|
+
malformed body, etc.).
|
|
84
|
+
"""
|
|
85
|
+
await self._transport.initialize()
|
|
86
|
+
|
|
66
87
|
# ==================== Context Manager ====================
|
|
67
88
|
|
|
68
89
|
async def close(self) -> None:
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Async JSON-RPC 2.0 transport for the Hyperping MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import SecretStr
|
|
14
|
+
|
|
15
|
+
from hyperping._version import __version__
|
|
16
|
+
from hyperping.endpoints import MCP_URL
|
|
17
|
+
from hyperping.exceptions import (
|
|
18
|
+
HyperpingAPIError,
|
|
19
|
+
HyperpingAuthError,
|
|
20
|
+
HyperpingNotFoundError,
|
|
21
|
+
HyperpingRateLimitError,
|
|
22
|
+
HyperpingValidationError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_PROTOCOL_VERSION = "2025-03-26"
|
|
26
|
+
|
|
27
|
+
# Tight marker: the server's observed phrasing is "rate limit exceeded ...".
|
|
28
|
+
# Bare "rate limit" would risk classifying messages like "rate limit
|
|
29
|
+
# configuration invalid" as a rate-limit error.
|
|
30
|
+
_MCP_RATE_LIMIT_MARKER = "rate limit exceeded"
|
|
31
|
+
# Accept "Retry after Ns", "Retry-After: Ns", "retry after 30 seconds", etc.
|
|
32
|
+
# Captures only the integer; sub-second values are floored.
|
|
33
|
+
_MCP_RATE_LIMIT_RETRY_AFTER_RE = re.compile(
|
|
34
|
+
r"retry[\s\-]after[:\s]+(\d+)",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
# Default cool-off when the server fails to advertise one.
|
|
38
|
+
_COOLOFF_DEFAULT_SECONDS = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AsyncMcpTransport:
|
|
42
|
+
"""Async low-level JSON-RPC 2.0 client for the Hyperping MCP server.
|
|
43
|
+
|
|
44
|
+
The MCP server exposes tools not available via the REST API: on-call
|
|
45
|
+
schedules, anomalies, alerts, integrations, probe logs, and more.
|
|
46
|
+
|
|
47
|
+
Uses the same Bearer token API key as the REST client.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
api_key: str | SecretStr,
|
|
53
|
+
base_url: str = MCP_URL,
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
max_retries: int = 2,
|
|
56
|
+
) -> None:
|
|
57
|
+
token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
|
|
58
|
+
self._url = base_url.rstrip("/")
|
|
59
|
+
self._client = httpx.AsyncClient(
|
|
60
|
+
headers={
|
|
61
|
+
"Authorization": f"Bearer {token}",
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"Accept": "application/json, text/event-stream",
|
|
64
|
+
},
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
self._initialized = False
|
|
68
|
+
self._request_id = 0
|
|
69
|
+
self._lock = asyncio.Lock()
|
|
70
|
+
# Separate lock for the handshake so request-id increment and the
|
|
71
|
+
# initialize() critical section don't contend.
|
|
72
|
+
self._init_lock = asyncio.Lock()
|
|
73
|
+
# Monotonic deadline (process-local). 0.0 means no latch.
|
|
74
|
+
self._init_blocked_until: float = 0.0
|
|
75
|
+
# Status code of the original rate-limit response that armed the
|
|
76
|
+
# latch, propagated through short-circuit raises so callers can tell
|
|
77
|
+
# whether they hit HTTP 429 or HTTP 200 + JSON-RPC -32000.
|
|
78
|
+
self._init_blocked_status_code: int = 200
|
|
79
|
+
self._init_result: dict[str, Any] = {}
|
|
80
|
+
self._max_retries = max_retries
|
|
81
|
+
|
|
82
|
+
async def _next_id(self) -> int:
|
|
83
|
+
async with self._lock:
|
|
84
|
+
self._request_id += 1
|
|
85
|
+
return self._request_id
|
|
86
|
+
|
|
87
|
+
async def _send_rpc(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
params: dict[str, Any] | None = None,
|
|
91
|
+
*,
|
|
92
|
+
is_notification: bool = False,
|
|
93
|
+
) -> dict[str, Any] | None:
|
|
94
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
95
|
+
if params is not None:
|
|
96
|
+
payload["params"] = params
|
|
97
|
+
if not is_notification:
|
|
98
|
+
payload["id"] = await self._next_id()
|
|
99
|
+
|
|
100
|
+
resp = await self._client.post(self._url, content=json.dumps(payload))
|
|
101
|
+
|
|
102
|
+
if resp.status_code in (401, 403):
|
|
103
|
+
raise HyperpingAuthError("Invalid or expired API key")
|
|
104
|
+
if resp.status_code == 202:
|
|
105
|
+
return None # Notification accepted
|
|
106
|
+
if resp.status_code == 404:
|
|
107
|
+
raise HyperpingNotFoundError(
|
|
108
|
+
"Resource not found",
|
|
109
|
+
status_code=404,
|
|
110
|
+
)
|
|
111
|
+
if resp.status_code == 429:
|
|
112
|
+
retry_after = None
|
|
113
|
+
raw_retry = resp.headers.get("retry-after")
|
|
114
|
+
if raw_retry:
|
|
115
|
+
try:
|
|
116
|
+
retry_after = int(raw_retry)
|
|
117
|
+
except ValueError:
|
|
118
|
+
pass
|
|
119
|
+
raise HyperpingRateLimitError(
|
|
120
|
+
"Rate limit exceeded",
|
|
121
|
+
retry_after=retry_after,
|
|
122
|
+
status_code=429,
|
|
123
|
+
response_body={"raw": resp.text[:500]},
|
|
124
|
+
)
|
|
125
|
+
if resp.status_code in (400, 422):
|
|
126
|
+
raise HyperpingValidationError(
|
|
127
|
+
f"Validation error: HTTP {resp.status_code}",
|
|
128
|
+
status_code=resp.status_code,
|
|
129
|
+
)
|
|
130
|
+
if resp.status_code != 200:
|
|
131
|
+
raise HyperpingAPIError(
|
|
132
|
+
f"MCP server returned HTTP {resp.status_code}",
|
|
133
|
+
status_code=resp.status_code,
|
|
134
|
+
response_body={"raw": resp.text[:500]},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# HTTP 200. Parse the body so we classify JSON-RPC errors (including
|
|
138
|
+
# rate-limit signals) on notification responses too -- the server can
|
|
139
|
+
# return 200 + JSON-RPC error on a "notifications/initialized" leg.
|
|
140
|
+
try:
|
|
141
|
+
data = resp.json()
|
|
142
|
+
except (json.JSONDecodeError, ValueError):
|
|
143
|
+
if is_notification:
|
|
144
|
+
return None
|
|
145
|
+
raise HyperpingAPIError(
|
|
146
|
+
"MCP server returned 200 with non-JSON body",
|
|
147
|
+
status_code=200,
|
|
148
|
+
response_body={"raw": resp.text[:500]},
|
|
149
|
+
) from None
|
|
150
|
+
|
|
151
|
+
if isinstance(data, dict) and "error" in data:
|
|
152
|
+
self._raise_for_jsonrpc_error(data["error"], resp.status_code)
|
|
153
|
+
|
|
154
|
+
if is_notification:
|
|
155
|
+
return None
|
|
156
|
+
return data # type: ignore[no-any-return]
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _raise_for_jsonrpc_error(err: Any, status_code: int) -> None:
|
|
160
|
+
"""Map a JSON-RPC ``error`` payload to a typed exception and raise it."""
|
|
161
|
+
if (
|
|
162
|
+
isinstance(err, dict)
|
|
163
|
+
and err.get("code") == -32000
|
|
164
|
+
and isinstance(err.get("message"), str)
|
|
165
|
+
and _MCP_RATE_LIMIT_MARKER in err["message"].lower()
|
|
166
|
+
):
|
|
167
|
+
rl_retry_after: int | None = None
|
|
168
|
+
match = _MCP_RATE_LIMIT_RETRY_AFTER_RE.search(err["message"])
|
|
169
|
+
if match:
|
|
170
|
+
rl_retry_after = int(match.group(1))
|
|
171
|
+
raise HyperpingRateLimitError(
|
|
172
|
+
err["message"],
|
|
173
|
+
retry_after=rl_retry_after,
|
|
174
|
+
status_code=status_code,
|
|
175
|
+
response_body=err if isinstance(err, dict) else None,
|
|
176
|
+
)
|
|
177
|
+
code = err.get("code", "?") if isinstance(err, dict) else "?"
|
|
178
|
+
message = err.get("message", "unknown") if isinstance(err, dict) else str(err)
|
|
179
|
+
raise HyperpingAPIError(
|
|
180
|
+
f"MCP error {code}: {message}",
|
|
181
|
+
status_code=status_code,
|
|
182
|
+
response_body=err if isinstance(err, dict) else None,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def initialize(self) -> dict[str, Any]:
|
|
186
|
+
"""Async idempotent and concurrency-safe MCP handshake.
|
|
187
|
+
|
|
188
|
+
Calling this more than once on the same transport is a no-op after the
|
|
189
|
+
first successful handshake. While an ``initialize`` cool-off latch is
|
|
190
|
+
active, raises :class:`HyperpingRateLimitError` without issuing any
|
|
191
|
+
HTTP request.
|
|
192
|
+
|
|
193
|
+
The cool-off latch is per-transport-instance and per-process. It does
|
|
194
|
+
not coordinate across separate Python processes sharing the same API
|
|
195
|
+
key; each process keeps its own latch.
|
|
196
|
+
"""
|
|
197
|
+
# Fast path: avoid lock acquisition on every call after the handshake
|
|
198
|
+
# has succeeded. ``_initialized`` is only assigned True under the lock
|
|
199
|
+
# after both legs of the handshake, so a True read here is safe.
|
|
200
|
+
if self._initialized:
|
|
201
|
+
return self._init_result
|
|
202
|
+
async with self._init_lock:
|
|
203
|
+
if self._initialized:
|
|
204
|
+
return self._init_result
|
|
205
|
+
return await self._initialize_locked()
|
|
206
|
+
|
|
207
|
+
async def _initialize_locked(self) -> dict[str, Any]:
|
|
208
|
+
"""Perform the handshake. Assumes ``self._init_lock`` is held."""
|
|
209
|
+
# ``time.monotonic`` is used deliberately over ``time.time`` so the
|
|
210
|
+
# latch is immune to wall-clock jumps (NTP adjustments, suspend/resume).
|
|
211
|
+
remaining = self._init_blocked_until - time.monotonic()
|
|
212
|
+
if remaining > 0:
|
|
213
|
+
raise HyperpingRateLimitError(
|
|
214
|
+
"MCP initialize rate limit cool-off active; retry later",
|
|
215
|
+
retry_after=max(math.ceil(remaining), 1),
|
|
216
|
+
status_code=self._init_blocked_status_code,
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
result = await self._send_rpc(
|
|
220
|
+
"initialize",
|
|
221
|
+
{
|
|
222
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
223
|
+
"capabilities": {},
|
|
224
|
+
"clientInfo": {"name": "hyperping-python", "version": __version__},
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
await self._send_rpc("notifications/initialized", is_notification=True)
|
|
228
|
+
except HyperpingRateLimitError as exc:
|
|
229
|
+
# retry_after=None -> default cool-off; retry_after=0 -> no latch
|
|
230
|
+
# (the server is telling us we may retry immediately); positive
|
|
231
|
+
# values are honoured verbatim.
|
|
232
|
+
if exc.retry_after is None:
|
|
233
|
+
wait = _COOLOFF_DEFAULT_SECONDS
|
|
234
|
+
else:
|
|
235
|
+
wait = max(int(exc.retry_after), 0)
|
|
236
|
+
self._init_blocked_until = time.monotonic() + wait
|
|
237
|
+
self._init_blocked_status_code = exc.status_code or 200
|
|
238
|
+
raise
|
|
239
|
+
self._init_result = result.get("result", {}) if result else {}
|
|
240
|
+
self._init_blocked_until = 0.0
|
|
241
|
+
# Set last so the fast path in initialize() never returns a stale
|
|
242
|
+
# ``_init_result``.
|
|
243
|
+
self._initialized = True
|
|
244
|
+
return self._init_result
|
|
245
|
+
|
|
246
|
+
async def call_tool(
|
|
247
|
+
self,
|
|
248
|
+
tool_name: str,
|
|
249
|
+
arguments: dict[str, Any] | None = None,
|
|
250
|
+
) -> Any:
|
|
251
|
+
"""Call an MCP tool and return parsed response data.
|
|
252
|
+
|
|
253
|
+
Auto-initializes on first call. Extracts and parses the JSON
|
|
254
|
+
string from ``result.content[0].text``.
|
|
255
|
+
|
|
256
|
+
Retries automatically on transient HTTP server errors (500, 502, 503, 504)
|
|
257
|
+
up to ``max_retries`` times with exponential back-off. Rate-limit errors
|
|
258
|
+
(HTTP 429 or JSON-RPC -32000) are NEVER retried at this layer; they raise
|
|
259
|
+
:class:`HyperpingRateLimitError` immediately so callers can honour
|
|
260
|
+
``retry_after``.
|
|
261
|
+
"""
|
|
262
|
+
await self.initialize()
|
|
263
|
+
|
|
264
|
+
last_exc: Exception | None = None
|
|
265
|
+
for attempt in range(self._max_retries + 1):
|
|
266
|
+
try:
|
|
267
|
+
result = await self._send_rpc(
|
|
268
|
+
"tools/call",
|
|
269
|
+
{"name": tool_name, "arguments": arguments or {}},
|
|
270
|
+
)
|
|
271
|
+
break
|
|
272
|
+
except HyperpingAPIError as exc:
|
|
273
|
+
if exc.status_code and exc.status_code in (500, 502, 503, 504):
|
|
274
|
+
last_exc = exc
|
|
275
|
+
if attempt < self._max_retries:
|
|
276
|
+
await asyncio.sleep(min(2**attempt, 10))
|
|
277
|
+
continue
|
|
278
|
+
raise
|
|
279
|
+
else:
|
|
280
|
+
raise last_exc # type: ignore[misc]
|
|
281
|
+
if result is None:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
content = result.get("result", {}).get("content", [])
|
|
285
|
+
if not content:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
text = content[0].get("text", "")
|
|
289
|
+
if not text:
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
return json.loads(text)
|
|
294
|
+
except json.JSONDecodeError as exc:
|
|
295
|
+
raise HyperpingAPIError(
|
|
296
|
+
f"Failed to parse MCP tool response: {exc}",
|
|
297
|
+
status_code=200,
|
|
298
|
+
response_body={"raw": text[:500]},
|
|
299
|
+
) from exc
|
|
300
|
+
|
|
301
|
+
async def close(self) -> None:
|
|
302
|
+
await self._client.aclose()
|
|
303
|
+
|
|
304
|
+
async def __aenter__(self) -> AsyncMcpTransport:
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
async def __aexit__(self, *args: object) -> None:
|
|
308
|
+
await self.close()
|