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.
Files changed (72) hide show
  1. {hyperping-1.5.0 → hyperping-1.7.0}/.gitignore +1 -0
  2. {hyperping-1.5.0 → hyperping-1.7.0}/CHANGELOG.md +51 -0
  3. {hyperping-1.5.0 → hyperping-1.7.0}/PKG-INFO +96 -1
  4. {hyperping-1.5.0 → hyperping-1.7.0}/README.md +95 -0
  5. hyperping-1.7.0/SECURITY.md +59 -0
  6. {hyperping-1.5.0 → hyperping-1.7.0}/pyproject.toml +2 -2
  7. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_client.py +96 -13
  8. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_mcp_client.py +21 -0
  9. hyperping-1.7.0/src/hyperping/_async_mcp_transport.py +308 -0
  10. hyperping-1.7.0/src/hyperping/_mcp_transport.py +309 -0
  11. hyperping-1.7.0/src/hyperping/_version.py +1 -0
  12. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/client.py +113 -13
  13. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/exceptions.py +16 -3
  14. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/mcp_client.py +23 -0
  15. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_async_mcp_client.py +46 -0
  16. hyperping-1.7.0/tests/unit/test_async_mcp_transport.py +806 -0
  17. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_mcp_client.py +88 -0
  18. hyperping-1.7.0/tests/unit/test_mcp_transport.py +959 -0
  19. hyperping-1.7.0/tests/unit/test_per_endpoint_circuit_breaker.py +365 -0
  20. hyperping-1.5.0/src/hyperping/_async_mcp_transport.py +0 -199
  21. hyperping-1.5.0/src/hyperping/_mcp_transport.py +0 -201
  22. hyperping-1.5.0/src/hyperping/_version.py +0 -1
  23. hyperping-1.5.0/tests/unit/test_async_mcp_transport.py +0 -302
  24. hyperping-1.5.0/tests/unit/test_mcp_transport.py +0 -333
  25. {hyperping-1.5.0 → hyperping-1.7.0}/CONTRIBUTING.md +0 -0
  26. {hyperping-1.5.0 → hyperping-1.7.0}/LICENSE +0 -0
  27. {hyperping-1.5.0 → hyperping-1.7.0}/scripts/verify_endpoints.py +0 -0
  28. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/__init__.py +0 -0
  29. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  30. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_incidents_mixin.py +0 -0
  31. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
  32. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  33. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_outages_mixin.py +0 -0
  34. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  35. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_circuit_breaker.py +0 -0
  36. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  37. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_incidents_mixin.py +0 -0
  38. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_internals.py +0 -0
  39. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_maintenance_mixin.py +0 -0
  40. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_monitor_constants.py +0 -0
  41. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_monitors_mixin.py +0 -0
  42. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_outages_mixin.py +0 -0
  43. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_protocols.py +0 -0
  44. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_statuspages_mixin.py +0 -0
  45. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/_utils.py +0 -0
  46. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/endpoints.py +0 -0
  47. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/__init__.py +0 -0
  48. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  49. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_incident_models.py +0 -0
  50. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_integration_models.py +0 -0
  51. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_maintenance_models.py +0 -0
  52. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_monitor_models.py +0 -0
  53. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_observability_models.py +0 -0
  54. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_oncall_models.py +0 -0
  55. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_outage_models.py +0 -0
  56. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_reporting_models.py +0 -0
  57. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/models/_statuspage_models.py +0 -0
  58. {hyperping-1.5.0 → hyperping-1.7.0}/src/hyperping/py.typed +0 -0
  59. {hyperping-1.5.0 → hyperping-1.7.0}/tests/__init__.py +0 -0
  60. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/__init__.py +0 -0
  61. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/conftest.py +0 -0
  62. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_async_client.py +0 -0
  63. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_async_preexisting.py +0 -0
  64. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_client_coverage.py +0 -0
  65. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_healthchecks.py +0 -0
  66. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_incidents.py +0 -0
  67. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_maintenance.py +0 -0
  68. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_monitors.py +0 -0
  69. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_outages.py +0 -0
  70. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_pagination.py +0 -0
  71. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_sdk_surface.py +0 -0
  72. {hyperping-1.5.0 → hyperping-1.7.0}/tests/unit/test_statuspages.py +0 -0
@@ -36,3 +36,4 @@ htmlcov/
36
36
  # Local dev tooling
37
37
  .claude/
38
38
  dist/
39
+ .worktrees/
@@ -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.5.0
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.5.0"
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._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
@@ -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: