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.
Files changed (74) hide show
  1. {hyperping-1.4.1 → hyperping-1.6.0}/CHANGELOG.md +42 -0
  2. {hyperping-1.4.1 → hyperping-1.6.0}/PKG-INFO +55 -1
  3. {hyperping-1.4.1 → hyperping-1.6.0}/README.md +54 -0
  4. hyperping-1.6.0/SECURITY.md +58 -0
  5. {hyperping-1.4.1 → hyperping-1.6.0}/pyproject.toml +1 -1
  6. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/__init__.py +18 -2
  7. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_client.py +96 -13
  8. hyperping-1.6.0/src/hyperping/_async_mcp_client.py +249 -0
  9. hyperping-1.6.0/src/hyperping/_async_mcp_transport.py +199 -0
  10. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_mcp_transport.py +64 -9
  11. hyperping-1.6.0/src/hyperping/_version.py +1 -0
  12. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/client.py +113 -13
  13. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/mcp_client.py +63 -41
  14. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/__init__.py +21 -4
  15. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_healthcheck_models.py +3 -3
  16. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_incident_models.py +2 -2
  17. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_integration_models.py +1 -1
  18. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_maintenance_models.py +1 -1
  19. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_monitor_models.py +5 -5
  20. hyperping-1.6.0/src/hyperping/models/_observability_models.py +56 -0
  21. hyperping-1.6.0/src/hyperping/models/_oncall_models.py +42 -0
  22. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_outage_models.py +30 -19
  23. hyperping-1.6.0/src/hyperping/models/_reporting_models.py +94 -0
  24. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/models/_statuspage_models.py +2 -2
  25. hyperping-1.6.0/tests/unit/test_async_mcp_client.py +284 -0
  26. hyperping-1.6.0/tests/unit/test_async_mcp_transport.py +302 -0
  27. hyperping-1.6.0/tests/unit/test_async_preexisting.py +716 -0
  28. hyperping-1.6.0/tests/unit/test_client_coverage.py +277 -0
  29. hyperping-1.6.0/tests/unit/test_mcp_client.py +259 -0
  30. hyperping-1.6.0/tests/unit/test_mcp_transport.py +333 -0
  31. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_outages.py +85 -0
  32. hyperping-1.6.0/tests/unit/test_per_endpoint_circuit_breaker.py +365 -0
  33. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_sdk_surface.py +62 -0
  34. hyperping-1.4.1/src/hyperping/_version.py +0 -1
  35. hyperping-1.4.1/src/hyperping/models/_observability_models.py +0 -46
  36. hyperping-1.4.1/src/hyperping/models/_oncall_models.py +0 -27
  37. hyperping-1.4.1/src/hyperping/models/_reporting_models.py +0 -17
  38. hyperping-1.4.1/tests/unit/test_async_preexisting.py +0 -325
  39. hyperping-1.4.1/tests/unit/test_mcp_client.py +0 -161
  40. hyperping-1.4.1/tests/unit/test_mcp_transport.py +0 -172
  41. {hyperping-1.4.1 → hyperping-1.6.0}/.gitignore +0 -0
  42. {hyperping-1.4.1 → hyperping-1.6.0}/CONTRIBUTING.md +0 -0
  43. {hyperping-1.4.1 → hyperping-1.6.0}/LICENSE +0 -0
  44. {hyperping-1.4.1 → hyperping-1.6.0}/scripts/verify_endpoints.py +0 -0
  45. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  46. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_incidents_mixin.py +0 -0
  47. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
  48. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  49. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_outages_mixin.py +0 -0
  50. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  51. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_circuit_breaker.py +0 -0
  52. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  53. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_incidents_mixin.py +0 -0
  54. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_internals.py +0 -0
  55. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_maintenance_mixin.py +0 -0
  56. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_monitor_constants.py +0 -0
  57. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_monitors_mixin.py +0 -0
  58. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_outages_mixin.py +0 -0
  59. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_protocols.py +0 -0
  60. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_statuspages_mixin.py +0 -0
  61. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/_utils.py +0 -0
  62. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/endpoints.py +0 -0
  63. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/exceptions.py +0 -0
  64. {hyperping-1.4.1 → hyperping-1.6.0}/src/hyperping/py.typed +0 -0
  65. {hyperping-1.4.1 → hyperping-1.6.0}/tests/__init__.py +0 -0
  66. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/__init__.py +0 -0
  67. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/conftest.py +0 -0
  68. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_async_client.py +0 -0
  69. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_healthchecks.py +0 -0
  70. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_incidents.py +0 -0
  71. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_maintenance.py +0 -0
  72. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_monitors.py +0 -0
  73. {hyperping-1.4.1 → hyperping-1.6.0}/tests/unit/test_pagination.py +0 -0
  74. {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.4.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.4.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
- AlertNotification,
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
- "AlertNotification",
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._circuit_breaker.record_success()
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
- if not self._circuit_breaker.call_allowed():
266
- cb = self._circuit_breaker
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
- self._circuit_breaker.record_failure()
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
- self._circuit_breaker.record_failure()
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