hyperping 1.4.1__tar.gz → 1.6.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.4.1 → hyperping-1.6.0}/CHANGELOG.md +42 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/PKG-INFO +55 -1
- {hyperping-1.4.1 → hyperping-1.6.0}/README.md +54 -0
- hyperping-1.6.0/SECURITY.md +58 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/pyproject.toml +1 -1
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/__init__.py +18 -2
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_client.py +96 -13
- hyperping-1.6.0/src/hyperping/_async_mcp_client.py +249 -0
- hyperping-1.6.0/src/hyperping/_async_mcp_transport.py +199 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_mcp_transport.py +64 -9
- hyperping-1.6.0/src/hyperping/_version.py +1 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/client.py +113 -13
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/mcp_client.py +63 -41
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/__init__.py +21 -4
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_healthcheck_models.py +3 -3
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_incident_models.py +2 -2
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_integration_models.py +1 -1
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_maintenance_models.py +1 -1
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_monitor_models.py +5 -5
- hyperping-1.6.0/src/hyperping/models/_observability_models.py +56 -0
- hyperping-1.6.0/src/hyperping/models/_oncall_models.py +42 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_outage_models.py +30 -19
- hyperping-1.6.0/src/hyperping/models/_reporting_models.py +94 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_statuspage_models.py +2 -2
- hyperping-1.6.0/tests/unit/test_async_mcp_client.py +284 -0
- hyperping-1.6.0/tests/unit/test_async_mcp_transport.py +302 -0
- hyperping-1.6.0/tests/unit/test_async_preexisting.py +716 -0
- hyperping-1.6.0/tests/unit/test_client_coverage.py +277 -0
- hyperping-1.6.0/tests/unit/test_mcp_client.py +259 -0
- hyperping-1.6.0/tests/unit/test_mcp_transport.py +333 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_outages.py +85 -0
- hyperping-1.6.0/tests/unit/test_per_endpoint_circuit_breaker.py +365 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_sdk_surface.py +62 -0
- hyperping-1.4.1/src/hyperping/_version.py +0 -1
- hyperping-1.4.1/src/hyperping/models/_observability_models.py +0 -46
- hyperping-1.4.1/src/hyperping/models/_oncall_models.py +0 -27
- hyperping-1.4.1/src/hyperping/models/_reporting_models.py +0 -17
- hyperping-1.4.1/tests/unit/test_async_preexisting.py +0 -325
- hyperping-1.4.1/tests/unit/test_mcp_client.py +0 -161
- hyperping-1.4.1/tests/unit/test_mcp_transport.py +0 -172
- {hyperping-1.4.1 → hyperping-1.6.0}/.gitignore +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/LICENSE +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_internals.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_maintenance_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_utils.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/exceptions.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/__init__.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_statuspages.py +0 -0
|
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.6.0] - 2026-05-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Per-endpoint circuit breaker option (`per_endpoint_circuit_breaker: bool = False`) on
|
|
13
|
+
`HyperpingClient` and `AsyncHyperpingClient`. When enabled, each `Endpoint` gets its own
|
|
14
|
+
breaker state so a single flaky endpoint no longer blocks traffic to healthy ones.
|
|
15
|
+
Sub-resource paths (e.g. `/v1/monitors/{uuid}`, `/v1/monitors/{uuid}/reports`) are
|
|
16
|
+
bucketed under their parent `Endpoint` prefix so the breaker set stays bounded; pass a
|
|
17
|
+
custom `breaker_key_fn` to change that. The OPEN-state error message now identifies
|
|
18
|
+
which endpoint tripped. State for a given path is readable via
|
|
19
|
+
`client.circuit_breaker_state_for(path)` in either mode. Default behaviour is
|
|
20
|
+
unchanged. See README for details.
|
|
21
|
+
|
|
22
|
+
## [1.5.0] - 2026-04-20
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`AsyncHyperpingMcpClient`** -- full async counterpart to `HyperpingMcpClient`. All 16
|
|
27
|
+
MCP methods available via `await`. Uses `httpx.AsyncClient`, `asyncio.Lock`, and async
|
|
28
|
+
retry with `asyncio.sleep`. Exported from `hyperping` top-level.
|
|
29
|
+
- **Typed MCP returns** -- all 16 MCP client methods now return Pydantic models instead of
|
|
30
|
+
raw `dict[str, Any]`. Models verified against the live API.
|
|
31
|
+
- **New models**: `TimeGroup`, `ResponseTimeReport`, `AlertHistory`, `MonitorMetricsSummary`,
|
|
32
|
+
`MttrReport`, `MttaReport`, `ProbeLogResponse`, `TeamMember`, `OutageMonitorSummary`.
|
|
33
|
+
- **MCP error handling parity** -- both sync and async transports now map HTTP 404, 429,
|
|
34
|
+
400/422 to the same exception types as the REST client (`HyperpingNotFoundError`,
|
|
35
|
+
`HyperpingRateLimitError`, `HyperpingValidationError`).
|
|
36
|
+
- **MCP retry logic** -- automatic retry with exponential backoff on transient server
|
|
37
|
+
errors (500, 502, 503, 504) up to `max_retries` times (default 2).
|
|
38
|
+
- **Thread safety** -- `threading.Lock` (sync) and `asyncio.Lock` (async) protect the
|
|
39
|
+
request ID counter and initialization flag.
|
|
40
|
+
- **83 new tests** covering async MCP transport, client coverage, sync transport error
|
|
41
|
+
paths, async maintenance/outages, and protocol base classes. Overall coverage: 96%.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **Forward-compatible models** -- all 28 response models changed from `extra="ignore"` to
|
|
46
|
+
`extra="allow"`. New API fields are preserved instead of silently dropped.
|
|
47
|
+
- **Typed sub-objects** -- `OutageTimeline.outage` is now typed `Outage` (was `dict`),
|
|
48
|
+
`.monitor` is `OutageMonitorSummary` (was `dict`).
|
|
49
|
+
|
|
8
50
|
## [1.4.1] - 2026-04-20
|
|
9
51
|
|
|
10
52
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperping
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -98,6 +98,18 @@ async def main():
|
|
|
98
98
|
|
|
99
99
|
The async client supports all the same resources, retry behaviour, and circuit breaker as the sync client. Use `RetryConfig` and `CircuitBreakerConfig` in exactly the same way.
|
|
100
100
|
|
|
101
|
+
An async MCP client is also available:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from hyperping import AsyncHyperpingMcpClient
|
|
105
|
+
|
|
106
|
+
async def main():
|
|
107
|
+
async with AsyncHyperpingMcpClient(api_key="sk_...") as mcp:
|
|
108
|
+
summary = await mcp.get_status_summary()
|
|
109
|
+
members = await mcp.list_team_members()
|
|
110
|
+
anomalies = await mcp.get_monitor_anomalies("mon_uuid")
|
|
111
|
+
```
|
|
112
|
+
|
|
101
113
|
## Authentication
|
|
102
114
|
|
|
103
115
|
Pass your API key directly or via environment variable:
|
|
@@ -277,6 +289,48 @@ client = HyperpingClient(
|
|
|
277
289
|
)
|
|
278
290
|
```
|
|
279
291
|
|
|
292
|
+
### Per-endpoint circuit breaker
|
|
293
|
+
|
|
294
|
+
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:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
client = HyperpingClient(
|
|
298
|
+
api_key="sk_...",
|
|
299
|
+
per_endpoint_circuit_breaker=True,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Inspect state for an endpoint. The breaker key is canonicalised to the
|
|
303
|
+
# matching `Endpoint` prefix, so all sub-resource paths share a bucket:
|
|
304
|
+
from hyperping import CircuitState, Endpoint
|
|
305
|
+
|
|
306
|
+
state = client.circuit_breaker_state_for(str(Endpoint.MONITORS))
|
|
307
|
+
# /v1/monitors, /v1/monitors/mon_abc and /v1/monitors/mon_abc/reports all
|
|
308
|
+
# report the same state — they share the `/v1/monitors` breaker.
|
|
309
|
+
assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_abc") == state
|
|
310
|
+
assert state in {CircuitState.CLOSED, CircuitState.HALF_OPEN, CircuitState.OPEN}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
If you need different bucketing (e.g. one breaker per resource UUID, or a single breaker per HTTP verb), pass a `breaker_key_fn`:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
def per_resource(path: str) -> str:
|
|
317
|
+
# one breaker per literal request path
|
|
318
|
+
return path.split("?", 1)[0]
|
|
319
|
+
|
|
320
|
+
client = HyperpingClient(
|
|
321
|
+
api_key="sk_...",
|
|
322
|
+
per_endpoint_circuit_breaker=True,
|
|
323
|
+
breaker_key_fn=per_resource,
|
|
324
|
+
)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
| Option | Type | Default | Description |
|
|
328
|
+
| --- | --- | --- | --- |
|
|
329
|
+
| `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`. |
|
|
330
|
+
| `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`. |
|
|
331
|
+
|
|
332
|
+
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`.
|
|
333
|
+
|
|
280
334
|
## Type Safety
|
|
281
335
|
|
|
282
336
|
This package ships a `py.typed` marker (PEP 561) and is fully typed. Works out of the box with mypy and pyright.
|
|
@@ -61,6 +61,18 @@ async def main():
|
|
|
61
61
|
|
|
62
62
|
The async client supports all the same resources, retry behaviour, and circuit breaker as the sync client. Use `RetryConfig` and `CircuitBreakerConfig` in exactly the same way.
|
|
63
63
|
|
|
64
|
+
An async MCP client is also available:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from hyperping import AsyncHyperpingMcpClient
|
|
68
|
+
|
|
69
|
+
async def main():
|
|
70
|
+
async with AsyncHyperpingMcpClient(api_key="sk_...") as mcp:
|
|
71
|
+
summary = await mcp.get_status_summary()
|
|
72
|
+
members = await mcp.list_team_members()
|
|
73
|
+
anomalies = await mcp.get_monitor_anomalies("mon_uuid")
|
|
74
|
+
```
|
|
75
|
+
|
|
64
76
|
## Authentication
|
|
65
77
|
|
|
66
78
|
Pass your API key directly or via environment variable:
|
|
@@ -240,6 +252,48 @@ client = HyperpingClient(
|
|
|
240
252
|
)
|
|
241
253
|
```
|
|
242
254
|
|
|
255
|
+
### Per-endpoint circuit breaker
|
|
256
|
+
|
|
257
|
+
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:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
client = HyperpingClient(
|
|
261
|
+
api_key="sk_...",
|
|
262
|
+
per_endpoint_circuit_breaker=True,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Inspect state for an endpoint. The breaker key is canonicalised to the
|
|
266
|
+
# matching `Endpoint` prefix, so all sub-resource paths share a bucket:
|
|
267
|
+
from hyperping import CircuitState, Endpoint
|
|
268
|
+
|
|
269
|
+
state = client.circuit_breaker_state_for(str(Endpoint.MONITORS))
|
|
270
|
+
# /v1/monitors, /v1/monitors/mon_abc and /v1/monitors/mon_abc/reports all
|
|
271
|
+
# report the same state — they share the `/v1/monitors` breaker.
|
|
272
|
+
assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_abc") == state
|
|
273
|
+
assert state in {CircuitState.CLOSED, CircuitState.HALF_OPEN, CircuitState.OPEN}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
If you need different bucketing (e.g. one breaker per resource UUID, or a single breaker per HTTP verb), pass a `breaker_key_fn`:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
def per_resource(path: str) -> str:
|
|
280
|
+
# one breaker per literal request path
|
|
281
|
+
return path.split("?", 1)[0]
|
|
282
|
+
|
|
283
|
+
client = HyperpingClient(
|
|
284
|
+
api_key="sk_...",
|
|
285
|
+
per_endpoint_circuit_breaker=True,
|
|
286
|
+
breaker_key_fn=per_resource,
|
|
287
|
+
)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
| Option | Type | Default | Description |
|
|
291
|
+
| --- | --- | --- | --- |
|
|
292
|
+
| `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`. |
|
|
293
|
+
| `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`. |
|
|
294
|
+
|
|
295
|
+
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`.
|
|
296
|
+
|
|
243
297
|
## Type Safety
|
|
244
298
|
|
|
245
299
|
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,58 @@
|
|
|
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.5.x | :white_check_mark: |
|
|
10
|
+
| < 1.5 | :x: |
|
|
11
|
+
|
|
12
|
+
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
|
+
## Reporting a Vulnerability
|
|
15
|
+
|
|
16
|
+
**Please do not report security vulnerabilities through public GitHub issues.**
|
|
17
|
+
|
|
18
|
+
Instead, please report security vulnerabilities by emailing:
|
|
19
|
+
|
|
20
|
+
**security@develeap.com**
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
Please include the following information in your report:
|
|
25
|
+
|
|
26
|
+
- Type of vulnerability (e.g., credential exposure, request smuggling, deserialization issue, etc.)
|
|
27
|
+
- Full paths of source file(s) related to the vulnerability
|
|
28
|
+
- The location of the affected source code (tag/branch/commit or direct URL)
|
|
29
|
+
- Step-by-step instructions to reproduce the issue
|
|
30
|
+
- Proof-of-concept or exploit code (if possible)
|
|
31
|
+
- Impact of the issue, including how an attacker might exploit it
|
|
32
|
+
- The Python version, `hyperping` package version, and any relevant transitive dependency versions (`pip show hyperping`, `python --version`)
|
|
33
|
+
|
|
34
|
+
This information will help us triage your report more quickly.
|
|
35
|
+
|
|
36
|
+
## Preferred Languages
|
|
37
|
+
|
|
38
|
+
We prefer all communications to be in English.
|
|
39
|
+
|
|
40
|
+
## Security Update Process
|
|
41
|
+
|
|
42
|
+
1. The security report is received and assigned a primary handler
|
|
43
|
+
2. The problem is confirmed and a list of affected versions determined
|
|
44
|
+
3. Code is audited to find any potential similar problems
|
|
45
|
+
4. Fixes are prepared for all supported releases
|
|
46
|
+
5. New versions are released to PyPI as soon as possible, and a GitHub Security Advisory is published
|
|
47
|
+
|
|
48
|
+
## Public Disclosure
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
## Comments on this Policy
|
|
53
|
+
|
|
54
|
+
If you have suggestions on how this process could be improved, please submit a pull request or open an issue to discuss.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
**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.6.0"
|
|
8
8
|
description = "Python SDK for the Hyperping uptime monitoring and incident management API"
|
|
9
9
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -15,6 +15,7 @@ Quick start::
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from hyperping._async_client import AsyncHyperpingClient
|
|
18
|
+
from hyperping._async_mcp_client import AsyncHyperpingMcpClient
|
|
18
19
|
from hyperping._version import __version__
|
|
19
20
|
from hyperping.client import (
|
|
20
21
|
CircuitBreaker,
|
|
@@ -40,7 +41,7 @@ from hyperping.mcp_client import HyperpingMcpClient
|
|
|
40
41
|
from hyperping.models import (
|
|
41
42
|
DEFAULT_REGIONS,
|
|
42
43
|
AddIncidentUpdateRequest,
|
|
43
|
-
|
|
44
|
+
AlertHistory,
|
|
44
45
|
DnsRecordType,
|
|
45
46
|
EscalationPolicy,
|
|
46
47
|
Healthcheck,
|
|
@@ -64,10 +65,13 @@ from hyperping.models import (
|
|
|
64
65
|
MonitorCreate,
|
|
65
66
|
MonitorFrequency,
|
|
66
67
|
MonitorListResponse,
|
|
68
|
+
MonitorMetricsSummary,
|
|
67
69
|
MonitorProtocol,
|
|
68
70
|
MonitorReport,
|
|
69
71
|
MonitorTimeout,
|
|
70
72
|
MonitorUpdate,
|
|
73
|
+
MttaReport,
|
|
74
|
+
MttrReport,
|
|
71
75
|
NotificationOption,
|
|
72
76
|
OnCallSchedule,
|
|
73
77
|
Outage,
|
|
@@ -77,14 +81,18 @@ from hyperping.models import (
|
|
|
77
81
|
OutageTimeline,
|
|
78
82
|
OutageTimelineEvent,
|
|
79
83
|
ProbeLog,
|
|
84
|
+
ProbeLogResponse,
|
|
80
85
|
Region,
|
|
81
86
|
ReportPeriod,
|
|
82
87
|
RequestHeader,
|
|
88
|
+
ResponseTimeReport,
|
|
83
89
|
StatusPage,
|
|
84
90
|
StatusPageCreate,
|
|
85
91
|
StatusPageSubscriber,
|
|
86
92
|
StatusPageUpdate,
|
|
87
93
|
StatusSummary,
|
|
94
|
+
TeamMember,
|
|
95
|
+
TimeGroup,
|
|
88
96
|
)
|
|
89
97
|
|
|
90
98
|
__all__ = [
|
|
@@ -92,6 +100,7 @@ __all__ = [
|
|
|
92
100
|
"__version__",
|
|
93
101
|
# Clients
|
|
94
102
|
"AsyncHyperpingClient",
|
|
103
|
+
"AsyncHyperpingMcpClient",
|
|
95
104
|
"HyperpingClient",
|
|
96
105
|
"HyperpingMcpClient",
|
|
97
106
|
# MCP
|
|
@@ -156,14 +165,21 @@ __all__ = [
|
|
|
156
165
|
# Observability
|
|
157
166
|
"MonitorAnomaly",
|
|
158
167
|
"ProbeLog",
|
|
159
|
-
"
|
|
168
|
+
"ProbeLogResponse",
|
|
160
169
|
# On-call
|
|
161
170
|
"OnCallSchedule",
|
|
162
171
|
"EscalationPolicy",
|
|
172
|
+
"TeamMember",
|
|
163
173
|
# Integrations
|
|
164
174
|
"Integration",
|
|
165
175
|
# Reporting
|
|
166
176
|
"StatusSummary",
|
|
177
|
+
"TimeGroup",
|
|
178
|
+
"ResponseTimeReport",
|
|
179
|
+
"AlertHistory",
|
|
180
|
+
"MonitorMetricsSummary",
|
|
181
|
+
"MttrReport",
|
|
182
|
+
"MttaReport",
|
|
167
183
|
# Healthchecks
|
|
168
184
|
"Healthcheck",
|
|
169
185
|
"HealthcheckCreate",
|
|
@@ -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
|