hyperping 1.6.0__tar.gz → 1.8.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 (76) hide show
  1. {hyperping-1.6.0 → hyperping-1.8.0}/.gitignore +1 -0
  2. {hyperping-1.6.0 → hyperping-1.8.0}/CHANGELOG.md +79 -0
  3. {hyperping-1.6.0 → hyperping-1.8.0}/PKG-INFO +54 -1
  4. {hyperping-1.6.0 → hyperping-1.8.0}/README.md +53 -0
  5. {hyperping-1.6.0 → hyperping-1.8.0}/SECURITY.md +3 -2
  6. {hyperping-1.6.0 → hyperping-1.8.0}/pyproject.toml +2 -2
  7. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_client.py +45 -8
  8. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_mcp_client.py +23 -0
  9. hyperping-1.8.0/src/hyperping/_async_mcp_transport.py +322 -0
  10. hyperping-1.8.0/src/hyperping/_internals.py +241 -0
  11. hyperping-1.8.0/src/hyperping/_mcp_transport.py +324 -0
  12. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_utils.py +17 -3
  13. hyperping-1.8.0/src/hyperping/_version.py +1 -0
  14. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/client.py +36 -5
  15. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/exceptions.py +34 -8
  16. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/mcp_client.py +25 -0
  17. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_async_mcp_client.py +46 -0
  18. hyperping-1.8.0/tests/unit/test_async_mcp_transport.py +806 -0
  19. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_mcp_client.py +77 -0
  20. hyperping-1.8.0/tests/unit/test_mcp_transport.py +964 -0
  21. hyperping-1.8.0/tests/unit/test_security_base_url.py +163 -0
  22. hyperping-1.8.0/tests/unit/test_security_breaker_cap.py +116 -0
  23. hyperping-1.8.0/tests/unit/test_security_exception_redaction.py +343 -0
  24. hyperping-1.6.0/src/hyperping/_async_mcp_transport.py +0 -199
  25. hyperping-1.6.0/src/hyperping/_internals.py +0 -31
  26. hyperping-1.6.0/src/hyperping/_mcp_transport.py +0 -201
  27. hyperping-1.6.0/src/hyperping/_version.py +0 -1
  28. hyperping-1.6.0/tests/unit/test_async_mcp_transport.py +0 -302
  29. hyperping-1.6.0/tests/unit/test_mcp_transport.py +0 -333
  30. {hyperping-1.6.0 → hyperping-1.8.0}/CONTRIBUTING.md +0 -0
  31. {hyperping-1.6.0 → hyperping-1.8.0}/LICENSE +0 -0
  32. {hyperping-1.6.0 → hyperping-1.8.0}/scripts/verify_endpoints.py +0 -0
  33. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/__init__.py +0 -0
  34. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  35. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_incidents_mixin.py +0 -0
  36. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
  37. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  38. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_outages_mixin.py +0 -0
  39. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  40. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_circuit_breaker.py +0 -0
  41. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  42. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_incidents_mixin.py +0 -0
  43. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_maintenance_mixin.py +0 -0
  44. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_monitor_constants.py +0 -0
  45. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_monitors_mixin.py +0 -0
  46. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_outages_mixin.py +0 -0
  47. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_protocols.py +0 -0
  48. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/_statuspages_mixin.py +0 -0
  49. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/endpoints.py +0 -0
  50. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/__init__.py +0 -0
  51. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  52. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_incident_models.py +0 -0
  53. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_integration_models.py +0 -0
  54. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_maintenance_models.py +0 -0
  55. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_monitor_models.py +0 -0
  56. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_observability_models.py +0 -0
  57. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_oncall_models.py +0 -0
  58. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_outage_models.py +0 -0
  59. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_reporting_models.py +0 -0
  60. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/models/_statuspage_models.py +0 -0
  61. {hyperping-1.6.0 → hyperping-1.8.0}/src/hyperping/py.typed +0 -0
  62. {hyperping-1.6.0 → hyperping-1.8.0}/tests/__init__.py +0 -0
  63. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/__init__.py +0 -0
  64. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/conftest.py +0 -0
  65. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_async_client.py +0 -0
  66. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_async_preexisting.py +0 -0
  67. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_client_coverage.py +0 -0
  68. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_healthchecks.py +0 -0
  69. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_incidents.py +0 -0
  70. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_maintenance.py +0 -0
  71. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_monitors.py +0 -0
  72. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_outages.py +0 -0
  73. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_pagination.py +0 -0
  74. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
  75. {hyperping-1.6.0 → hyperping-1.8.0}/tests/unit/test_sdk_surface.py +0 -0
  76. {hyperping-1.6.0 → hyperping-1.8.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,85 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [1.8.0] - 2026-05-31
11
+
12
+ This is a security-focused release. It closes several credential-leak and validation gaps surfaced by an independent audit. One change is breaking for local-development workflows; see Upgrade Notes below.
13
+
14
+ ### Security
15
+
16
+ - `validate_base_url` is now applied at every constructor that accepts a `base_url` or `mcp_url`: both the sync and async REST clients, both MCP transports, and the high-level MCP clients. The validator rejects non-`https` URLs by default, rejects URLs that carry userinfo (`user:pass@host` or bare `user@host`), and rejects URLs with a query string or fragment. The `Authorization: Bearer` header therefore cannot reach an attacker-controlled host through a misconfigured base URL.
17
+ - `HyperpingAPIError.response_body` is now recursively redacted at construction time. Sensitive keys (authorization, tokens, cookies, set-cookie, request headers, request body, emails, webhooks) are replaced with `[REDACTED]`. Free-form string values are scrubbed for `Bearer <token>` and `sk_<token>` shapes. The recursion is bounded to prevent `RecursionError` from pathological payloads. Applies to all subclasses, including `HyperpingRateLimitError`.
18
+ - `HyperpingAPIError` formatted messages now strip C0 control bytes (preserving `\t` and `\n`) and cap at 256 characters, so a server-supplied error string carrying ANSI escapes or a 1 KB blob cannot poison terminal output or downstream log pipelines.
19
+ - The MCP transport no longer captures the first 500 bytes of server response as `response_body["raw"]` on rate-limit (429), generic HTTP error, or JSON-parse failure paths. Subscriber emails or webhook URLs that the server may echo cannot leak through that channel.
20
+ - `_utils.parse_list` no longer logs the full `pydantic.ValidationError` string on per-item parse failures (Pydantic v2 includes the offending input by default). It now logs only the exception class and field locations.
21
+ - The `breaker_key_fn` callback is now used through an LRU-bounded map. A custom callback that returns unbounded unique strings can no longer leak memory in long-running processes: the per-endpoint breaker map is capped at 1024 entries and evicts oldest on overflow. Applies to both sync and async clients.
22
+
23
+ ### Added
24
+
25
+ - `allow_insecure: bool = False` keyword argument on every constructor that accepts a `base_url` or `mcp_url`. When set to `True`, `http://` URLs are permitted and an `InsecureTransportWarning` is emitted. Provided for local-development workflows; not recommended for production.
26
+ - `InsecureTransportWarning` warning class exported from `hyperping`.
27
+
28
+ ### Changed
29
+
30
+ - `_parse_retry_after` documents that it intentionally supports only the delta-seconds form of the `Retry-After` header. HTTP-date values fall through to exponential backoff rather than raising. Trade-off is documented in the docstring; no behavioral change for callers passing the integer form.
31
+
32
+ ### Upgrade Notes
33
+
34
+ If your code instantiates `HyperpingClient` (or any MCP client) against a `http://localhost` URL for local development or against a mock server, the constructor will now raise `ValueError`. Pass `allow_insecure=True` to opt in, and expect an `InsecureTransportWarning` at runtime:
35
+
36
+ ```python
37
+ from hyperping import HyperpingClient
38
+
39
+ client = HyperpingClient(
40
+ api_key="sk_test_xxx",
41
+ base_url="http://localhost:8000",
42
+ allow_insecure=True,
43
+ )
44
+ ```
45
+
46
+ Production callers using `https://api.hyperping.io` are unaffected.
47
+
48
+ URLs that previously carried embedded credentials (`https://user:pass@api.example.com`) or a trailing query string / fragment will now also raise at construction time. Refactor to pass credentials through `api_key` and to keep query strings out of the base URL.
49
+
50
+ ## [1.7.0] - 2026-05-21
51
+
52
+ ### Added
53
+
54
+ - `ensure_initialized()` on `HyperpingMcpClient` and `AsyncHyperpingMcpClient` for
55
+ startup health checks. Performs the MCP handshake now if it hasn't happened yet
56
+ and raises `HyperpingRateLimitError` if the server's `initialize` cap is hit.
57
+ - New "MCP rate limits and connection lifecycle" section in README documenting
58
+ Hyperping's stateless MCP server, the undocumented `initialize` cap, and the
59
+ recommended client lifetime per process.
60
+
61
+ ### Fixed
62
+
63
+ - MCP rate-limit errors that the server returns as HTTP 200 with JSON-RPC
64
+ `error.code = -32000` (notably the `initialize` per-minute cap) are now
65
+ classified as `HyperpingRateLimitError` with `retry_after` parsed from the
66
+ message, instead of a generic `HyperpingAPIError`. Existing HTTP 429 handling is
67
+ unchanged.
68
+ - After a rate-limit on `initialize`, the MCP transport latches a cool-off so
69
+ subsequent `call_tool` invocations short-circuit with `HyperpingRateLimitError`
70
+ until the advertised `retry_after` elapses, instead of issuing further HTTP
71
+ requests that would burn more slots from the bucket.
72
+ - TOCTOU race in lazy `initialize` where two concurrent first calls on the same
73
+ `HyperpingMcpClient` could each POST `initialize`. The handshake is now
74
+ performed under a dedicated lock with a double-checked flag, including a
75
+ lockless fast path so post-handshake `call_tool` does not contend on it.
76
+ - Cool-off short-circuit now preserves the originating status code (200 for
77
+ JSON-RPC `-32000`, 429 for HTTP 429) so callers can distinguish buckets, and
78
+ `retry_after` uses `math.ceil` to avoid over-reporting by one second.
79
+ - JSON-RPC rate-limit signals returned on the `notifications/initialized` leg
80
+ are now classified as `HyperpingRateLimitError` (previously they were
81
+ silently treated as a successful notification).
82
+ - Rate-limit detection requires the message to contain `"rate limit exceeded"`
83
+ (the observed phrasing) to avoid false positives on unrelated server messages
84
+ that happen to mention `"rate limit"`. The `Retry-After` parser now also
85
+ accepts `Retry-After:` and `retry after N seconds` variants.
86
+
8
87
  ## [1.6.0] - 2026-05-06
9
88
 
10
89
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.6.0
3
+ Version: 1.8.0
4
4
  Summary: Python SDK for the Hyperping uptime monitoring and incident management API
5
5
  Project-URL: Homepage, https://github.com/develeap/hyperping-python
6
6
  Project-URL: Documentation, https://github.com/develeap/hyperping-python#readme
@@ -230,6 +230,59 @@ The MCP client uses the same API key as `HyperpingClient`. All methods return
230
230
  plain dicts/lists; use the exported Pydantic models (e.g., `OnCallSchedule`,
231
231
  `EscalationPolicy`) for validation if needed.
232
232
 
233
+ ### MCP rate limits and connection lifecycle
234
+
235
+ The Hyperping MCP server (`https://api.hyperping.io/v1/mcp`) is
236
+ [documented by Hyperping as stateless over HTTP](https://hyperping.com/mcp)
237
+ and rate-limits per API key. The publicly documented limit is 300 requests per
238
+ minute shared with the REST API
239
+ ([rate-limit docs](https://hyperping.com/docs/monitoring/api-rate-limits)), but
240
+ the server also enforces a separate, undocumented cap on the `initialize`
241
+ handshake (observed around 5/minute). Because every new `HyperpingMcpClient`
242
+ instance must perform the MCP `initialize` handshake on its first call,
243
+ instantiating the client in a hot path or running several short-lived processes
244
+ against one key will trip this cap.
245
+
246
+ Operational guidance:
247
+
248
+ - **Create one `HyperpingMcpClient` per process and reuse it.** Do not instantiate
249
+ it inside a loop. The first call performs the handshake; subsequent calls reuse
250
+ it for the life of the client.
251
+ - **Catch `HyperpingRateLimitError` and honour `retry_after`.** Rate-limit signals
252
+ arrive two ways: as HTTP 429 (with a standard `Retry-After` header) and as a
253
+ JSON-RPC server error (`code: -32000`, HTTP 200) on `initialize`. Both surface as
254
+ `HyperpingRateLimitError` with `retry_after` parsed from whichever signal was
255
+ used. The `status_code` attribute is `429` or `200`, matching the underlying
256
+ signal; cool-off short-circuits preserve the originating status code so callers
257
+ can disambiguate the two buckets.
258
+ - **Use `ensure_initialized()` for startup health checks.** Calling it once on
259
+ service boot lets you fail fast if the key is already at the `initialize` cap,
260
+ instead of failing on the first business call.
261
+ - **Several workloads on one key collide on the `initialize` cap.** A weekly cron,
262
+ a watchdog daemon, and a developer running the CLI cannot all warm up the same
263
+ API key inside one minute. Use one long-lived process per workload, or separate
264
+ API keys per workload if your plan allows.
265
+ - **After a rate-limit on `initialize`, the SDK latches a cool-off** so that
266
+ subsequent `call_tool` invocations on the same client fail fast with
267
+ `HyperpingRateLimitError` (no extra HTTP traffic) until `retry_after` elapses.
268
+ This prevents accidentally burning more slots from the bucket. The latch is
269
+ per-`HyperpingMcpClient` instance and per-process; it does not coordinate
270
+ across separate Python processes sharing the same API key, so multi-process
271
+ setups still need the workload-separation advice above.
272
+
273
+ ```python
274
+ from hyperping import HyperpingMcpClient, HyperpingRateLimitError
275
+
276
+ mcp = HyperpingMcpClient(api_key="sk_...")
277
+ try:
278
+ mcp.ensure_initialized()
279
+ except HyperpingRateLimitError as e:
280
+ print(f"MCP cold-start rate-limited; retry in {e.retry_after}s")
281
+ raise
282
+
283
+ summary = mcp.get_status_summary()
284
+ ```
285
+
233
286
  ### Healthchecks
234
287
 
235
288
  ```python
@@ -193,6 +193,59 @@ The MCP client uses the same API key as `HyperpingClient`. All methods return
193
193
  plain dicts/lists; use the exported Pydantic models (e.g., `OnCallSchedule`,
194
194
  `EscalationPolicy`) for validation if needed.
195
195
 
196
+ ### MCP rate limits and connection lifecycle
197
+
198
+ The Hyperping MCP server (`https://api.hyperping.io/v1/mcp`) is
199
+ [documented by Hyperping as stateless over HTTP](https://hyperping.com/mcp)
200
+ and rate-limits per API key. The publicly documented limit is 300 requests per
201
+ minute shared with the REST API
202
+ ([rate-limit docs](https://hyperping.com/docs/monitoring/api-rate-limits)), but
203
+ the server also enforces a separate, undocumented cap on the `initialize`
204
+ handshake (observed around 5/minute). Because every new `HyperpingMcpClient`
205
+ instance must perform the MCP `initialize` handshake on its first call,
206
+ instantiating the client in a hot path or running several short-lived processes
207
+ against one key will trip this cap.
208
+
209
+ Operational guidance:
210
+
211
+ - **Create one `HyperpingMcpClient` per process and reuse it.** Do not instantiate
212
+ it inside a loop. The first call performs the handshake; subsequent calls reuse
213
+ it for the life of the client.
214
+ - **Catch `HyperpingRateLimitError` and honour `retry_after`.** Rate-limit signals
215
+ arrive two ways: as HTTP 429 (with a standard `Retry-After` header) and as a
216
+ JSON-RPC server error (`code: -32000`, HTTP 200) on `initialize`. Both surface as
217
+ `HyperpingRateLimitError` with `retry_after` parsed from whichever signal was
218
+ used. The `status_code` attribute is `429` or `200`, matching the underlying
219
+ signal; cool-off short-circuits preserve the originating status code so callers
220
+ can disambiguate the two buckets.
221
+ - **Use `ensure_initialized()` for startup health checks.** Calling it once on
222
+ service boot lets you fail fast if the key is already at the `initialize` cap,
223
+ instead of failing on the first business call.
224
+ - **Several workloads on one key collide on the `initialize` cap.** A weekly cron,
225
+ a watchdog daemon, and a developer running the CLI cannot all warm up the same
226
+ API key inside one minute. Use one long-lived process per workload, or separate
227
+ API keys per workload if your plan allows.
228
+ - **After a rate-limit on `initialize`, the SDK latches a cool-off** so that
229
+ subsequent `call_tool` invocations on the same client fail fast with
230
+ `HyperpingRateLimitError` (no extra HTTP traffic) until `retry_after` elapses.
231
+ This prevents accidentally burning more slots from the bucket. The latch is
232
+ per-`HyperpingMcpClient` instance and per-process; it does not coordinate
233
+ across separate Python processes sharing the same API key, so multi-process
234
+ setups still need the workload-separation advice above.
235
+
236
+ ```python
237
+ from hyperping import HyperpingMcpClient, HyperpingRateLimitError
238
+
239
+ mcp = HyperpingMcpClient(api_key="sk_...")
240
+ try:
241
+ mcp.ensure_initialized()
242
+ except HyperpingRateLimitError as e:
243
+ print(f"MCP cold-start rate-limited; retry in {e.retry_after}s")
244
+ raise
245
+
246
+ summary = mcp.get_status_summary()
247
+ ```
248
+
196
249
  ### Healthchecks
197
250
 
198
251
  ```python
@@ -6,8 +6,9 @@ We release patches for security vulnerabilities for the following versions:
6
6
 
7
7
  | Version | Supported |
8
8
  | ------- | ------------------ |
9
- | 1.5.x | :white_check_mark: |
10
- | < 1.5 | :x: |
9
+ | 1.7.x | :white_check_mark: |
10
+ | 1.6.x | :white_check_mark: |
11
+ | < 1.6 | :x: |
11
12
 
12
13
  Older releases may receive a fix at maintainers' discretion when the issue is severe and an upgrade is not feasible. The latest 1.x release is always the recommended target.
13
14
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperping"
7
- version = "1.6.0"
7
+ version = "1.8.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"]
@@ -15,6 +15,7 @@ import asyncio
15
15
  import logging
16
16
  import random
17
17
  import threading
18
+ from collections import OrderedDict
18
19
  from collections.abc import Callable
19
20
  from typing import Any
20
21
  from urllib.parse import urlsplit
@@ -33,8 +34,13 @@ from hyperping._circuit_breaker import (
33
34
  CircuitBreakerConfig,
34
35
  CircuitState,
35
36
  )
36
- from hyperping._internals import DEFAULT_USER_AGENT, RETRY_AFTER_MAX, sanitize_for_log
37
- from hyperping.client import DEFAULT_RETRY_CONFIG, RetryConfig
37
+ from hyperping._internals import (
38
+ DEFAULT_USER_AGENT,
39
+ RETRY_AFTER_MAX,
40
+ sanitize_for_log,
41
+ validate_base_url,
42
+ )
43
+ from hyperping.client import _ENDPOINT_BREAKERS_MAX, DEFAULT_RETRY_CONFIG, RetryConfig
38
44
  from hyperping.endpoints import API_BASE, Endpoint
39
45
  from hyperping.exceptions import (
40
46
  HyperpingAPIError,
@@ -79,6 +85,7 @@ class AsyncHyperpingClient(
79
85
  user_agent: str | None = None,
80
86
  per_endpoint_circuit_breaker: bool = False,
81
87
  breaker_key_fn: Callable[[str], str] | None = None,
88
+ allow_insecure: bool = False,
82
89
  ) -> None:
83
90
  """Initialize the async Hyperping API client.
84
91
 
@@ -106,14 +113,17 @@ class AsyncHyperpingClient(
106
113
  if not raw_key or not raw_key.strip():
107
114
  raise ValueError("api_key must be a non-empty string")
108
115
  self._api_key = SecretStr(raw_key) if isinstance(api_key, str) else api_key
109
- self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
116
+ self.base_url = validate_base_url(
117
+ base_url or self.DEFAULT_BASE_URL,
118
+ allow_insecure=allow_insecure,
119
+ )
110
120
  self.timeout = timeout
111
121
  self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
112
122
  self._circuit_breaker_config = circuit_breaker_config
113
123
  self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
114
124
  self._per_endpoint_circuit_breaker = per_endpoint_circuit_breaker
115
125
  self._breaker_key_fn = breaker_key_fn
116
- self._endpoint_breakers: dict[str, CircuitBreaker] = {}
126
+ self._endpoint_breakers: OrderedDict[str, CircuitBreaker] = OrderedDict()
117
127
  self._endpoint_breakers_lock = threading.Lock()
118
128
 
119
129
  self._client = httpx.AsyncClient(
@@ -167,17 +177,39 @@ class AsyncHyperpingClient(
167
177
  return pure
168
178
 
169
179
  def _breaker_for(self, path: str) -> CircuitBreaker:
170
- """Return the breaker that governs ``path`` (shared, or per-endpoint)."""
180
+ """Return the breaker that governs ``path`` (shared, or per-endpoint).
181
+
182
+ The critical section under ``_endpoint_breakers_lock`` is purely
183
+ CPU-bound (a single ``OrderedDict.get`` / ``__setitem__`` /
184
+ ``move_to_end`` / ``popitem``) and never awaits, so wrapping it in a
185
+ ``threading.Lock`` does not block the event loop in practice; the
186
+ loop only "stalls" for the duration of one dict operation, which is
187
+ well below the resolution of any asyncio scheduling decision.
188
+
189
+ We keep ``threading.Lock`` (rather than ``asyncio.Lock``) so the same
190
+ breaker map remains safe if a caller drives the async client from
191
+ multiple OS threads (e.g. via ``loop.run_in_executor`` or a thread
192
+ pool that re-enters the SDK). Switching to ``asyncio.Lock`` would
193
+ make the per-endpoint path correct only on the loop that owns the
194
+ lock; ``threading.Lock`` is correct in both cases. Regression
195
+ coverage:
196
+ ``tests/unit/test_security_breaker_cap.py
197
+ ::test_async_breaker_lock_does_not_deadlock_under_gather``.
198
+ """
171
199
  if not self._per_endpoint_circuit_breaker:
172
200
  return self._circuit_breaker
173
201
  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
202
  with self._endpoint_breakers_lock:
177
203
  breaker = self._endpoint_breakers.get(key)
178
204
  if breaker is None:
179
205
  breaker = CircuitBreaker(self._circuit_breaker_config)
180
206
  self._endpoint_breakers[key] = breaker
207
+ # Evict LRU once the cap is hit to bound memory under a
208
+ # pathological breaker_key_fn (see HyperpingClient).
209
+ while len(self._endpoint_breakers) > _ENDPOINT_BREAKERS_MAX:
210
+ self._endpoint_breakers.popitem(last=False)
211
+ else:
212
+ self._endpoint_breakers.move_to_end(key)
181
213
  return breaker
182
214
 
183
215
  def circuit_breaker_state_for(self, path: str) -> CircuitState:
@@ -220,7 +252,12 @@ class AsyncHyperpingClient(
220
252
  return {"error": response.text or "Unknown error"}
221
253
 
222
254
  def _parse_retry_after(self, response: httpx.Response) -> int | None:
223
- """Extract and parse the ``Retry-After`` header value."""
255
+ """Extract and parse the ``Retry-After`` header value.
256
+
257
+ Only the delta-seconds form (RFC 7231 7.1.3) is parsed; HTTP-date
258
+ is intentionally not supported (see
259
+ :meth:`HyperpingClient._parse_retry_after` for rationale).
260
+ """
224
261
  retry_after = response.headers.get("Retry-After")
225
262
  if not retry_after:
226
263
  return None
@@ -50,11 +50,13 @@ class AsyncHyperpingMcpClient:
50
50
  api_key: str | SecretStr,
51
51
  base_url: str = MCP_URL,
52
52
  timeout: float = 30.0,
53
+ allow_insecure: bool = False,
53
54
  ) -> None:
54
55
  self._transport = AsyncMcpTransport(
55
56
  api_key=api_key,
56
57
  base_url=base_url,
57
58
  timeout=timeout,
59
+ allow_insecure=allow_insecure,
58
60
  )
59
61
 
60
62
  # ==================== Internal ====================
@@ -63,6 +65,27 @@ class AsyncHyperpingMcpClient:
63
65
  """Call an MCP tool via the transport."""
64
66
  return await self._transport.call_tool(tool, args or {})
65
67
 
68
+ async def ensure_initialized(self) -> None:
69
+ """Perform the MCP handshake now if it hasn't happened yet.
70
+
71
+ Async counterpart to
72
+ :meth:`hyperping.mcp_client.HyperpingMcpClient.ensure_initialized`.
73
+ Idempotent.
74
+
75
+ Raises:
76
+ HyperpingRateLimitError: If the server rate-limits ``initialize``,
77
+ either via HTTP 429 or via the JSON-RPC ``-32000`` rate-limit
78
+ payload. Inspect ``.retry_after`` to back off.
79
+ HyperpingAuthError: If the API key is invalid (HTTP 401/403).
80
+ HyperpingNotFoundError: If the MCP endpoint URL is wrong
81
+ (HTTP 404).
82
+ HyperpingValidationError: If the server rejects the handshake
83
+ payload (HTTP 400/422; unusual on initialize).
84
+ HyperpingAPIError: Any other transport-level error (HTTP 5xx,
85
+ malformed body, etc.).
86
+ """
87
+ await self._transport.initialize()
88
+
66
89
  # ==================== Context Manager ====================
67
90
 
68
91
  async def close(self) -> None: