hyperping 1.5.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.5.0 → hyperping-1.7.0}/.gitignore +1 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/CHANGELOG.md +51 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/PKG-INFO +96 -1
- {hyperping-1.5.0 → hyperping-1.7.0}/README.md +95 -0
- hyperping-1.7.0/SECURITY.md +59 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/pyproject.toml +2 -2
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_client.py +96 -13
- {hyperping-1.5.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.5.0 → hyperping-1.7.0}/src/hyperping/client.py +113 -13
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/exceptions.py +16 -3
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/mcp_client.py +23 -0
- {hyperping-1.5.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.5.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.7.0/tests/unit/test_per_endpoint_circuit_breaker.py +365 -0
- hyperping-1.5.0/src/hyperping/_async_mcp_transport.py +0 -199
- hyperping-1.5.0/src/hyperping/_mcp_transport.py +0 -201
- hyperping-1.5.0/src/hyperping/_version.py +0 -1
- hyperping-1.5.0/tests/unit/test_async_mcp_transport.py +0 -302
- hyperping-1.5.0/tests/unit/test_mcp_transport.py +0 -333
- {hyperping-1.5.0 → hyperping-1.7.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/LICENSE +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_internals.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_maintenance_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_utils.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_integration_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_oncall_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_async_preexisting.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_outages.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_sdk_surface.py +0 -0
- {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_statuspages.py +0 -0
|
@@ -5,6 +5,57 @@ 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
|
+
|
|
45
|
+
## [1.6.0] - 2026-05-06
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- Per-endpoint circuit breaker option (`per_endpoint_circuit_breaker: bool = False`) on
|
|
50
|
+
`HyperpingClient` and `AsyncHyperpingClient`. When enabled, each `Endpoint` gets its own
|
|
51
|
+
breaker state so a single flaky endpoint no longer blocks traffic to healthy ones.
|
|
52
|
+
Sub-resource paths (e.g. `/v1/monitors/{uuid}`, `/v1/monitors/{uuid}/reports`) are
|
|
53
|
+
bucketed under their parent `Endpoint` prefix so the breaker set stays bounded; pass a
|
|
54
|
+
custom `breaker_key_fn` to change that. The OPEN-state error message now identifies
|
|
55
|
+
which endpoint tripped. State for a given path is readable via
|
|
56
|
+
`client.circuit_breaker_state_for(path)` in either mode. Default behaviour is
|
|
57
|
+
unchanged. See README for details.
|
|
58
|
+
|
|
8
59
|
## [1.5.0] - 2026-04-20
|
|
9
60
|
|
|
10
61
|
### 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
|
|
@@ -289,6 +342,48 @@ client = HyperpingClient(
|
|
|
289
342
|
)
|
|
290
343
|
```
|
|
291
344
|
|
|
345
|
+
### Per-endpoint circuit breaker
|
|
346
|
+
|
|
347
|
+
By default a single shared circuit breaker covers every request. If one endpoint flakes, every other endpoint is also blocked. Enable `per_endpoint_circuit_breaker=True` to keep one breaker per *endpoint* so a failing endpoint does not punish healthy ones:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
client = HyperpingClient(
|
|
351
|
+
api_key="sk_...",
|
|
352
|
+
per_endpoint_circuit_breaker=True,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Inspect state for an endpoint. The breaker key is canonicalised to the
|
|
356
|
+
# matching `Endpoint` prefix, so all sub-resource paths share a bucket:
|
|
357
|
+
from hyperping import CircuitState, Endpoint
|
|
358
|
+
|
|
359
|
+
state = client.circuit_breaker_state_for(str(Endpoint.MONITORS))
|
|
360
|
+
# /v1/monitors, /v1/monitors/mon_abc and /v1/monitors/mon_abc/reports all
|
|
361
|
+
# report the same state — they share the `/v1/monitors` breaker.
|
|
362
|
+
assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_abc") == state
|
|
363
|
+
assert state in {CircuitState.CLOSED, CircuitState.HALF_OPEN, CircuitState.OPEN}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
If you need different bucketing (e.g. one breaker per resource UUID, or a single breaker per HTTP verb), pass a `breaker_key_fn`:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
def per_resource(path: str) -> str:
|
|
370
|
+
# one breaker per literal request path
|
|
371
|
+
return path.split("?", 1)[0]
|
|
372
|
+
|
|
373
|
+
client = HyperpingClient(
|
|
374
|
+
api_key="sk_...",
|
|
375
|
+
per_endpoint_circuit_breaker=True,
|
|
376
|
+
breaker_key_fn=per_resource,
|
|
377
|
+
)
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
| Option | Type | Default | Description |
|
|
381
|
+
| --- | --- | --- | --- |
|
|
382
|
+
| `per_endpoint_circuit_breaker` | `bool` | `False` | When `True`, maintain a separate circuit breaker keyed by request endpoint instead of using one shared breaker. The same `circuit_breaker_config` applies to every per-endpoint breaker. The shared breaker remains accessible via `client.circuit_breaker`. |
|
|
383
|
+
| `breaker_key_fn` | `Callable[[str], str] \| None` | `None` | Override the default endpoint-prefix bucketing. Receives the request path and returns the breaker key. Default behaviour collapses every path under the matching `Endpoint` prefix so the breaker set stays bounded (one per `Endpoint`); a custom function takes responsibility for keeping the key set bounded. Ignored unless `per_endpoint_circuit_breaker=True`. |
|
|
384
|
+
|
|
385
|
+
State for any path is readable via `client.circuit_breaker_state_for(path)`. In the default (single-breaker) mode this returns the shared breaker's state for any path, so the call is always safe regardless of the flag. The same options and method are available on `AsyncHyperpingClient`.
|
|
386
|
+
|
|
292
387
|
## Type Safety
|
|
293
388
|
|
|
294
389
|
This package ships a `py.typed` marker (PEP 561) and is fully typed. Works out of the box with mypy and pyright.
|
|
@@ -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
|
|
@@ -252,6 +305,48 @@ client = HyperpingClient(
|
|
|
252
305
|
)
|
|
253
306
|
```
|
|
254
307
|
|
|
308
|
+
### Per-endpoint circuit breaker
|
|
309
|
+
|
|
310
|
+
By default a single shared circuit breaker covers every request. If one endpoint flakes, every other endpoint is also blocked. Enable `per_endpoint_circuit_breaker=True` to keep one breaker per *endpoint* so a failing endpoint does not punish healthy ones:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
client = HyperpingClient(
|
|
314
|
+
api_key="sk_...",
|
|
315
|
+
per_endpoint_circuit_breaker=True,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Inspect state for an endpoint. The breaker key is canonicalised to the
|
|
319
|
+
# matching `Endpoint` prefix, so all sub-resource paths share a bucket:
|
|
320
|
+
from hyperping import CircuitState, Endpoint
|
|
321
|
+
|
|
322
|
+
state = client.circuit_breaker_state_for(str(Endpoint.MONITORS))
|
|
323
|
+
# /v1/monitors, /v1/monitors/mon_abc and /v1/monitors/mon_abc/reports all
|
|
324
|
+
# report the same state — they share the `/v1/monitors` breaker.
|
|
325
|
+
assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_abc") == state
|
|
326
|
+
assert state in {CircuitState.CLOSED, CircuitState.HALF_OPEN, CircuitState.OPEN}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
If you need different bucketing (e.g. one breaker per resource UUID, or a single breaker per HTTP verb), pass a `breaker_key_fn`:
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
def per_resource(path: str) -> str:
|
|
333
|
+
# one breaker per literal request path
|
|
334
|
+
return path.split("?", 1)[0]
|
|
335
|
+
|
|
336
|
+
client = HyperpingClient(
|
|
337
|
+
api_key="sk_...",
|
|
338
|
+
per_endpoint_circuit_breaker=True,
|
|
339
|
+
breaker_key_fn=per_resource,
|
|
340
|
+
)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
| Option | Type | Default | Description |
|
|
344
|
+
| --- | --- | --- | --- |
|
|
345
|
+
| `per_endpoint_circuit_breaker` | `bool` | `False` | When `True`, maintain a separate circuit breaker keyed by request endpoint instead of using one shared breaker. The same `circuit_breaker_config` applies to every per-endpoint breaker. The shared breaker remains accessible via `client.circuit_breaker`. |
|
|
346
|
+
| `breaker_key_fn` | `Callable[[str], str] \| None` | `None` | Override the default endpoint-prefix bucketing. Receives the request path and returns the breaker key. Default behaviour collapses every path under the matching `Endpoint` prefix so the breaker set stays bounded (one per `Endpoint`); a custom function takes responsibility for keeping the key set bounded. Ignored unless `per_endpoint_circuit_breaker=True`. |
|
|
347
|
+
|
|
348
|
+
State for any path is readable via `client.circuit_breaker_state_for(path)`. In the default (single-breaker) mode this returns the shared breaker's state for any path, so the call is always safe regardless of the flag. The same options and method are available on `AsyncHyperpingClient`.
|
|
349
|
+
|
|
255
350
|
## Type Safety
|
|
256
351
|
|
|
257
352
|
This package ships a `py.typed` marker (PEP 561) and is fully typed. Works out of the box with mypy and pyright.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
We release patches for security vulnerabilities for the following versions:
|
|
6
|
+
|
|
7
|
+
| Version | Supported |
|
|
8
|
+
| ------- | ------------------ |
|
|
9
|
+
| 1.7.x | :white_check_mark: |
|
|
10
|
+
| 1.6.x | :white_check_mark: |
|
|
11
|
+
| < 1.6 | :x: |
|
|
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.
|
|
14
|
+
|
|
15
|
+
## Reporting a Vulnerability
|
|
16
|
+
|
|
17
|
+
**Please do not report security vulnerabilities through public GitHub issues.**
|
|
18
|
+
|
|
19
|
+
Instead, please report security vulnerabilities by emailing:
|
|
20
|
+
|
|
21
|
+
**security@develeap.com**
|
|
22
|
+
|
|
23
|
+
You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message.
|
|
24
|
+
|
|
25
|
+
Please include the following information in your report:
|
|
26
|
+
|
|
27
|
+
- Type of vulnerability (e.g., credential exposure, request smuggling, deserialization issue, etc.)
|
|
28
|
+
- Full paths of source file(s) related to the vulnerability
|
|
29
|
+
- The location of the affected source code (tag/branch/commit or direct URL)
|
|
30
|
+
- Step-by-step instructions to reproduce the issue
|
|
31
|
+
- Proof-of-concept or exploit code (if possible)
|
|
32
|
+
- Impact of the issue, including how an attacker might exploit it
|
|
33
|
+
- The Python version, `hyperping` package version, and any relevant transitive dependency versions (`pip show hyperping`, `python --version`)
|
|
34
|
+
|
|
35
|
+
This information will help us triage your report more quickly.
|
|
36
|
+
|
|
37
|
+
## Preferred Languages
|
|
38
|
+
|
|
39
|
+
We prefer all communications to be in English.
|
|
40
|
+
|
|
41
|
+
## Security Update Process
|
|
42
|
+
|
|
43
|
+
1. The security report is received and assigned a primary handler
|
|
44
|
+
2. The problem is confirmed and a list of affected versions determined
|
|
45
|
+
3. Code is audited to find any potential similar problems
|
|
46
|
+
4. Fixes are prepared for all supported releases
|
|
47
|
+
5. New versions are released to PyPI as soon as possible, and a GitHub Security Advisory is published
|
|
48
|
+
|
|
49
|
+
## Public Disclosure
|
|
50
|
+
|
|
51
|
+
We believe in responsible disclosure. We will coordinate the public disclosure with you, and we prefer to fully disclose the vulnerability once a patch is available on PyPI.
|
|
52
|
+
|
|
53
|
+
## Comments on this Policy
|
|
54
|
+
|
|
55
|
+
If you have suggestions on how this process could be improved, please submit a pull request or open an issue to discuss.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
**Thank you for helping keep hyperping-python and our users safe!**
|
|
@@ -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"]
|
|
@@ -14,7 +14,10 @@ Example::
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import logging
|
|
16
16
|
import random
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Callable
|
|
17
19
|
from typing import Any
|
|
20
|
+
from urllib.parse import urlsplit
|
|
18
21
|
|
|
19
22
|
import httpx
|
|
20
23
|
from pydantic import SecretStr
|
|
@@ -28,10 +31,11 @@ from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin
|
|
|
28
31
|
from hyperping._circuit_breaker import (
|
|
29
32
|
CircuitBreaker,
|
|
30
33
|
CircuitBreakerConfig,
|
|
34
|
+
CircuitState,
|
|
31
35
|
)
|
|
32
36
|
from hyperping._internals import DEFAULT_USER_AGENT, RETRY_AFTER_MAX, sanitize_for_log
|
|
33
37
|
from hyperping.client import DEFAULT_RETRY_CONFIG, RetryConfig
|
|
34
|
-
from hyperping.endpoints import API_BASE
|
|
38
|
+
from hyperping.endpoints import API_BASE, Endpoint
|
|
35
39
|
from hyperping.exceptions import (
|
|
36
40
|
HyperpingAPIError,
|
|
37
41
|
HyperpingAuthError,
|
|
@@ -73,6 +77,8 @@ class AsyncHyperpingClient(
|
|
|
73
77
|
retry_config: RetryConfig | None = None,
|
|
74
78
|
circuit_breaker_config: CircuitBreakerConfig | None = None,
|
|
75
79
|
user_agent: str | None = None,
|
|
80
|
+
per_endpoint_circuit_breaker: bool = False,
|
|
81
|
+
breaker_key_fn: Callable[[str], str] | None = None,
|
|
76
82
|
) -> None:
|
|
77
83
|
"""Initialize the async Hyperping API client.
|
|
78
84
|
|
|
@@ -82,8 +88,19 @@ class AsyncHyperpingClient(
|
|
|
82
88
|
base_url: Override the default API base URL.
|
|
83
89
|
timeout: HTTP request timeout in seconds.
|
|
84
90
|
retry_config: Retry behaviour configuration.
|
|
85
|
-
circuit_breaker_config: Circuit breaker configuration.
|
|
91
|
+
circuit_breaker_config: Circuit breaker configuration. When
|
|
92
|
+
``per_endpoint_circuit_breaker`` is ``True`` the same config is
|
|
93
|
+
applied to each per-endpoint breaker.
|
|
86
94
|
user_agent: Custom ``User-Agent`` header value.
|
|
95
|
+
per_endpoint_circuit_breaker: When ``True``, maintain an
|
|
96
|
+
independent breaker per :class:`~hyperping.endpoints.Endpoint`
|
|
97
|
+
prefix (sub-resources inherit the parent endpoint's breaker,
|
|
98
|
+
so the breaker set stays bounded). Default ``False``
|
|
99
|
+
preserves the original single-shared-breaker behaviour.
|
|
100
|
+
breaker_key_fn: Override the default endpoint-prefix bucketing.
|
|
101
|
+
Receives the request path and must return the breaker key.
|
|
102
|
+
Ignored unless ``per_endpoint_circuit_breaker`` is ``True``.
|
|
103
|
+
Caller is responsible for keeping the key set bounded.
|
|
87
104
|
"""
|
|
88
105
|
raw_key = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
|
|
89
106
|
if not raw_key or not raw_key.strip():
|
|
@@ -92,7 +109,12 @@ class AsyncHyperpingClient(
|
|
|
92
109
|
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
93
110
|
self.timeout = timeout
|
|
94
111
|
self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
|
|
112
|
+
self._circuit_breaker_config = circuit_breaker_config
|
|
95
113
|
self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
|
|
114
|
+
self._per_endpoint_circuit_breaker = per_endpoint_circuit_breaker
|
|
115
|
+
self._breaker_key_fn = breaker_key_fn
|
|
116
|
+
self._endpoint_breakers: dict[str, CircuitBreaker] = {}
|
|
117
|
+
self._endpoint_breakers_lock = threading.Lock()
|
|
96
118
|
|
|
97
119
|
self._client = httpx.AsyncClient(
|
|
98
120
|
base_url=self.base_url,
|
|
@@ -120,9 +142,74 @@ class AsyncHyperpingClient(
|
|
|
120
142
|
|
|
121
143
|
@property
|
|
122
144
|
def circuit_breaker(self) -> CircuitBreaker:
|
|
123
|
-
"""Access the circuit breaker state (for monitoring).
|
|
145
|
+
"""Access the (shared) circuit breaker state (for monitoring).
|
|
146
|
+
|
|
147
|
+
In per-endpoint mode this returns the original shared breaker, kept
|
|
148
|
+
for backward compatibility; the per-path breakers are exposed via
|
|
149
|
+
:meth:`circuit_breaker_state_for`.
|
|
150
|
+
"""
|
|
124
151
|
return self._circuit_breaker
|
|
125
152
|
|
|
153
|
+
def _resolve_breaker_key(self, path: str) -> str:
|
|
154
|
+
"""Map a request path to its circuit-breaker key.
|
|
155
|
+
|
|
156
|
+
Default bucketing strips query/fragment and collapses the path under
|
|
157
|
+
the longest matching :class:`Endpoint` prefix; a custom
|
|
158
|
+
``breaker_key_fn`` wins outright.
|
|
159
|
+
"""
|
|
160
|
+
if self._breaker_key_fn is not None:
|
|
161
|
+
return self._breaker_key_fn(path)
|
|
162
|
+
pure = urlsplit(path).path
|
|
163
|
+
for ep in Endpoint:
|
|
164
|
+
ep_value = ep.value
|
|
165
|
+
if pure == ep_value or pure.startswith(ep_value + "/"):
|
|
166
|
+
return ep_value
|
|
167
|
+
return pure
|
|
168
|
+
|
|
169
|
+
def _breaker_for(self, path: str) -> CircuitBreaker:
|
|
170
|
+
"""Return the breaker that governs ``path`` (shared, or per-endpoint)."""
|
|
171
|
+
if not self._per_endpoint_circuit_breaker:
|
|
172
|
+
return self._circuit_breaker
|
|
173
|
+
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
|
+
with self._endpoint_breakers_lock:
|
|
177
|
+
breaker = self._endpoint_breakers.get(key)
|
|
178
|
+
if breaker is None:
|
|
179
|
+
breaker = CircuitBreaker(self._circuit_breaker_config)
|
|
180
|
+
self._endpoint_breakers[key] = breaker
|
|
181
|
+
return breaker
|
|
182
|
+
|
|
183
|
+
def circuit_breaker_state_for(self, path: str) -> CircuitState:
|
|
184
|
+
"""Return the circuit state of the breaker governing ``path``.
|
|
185
|
+
|
|
186
|
+
In per-endpoint mode the path is canonicalised the same way as during
|
|
187
|
+
a request; untouched buckets report :attr:`CircuitState.CLOSED`
|
|
188
|
+
without allocating a breaker. In default mode the shared breaker's
|
|
189
|
+
state is returned for any path.
|
|
190
|
+
"""
|
|
191
|
+
if not self._per_endpoint_circuit_breaker:
|
|
192
|
+
return self._circuit_breaker.state
|
|
193
|
+
key = self._resolve_breaker_key(path)
|
|
194
|
+
with self._endpoint_breakers_lock:
|
|
195
|
+
breaker = self._endpoint_breakers.get(key)
|
|
196
|
+
return breaker.state if breaker is not None else CircuitState.CLOSED
|
|
197
|
+
|
|
198
|
+
def _circuit_open_message(self, breaker: CircuitBreaker, path: str) -> str:
|
|
199
|
+
"""Build the error message raised when a request is rejected by an OPEN breaker."""
|
|
200
|
+
if self._per_endpoint_circuit_breaker:
|
|
201
|
+
key = self._resolve_breaker_key(path)
|
|
202
|
+
return (
|
|
203
|
+
f"Circuit breaker OPEN for {key!r} - API calls to this endpoint suspended. "
|
|
204
|
+
f"Consecutive failures: {breaker.failure_count}. "
|
|
205
|
+
f"Will recover after {breaker.recovery_timeout}s."
|
|
206
|
+
)
|
|
207
|
+
return (
|
|
208
|
+
f"Circuit breaker OPEN - API calls suspended. "
|
|
209
|
+
f"Consecutive failures: {breaker.failure_count}. "
|
|
210
|
+
f"Will recover after {breaker.recovery_timeout}s."
|
|
211
|
+
)
|
|
212
|
+
|
|
126
213
|
# ==================== Error Handling ====================
|
|
127
214
|
|
|
128
215
|
def _parse_error_body(self, response: httpx.Response) -> dict[str, Any]:
|
|
@@ -236,7 +323,7 @@ class AsyncHyperpingClient(
|
|
|
236
323
|
if response.status_code >= 400:
|
|
237
324
|
return response
|
|
238
325
|
|
|
239
|
-
self.
|
|
326
|
+
self._breaker_for(path).record_success()
|
|
240
327
|
if response.status_code == 204:
|
|
241
328
|
return {}
|
|
242
329
|
return response.json() # type: ignore[no-any-return]
|
|
@@ -262,13 +349,9 @@ class AsyncHyperpingClient(
|
|
|
262
349
|
Raises:
|
|
263
350
|
HyperpingAPIError: On API errors after retries exhausted
|
|
264
351
|
"""
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
raise HyperpingAPIError(
|
|
268
|
-
f"Circuit breaker OPEN - API calls suspended. "
|
|
269
|
-
f"Consecutive failures: {cb.failure_count}. "
|
|
270
|
-
f"Will recover after {cb.recovery_timeout}s."
|
|
271
|
-
)
|
|
352
|
+
breaker = self._breaker_for(path)
|
|
353
|
+
if not breaker.call_allowed():
|
|
354
|
+
raise HyperpingAPIError(self._circuit_open_message(breaker, path))
|
|
272
355
|
|
|
273
356
|
last_exception: Exception | None = None
|
|
274
357
|
delay = self.retry_config.initial_delay
|
|
@@ -299,7 +382,7 @@ class AsyncHyperpingClient(
|
|
|
299
382
|
continue
|
|
300
383
|
|
|
301
384
|
if response.status_code >= 500:
|
|
302
|
-
|
|
385
|
+
breaker.record_failure()
|
|
303
386
|
self._handle_response_error(response)
|
|
304
387
|
|
|
305
388
|
except (httpx.TimeoutException, httpx.RequestError) as e:
|
|
@@ -320,7 +403,7 @@ class AsyncHyperpingClient(
|
|
|
320
403
|
self.retry_config.max_delay,
|
|
321
404
|
)
|
|
322
405
|
continue
|
|
323
|
-
|
|
406
|
+
breaker.record_failure()
|
|
324
407
|
if isinstance(e, httpx.TimeoutException):
|
|
325
408
|
raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e
|
|
326
409
|
raise HyperpingAPIError(f"Request failed: {e}") from e
|
|
@@ -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:
|