hyperping 1.5.0__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 (68) hide show
  1. {hyperping-1.5.0 → hyperping-1.6.0}/CHANGELOG.md +14 -0
  2. {hyperping-1.5.0 → hyperping-1.6.0}/PKG-INFO +43 -1
  3. {hyperping-1.5.0 → hyperping-1.6.0}/README.md +42 -0
  4. hyperping-1.6.0/SECURITY.md +58 -0
  5. {hyperping-1.5.0 → hyperping-1.6.0}/pyproject.toml +1 -1
  6. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_client.py +96 -13
  7. hyperping-1.6.0/src/hyperping/_version.py +1 -0
  8. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/client.py +113 -13
  9. hyperping-1.6.0/tests/unit/test_per_endpoint_circuit_breaker.py +365 -0
  10. hyperping-1.5.0/src/hyperping/_version.py +0 -1
  11. {hyperping-1.5.0 → hyperping-1.6.0}/.gitignore +0 -0
  12. {hyperping-1.5.0 → hyperping-1.6.0}/CONTRIBUTING.md +0 -0
  13. {hyperping-1.5.0 → hyperping-1.6.0}/LICENSE +0 -0
  14. {hyperping-1.5.0 → hyperping-1.6.0}/scripts/verify_endpoints.py +0 -0
  15. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/__init__.py +0 -0
  16. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  17. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_incidents_mixin.py +0 -0
  18. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
  19. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_mcp_client.py +0 -0
  20. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_mcp_transport.py +0 -0
  21. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  22. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_outages_mixin.py +0 -0
  23. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  24. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_circuit_breaker.py +0 -0
  25. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  26. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_incidents_mixin.py +0 -0
  27. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_internals.py +0 -0
  28. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_maintenance_mixin.py +0 -0
  29. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_mcp_transport.py +0 -0
  30. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_monitor_constants.py +0 -0
  31. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_monitors_mixin.py +0 -0
  32. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_outages_mixin.py +0 -0
  33. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_protocols.py +0 -0
  34. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_statuspages_mixin.py +0 -0
  35. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_utils.py +0 -0
  36. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/endpoints.py +0 -0
  37. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/exceptions.py +0 -0
  38. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/mcp_client.py +0 -0
  39. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/__init__.py +0 -0
  40. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  41. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_incident_models.py +0 -0
  42. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_integration_models.py +0 -0
  43. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_maintenance_models.py +0 -0
  44. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_monitor_models.py +0 -0
  45. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_observability_models.py +0 -0
  46. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_oncall_models.py +0 -0
  47. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_outage_models.py +0 -0
  48. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_reporting_models.py +0 -0
  49. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_statuspage_models.py +0 -0
  50. {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/py.typed +0 -0
  51. {hyperping-1.5.0 → hyperping-1.6.0}/tests/__init__.py +0 -0
  52. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/__init__.py +0 -0
  53. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/conftest.py +0 -0
  54. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_client.py +0 -0
  55. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_mcp_client.py +0 -0
  56. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_mcp_transport.py +0 -0
  57. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_preexisting.py +0 -0
  58. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_client_coverage.py +0 -0
  59. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_healthchecks.py +0 -0
  60. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_incidents.py +0 -0
  61. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_maintenance.py +0 -0
  62. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_mcp_client.py +0 -0
  63. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_mcp_transport.py +0 -0
  64. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_monitors.py +0 -0
  65. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_outages.py +0 -0
  66. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_pagination.py +0 -0
  67. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_sdk_surface.py +0 -0
  68. {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_statuspages.py +0 -0
@@ -5,6 +5,20 @@ 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
+
8
22
  ## [1.5.0] - 2026-04-20
9
23
 
10
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.5.0
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
@@ -289,6 +289,48 @@ client = HyperpingClient(
289
289
  )
290
290
  ```
291
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
+
292
334
  ## Type Safety
293
335
 
294
336
  This package ships a `py.typed` marker (PEP 561) and is fully typed. Works out of the box with mypy and pyright.
@@ -252,6 +252,48 @@ client = HyperpingClient(
252
252
  )
253
253
  ```
254
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
+
255
297
  ## Type Safety
256
298
 
257
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.5.0"
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"}
@@ -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
@@ -0,0 +1 @@
1
+ __version__ = "1.6.0"
@@ -10,9 +10,12 @@ Circuit-breaker types (``CircuitBreaker``, ``CircuitBreakerConfig``,
10
10
 
11
11
  import logging
12
12
  import random
13
+ import threading
13
14
  import time
15
+ from collections.abc import Callable
14
16
  from dataclasses import dataclass
15
17
  from typing import Any
18
+ from urllib.parse import urlsplit
16
19
 
17
20
  import httpx
18
21
  from pydantic import SecretStr
@@ -30,7 +33,7 @@ from hyperping._maintenance_mixin import MaintenanceMixin
30
33
  from hyperping._monitors_mixin import MonitorsMixin
31
34
  from hyperping._outages_mixin import OutagesMixin
32
35
  from hyperping._statuspages_mixin import StatusPagesMixin
33
- from hyperping.endpoints import API_BASE
36
+ from hyperping.endpoints import API_BASE, Endpoint
34
37
  from hyperping.exceptions import (
35
38
  HyperpingAPIError,
36
39
  HyperpingAuthError,
@@ -88,6 +91,8 @@ class HyperpingClient(
88
91
  retry_config: RetryConfig | None = None,
89
92
  circuit_breaker_config: CircuitBreakerConfig | None = None,
90
93
  user_agent: str | None = None,
94
+ per_endpoint_circuit_breaker: bool = False,
95
+ breaker_key_fn: Callable[[str], str] | None = None,
91
96
  ) -> None:
92
97
  """Initialize the Hyperping API client.
93
98
 
@@ -100,9 +105,28 @@ class HyperpingClient(
100
105
  retry_config: Retry behaviour configuration. Pass ``None`` for
101
106
  defaults (3 retries, exponential backoff).
102
107
  circuit_breaker_config: Circuit breaker configuration. Pass ``None``
103
- for defaults (5-failure threshold, 60 s recovery).
108
+ for defaults (5-failure threshold, 60 s recovery). When
109
+ ``per_endpoint_circuit_breaker`` is ``True`` this same config
110
+ is applied to every per-path breaker.
104
111
  user_agent: Custom ``User-Agent`` header value. Defaults to
105
112
  ``hyperping-python/0.1.0``.
113
+ per_endpoint_circuit_breaker: When ``True``, maintain an independent
114
+ breaker per *endpoint* so a single flaky endpoint does not
115
+ block traffic to healthy ones. By default the breaker key is
116
+ the matching :class:`~hyperping.endpoints.Endpoint` prefix:
117
+ ``/v1/monitors``, ``/v1/monitors/{uuid}`` and
118
+ ``/v1/monitors/{uuid}/anything`` all share one breaker keyed
119
+ on ``/v1/monitors``. This keeps the breaker set bounded
120
+ (one per Endpoint) instead of growing per resource UUID.
121
+ Default ``False`` preserves the original single-shared-breaker
122
+ behaviour.
123
+ breaker_key_fn: Override the default endpoint-prefix bucketing.
124
+ Receives the request path (with query/fragment intact) and
125
+ must return the breaker key. Use this if you want different
126
+ granularity (e.g. one breaker per resource UUID, or a single
127
+ breaker for all monitor sub-paths). Ignored unless
128
+ ``per_endpoint_circuit_breaker`` is ``True``. *Caller is
129
+ responsible for keeping the key set bounded.*
106
130
  """
107
131
  raw_key = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
108
132
  if not raw_key or not raw_key.strip():
@@ -111,7 +135,12 @@ class HyperpingClient(
111
135
  self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
112
136
  self.timeout = timeout
113
137
  self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
138
+ self._circuit_breaker_config = circuit_breaker_config
114
139
  self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
140
+ self._per_endpoint_circuit_breaker = per_endpoint_circuit_breaker
141
+ self._breaker_key_fn = breaker_key_fn
142
+ self._endpoint_breakers: dict[str, CircuitBreaker] = {}
143
+ self._endpoint_breakers_lock = threading.Lock()
115
144
 
116
145
  self._client = httpx.Client(
117
146
  base_url=self.base_url,
@@ -139,9 +168,84 @@ class HyperpingClient(
139
168
 
140
169
  @property
141
170
  def circuit_breaker(self) -> CircuitBreaker:
142
- """Access the circuit breaker state (for monitoring)."""
171
+ """Access the (shared) circuit breaker state (for monitoring).
172
+
173
+ In per-endpoint mode this returns the original shared breaker, kept
174
+ for backward compatibility; the per-path breakers are exposed via
175
+ :meth:`circuit_breaker_state_for`.
176
+ """
143
177
  return self._circuit_breaker
144
178
 
179
+ def _resolve_breaker_key(self, path: str) -> str:
180
+ """Map a request path to its circuit-breaker key.
181
+
182
+ Default bucketing strips query/fragment and collapses the path under
183
+ the longest matching :class:`Endpoint` prefix, so every sub-resource
184
+ under an endpoint shares the parent's breaker. When the caller passes
185
+ a custom ``breaker_key_fn`` it wins outright.
186
+ """
187
+ if self._breaker_key_fn is not None:
188
+ return self._breaker_key_fn(path)
189
+ pure = urlsplit(path).path
190
+ for ep in Endpoint:
191
+ ep_value = ep.value
192
+ if pure == ep_value or pure.startswith(ep_value + "/"):
193
+ return ep_value
194
+ return pure
195
+
196
+ def _breaker_for(self, path: str) -> CircuitBreaker:
197
+ """Return the breaker that governs ``path``.
198
+
199
+ In default mode this is always the shared breaker; in per-endpoint
200
+ mode each canonical key gets its own :class:`CircuitBreaker` lazily.
201
+ """
202
+ if not self._per_endpoint_circuit_breaker:
203
+ return self._circuit_breaker
204
+ key = self._resolve_breaker_key(path)
205
+ # threading.Lock here (not asyncio.Lock) is intentional: it lets the
206
+ # same per-endpoint logic serve both the sync and async clients
207
+ # without forcing an `async` accessor, and it correctly serialises
208
+ # access if the async client is driven from multiple OS threads
209
+ # (e.g. via run_in_executor).
210
+ with self._endpoint_breakers_lock:
211
+ breaker = self._endpoint_breakers.get(key)
212
+ if breaker is None:
213
+ breaker = CircuitBreaker(self._circuit_breaker_config)
214
+ self._endpoint_breakers[key] = breaker
215
+ return breaker
216
+
217
+ def circuit_breaker_state_for(self, path: str) -> CircuitState:
218
+ """Return the circuit state of the breaker governing ``path``.
219
+
220
+ In per-endpoint mode the path is canonicalised the same way as during
221
+ a request (default endpoint-prefix bucketing, or ``breaker_key_fn``
222
+ if set); untouched buckets report :attr:`CircuitState.CLOSED` without
223
+ allocating a breaker. In the default single-breaker mode the shared
224
+ breaker's state is returned for any path, so this method is always
225
+ safe to call regardless of the flag.
226
+ """
227
+ if not self._per_endpoint_circuit_breaker:
228
+ return self._circuit_breaker.state
229
+ key = self._resolve_breaker_key(path)
230
+ with self._endpoint_breakers_lock:
231
+ breaker = self._endpoint_breakers.get(key)
232
+ return breaker.state if breaker is not None else CircuitState.CLOSED
233
+
234
+ def _circuit_open_message(self, breaker: CircuitBreaker, path: str) -> str:
235
+ """Build the error message raised when a request is rejected by an OPEN breaker."""
236
+ if self._per_endpoint_circuit_breaker:
237
+ key = self._resolve_breaker_key(path)
238
+ return (
239
+ f"Circuit breaker OPEN for {key!r} - API calls to this endpoint suspended. "
240
+ f"Consecutive failures: {breaker.failure_count}. "
241
+ f"Will recover after {breaker.recovery_timeout}s."
242
+ )
243
+ return (
244
+ f"Circuit breaker OPEN - API calls suspended. "
245
+ f"Consecutive failures: {breaker.failure_count}. "
246
+ f"Will recover after {breaker.recovery_timeout}s."
247
+ )
248
+
145
249
  # ==================== Error Handling ====================
146
250
 
147
251
  def _parse_error_body(self, response: httpx.Response) -> dict[str, Any]:
@@ -310,7 +414,7 @@ class HyperpingClient(
310
414
  return response
311
415
 
312
416
  # Success
313
- self._circuit_breaker.record_success()
417
+ self._breaker_for(path).record_success()
314
418
  if response.status_code == 204:
315
419
  return {}
316
420
  return response.json() # type: ignore[no-any-return]
@@ -336,13 +440,9 @@ class HyperpingClient(
336
440
  Raises:
337
441
  HyperpingAPIError: On API errors after retries exhausted
338
442
  """
339
- if not self._circuit_breaker.call_allowed():
340
- cb = self._circuit_breaker
341
- raise HyperpingAPIError(
342
- f"Circuit breaker OPEN - API calls suspended. "
343
- f"Consecutive failures: {cb.failure_count}. "
344
- f"Will recover after {cb.recovery_timeout}s."
345
- )
443
+ breaker = self._breaker_for(path)
444
+ if not breaker.call_allowed():
445
+ raise HyperpingAPIError(self._circuit_open_message(breaker, path))
346
446
 
347
447
  last_exception: Exception | None = None
348
448
  delay = self.retry_config.initial_delay
@@ -374,7 +474,7 @@ class HyperpingClient(
374
474
 
375
475
  # Only trip circuit breaker on server errors, not client errors
376
476
  if response.status_code >= 500:
377
- self._circuit_breaker.record_failure()
477
+ breaker.record_failure()
378
478
  self._handle_response_error(response)
379
479
 
380
480
  except (httpx.TimeoutException, httpx.RequestError) as e:
@@ -395,7 +495,7 @@ class HyperpingClient(
395
495
  self.retry_config.max_delay,
396
496
  )
397
497
  continue
398
- self._circuit_breaker.record_failure()
498
+ breaker.record_failure()
399
499
  if isinstance(e, httpx.TimeoutException):
400
500
  raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e
401
501
  raise HyperpingAPIError(f"Request failed: {e}") from e
@@ -0,0 +1,365 @@
1
+ """Tests for the per-endpoint circuit breaker option (PY-03).
2
+
3
+ The default behaviour (single shared breaker) is exercised by the existing
4
+ breaker tests in ``test_sdk_surface.py``, ``test_monitors.py``, and
5
+ ``test_async_client.py``. These tests cover the new opt-in path:
6
+
7
+ HyperpingClient(..., per_endpoint_circuit_breaker=True)
8
+
9
+ with isolation between paths, a per-path state accessor, async parity, and
10
+ thread safety on the per-path breaker dict.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+
17
+ import httpx
18
+ import pytest
19
+ import pytest_asyncio
20
+ import respx
21
+
22
+ from hyperping._async_client import AsyncHyperpingClient
23
+ from hyperping._circuit_breaker import CircuitBreakerConfig, CircuitState
24
+ from hyperping.client import HyperpingClient, RetryConfig
25
+ from hyperping.endpoints import API_BASE, Endpoint
26
+ from hyperping.exceptions import HyperpingAPIError
27
+
28
+
29
+ def _cb_config(threshold: int = 2) -> CircuitBreakerConfig:
30
+ """Tight threshold so tests trip the breaker quickly without long timeouts."""
31
+ return CircuitBreakerConfig(failure_threshold=threshold, recovery_timeout=60.0)
32
+
33
+
34
+ def _monitor_payload(uuid: str) -> dict:
35
+ """Minimal monitor payload that satisfies Monitor.model_validate()."""
36
+ return {
37
+ "monitorUuid": uuid,
38
+ "name": uuid,
39
+ "url": "https://example.com",
40
+ "method": "GET",
41
+ "frequency": 60,
42
+ "timeout": 10,
43
+ "regions": ["london"],
44
+ "headers": {},
45
+ "expectedStatus": 200,
46
+ "down": False,
47
+ "paused": False,
48
+ }
49
+
50
+
51
+ # ==================== sync ====================
52
+
53
+
54
+ class TestPerEndpointCircuitBreakerSync:
55
+ """Per-endpoint isolation on HyperpingClient."""
56
+
57
+ @respx.mock
58
+ def test_per_endpoint_isolation(self) -> None:
59
+ """A failing endpoint trips its own breaker; a healthy endpoint is unaffected."""
60
+ client = HyperpingClient(
61
+ api_key="sk_test",
62
+ retry_config=RetryConfig(max_retries=0),
63
+ circuit_breaker_config=_cb_config(threshold=2),
64
+ per_endpoint_circuit_breaker=True,
65
+ )
66
+
67
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(
68
+ return_value=httpx.Response(500, json={"error": "boom"}),
69
+ )
70
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock(
71
+ return_value=httpx.Response(200, json={"incidents": []}),
72
+ )
73
+
74
+ # Trip /v1/monitors
75
+ for _ in range(2):
76
+ with pytest.raises(HyperpingAPIError):
77
+ client.list_monitors()
78
+
79
+ # /v1/monitors breaker is now OPEN; further calls fail-fast.
80
+ with pytest.raises(HyperpingAPIError, match="Circuit breaker OPEN"):
81
+ client.list_monitors()
82
+
83
+ # /v3/incidents breaker is untouched and the call succeeds.
84
+ assert client.list_incidents() == []
85
+
86
+ assert client.circuit_breaker_state_for(str(Endpoint.MONITORS)) == CircuitState.OPEN
87
+ assert client.circuit_breaker_state_for(str(Endpoint.INCIDENTS)) == CircuitState.CLOSED
88
+
89
+ client.close()
90
+
91
+ @respx.mock
92
+ def test_per_endpoint_state_query_strips_query_string(self) -> None:
93
+ """``circuit_breaker_state_for`` keys on path only, ignoring query/fragment."""
94
+ client = HyperpingClient(
95
+ api_key="sk_test",
96
+ retry_config=RetryConfig(max_retries=0),
97
+ circuit_breaker_config=_cb_config(threshold=1),
98
+ per_endpoint_circuit_breaker=True,
99
+ )
100
+
101
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock(
102
+ return_value=httpx.Response(500, json={"error": "boom"}),
103
+ )
104
+
105
+ with pytest.raises(HyperpingAPIError):
106
+ client.list_incidents(status="investigating")
107
+
108
+ # The request used a path with a query string, but the breaker key strips it.
109
+ assert client.circuit_breaker_state_for(str(Endpoint.INCIDENTS)) == CircuitState.OPEN
110
+ assert (
111
+ client.circuit_breaker_state_for(f"{Endpoint.INCIDENTS}?status=investigating")
112
+ == CircuitState.OPEN
113
+ )
114
+
115
+ client.close()
116
+
117
+ @respx.mock
118
+ def test_default_behaviour_unchanged(self) -> None:
119
+ """With the flag off (default), a 5xx on one path trips the shared breaker for all paths."""
120
+ client = HyperpingClient(
121
+ api_key="sk_test",
122
+ retry_config=RetryConfig(max_retries=0),
123
+ circuit_breaker_config=_cb_config(threshold=1),
124
+ )
125
+
126
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(
127
+ return_value=httpx.Response(500, json={"error": "boom"}),
128
+ )
129
+
130
+ with pytest.raises(HyperpingAPIError):
131
+ client.list_monitors()
132
+
133
+ # Shared breaker is OPEN; even an unrelated path (which has no mock route)
134
+ # is rejected without an HTTP call.
135
+ with pytest.raises(HyperpingAPIError, match="Circuit breaker OPEN"):
136
+ client.list_incidents()
137
+
138
+ assert client.circuit_breaker.state == CircuitState.OPEN
139
+ client.close()
140
+
141
+ def test_state_for_unknown_path_is_closed(self) -> None:
142
+ """Querying a path that has not been touched returns CLOSED (no breaker created)."""
143
+ client = HyperpingClient(
144
+ api_key="sk_test",
145
+ per_endpoint_circuit_breaker=True,
146
+ )
147
+ assert client.circuit_breaker_state_for("/v1/unused") == CircuitState.CLOSED
148
+ client.close()
149
+
150
+ @respx.mock
151
+ def test_state_for_default_mode_returns_shared_state(self) -> None:
152
+ """In default mode, state_for(any_path) reflects the single shared breaker."""
153
+ client = HyperpingClient(
154
+ api_key="sk_test",
155
+ retry_config=RetryConfig(max_retries=0),
156
+ circuit_breaker_config=_cb_config(threshold=1),
157
+ )
158
+ # Untripped: any path reports CLOSED, identical to the shared breaker.
159
+ assert client.circuit_breaker_state_for("/v1/monitors") == CircuitState.CLOSED
160
+ assert client.circuit_breaker_state_for("/anything") == CircuitState.CLOSED
161
+
162
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(
163
+ return_value=httpx.Response(500, json={"error": "boom"}),
164
+ )
165
+ with pytest.raises(HyperpingAPIError):
166
+ client.list_monitors()
167
+
168
+ # Shared breaker is now OPEN; state_for reports OPEN regardless of path.
169
+ assert client.circuit_breaker.state == CircuitState.OPEN
170
+ assert client.circuit_breaker_state_for("/v1/monitors") == CircuitState.OPEN
171
+ assert client.circuit_breaker_state_for("/v3/incidents") == CircuitState.OPEN
172
+ client.close()
173
+
174
+ @respx.mock
175
+ def test_default_canonicalization_buckets_by_endpoint(self) -> None:
176
+ """`/v1/monitors/{uuid}` and `/v1/monitors` share one breaker; other endpoints don't."""
177
+ client = HyperpingClient(
178
+ api_key="sk_test",
179
+ retry_config=RetryConfig(max_retries=0),
180
+ circuit_breaker_config=_cb_config(threshold=2),
181
+ per_endpoint_circuit_breaker=True,
182
+ )
183
+
184
+ # Two different monitor UUIDs both fail with 5xx.
185
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_A").mock(
186
+ return_value=httpx.Response(500, json={"error": "boom"}),
187
+ )
188
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_B").mock(
189
+ return_value=httpx.Response(500, json={"error": "boom"}),
190
+ )
191
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock(
192
+ return_value=httpx.Response(200, json={"incidents": []}),
193
+ )
194
+
195
+ # Two failures on mon_A trip the shared `/v1/monitors` breaker.
196
+ with pytest.raises(HyperpingAPIError):
197
+ client.get_monitor("mon_A")
198
+ with pytest.raises(HyperpingAPIError):
199
+ client.get_monitor("mon_A")
200
+
201
+ # mon_B is now blocked too — it shares the `/v1/monitors` bucket. No HTTP
202
+ # request is made (no mock interaction needed beyond the route).
203
+ with pytest.raises(HyperpingAPIError, match="Circuit breaker OPEN"):
204
+ client.get_monitor("mon_B")
205
+
206
+ # The list endpoint also falls under `/v1/monitors` and is blocked.
207
+ with pytest.raises(HyperpingAPIError, match="Circuit breaker OPEN"):
208
+ client.list_monitors()
209
+
210
+ # A different endpoint (`/v3/incidents`) is unaffected.
211
+ assert client.list_incidents() == []
212
+
213
+ # State queries: any monitor sub-path resolves to the same key.
214
+ assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_A") == CircuitState.OPEN
215
+ assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_B") == CircuitState.OPEN
216
+ assert client.circuit_breaker_state_for(str(Endpoint.MONITORS)) == CircuitState.OPEN
217
+ assert client.circuit_breaker_state_for(str(Endpoint.INCIDENTS)) == CircuitState.CLOSED
218
+
219
+ client.close()
220
+
221
+ @respx.mock
222
+ def test_custom_breaker_key_fn(self) -> None:
223
+ """A caller-supplied key fn overrides the default endpoint bucketing."""
224
+ seen: list[str] = []
225
+
226
+ def per_uuid(path: str) -> str:
227
+ seen.append(path)
228
+ # Force one breaker per literal path (the pre-canonicalisation behaviour).
229
+ return path
230
+
231
+ client = HyperpingClient(
232
+ api_key="sk_test",
233
+ retry_config=RetryConfig(max_retries=0),
234
+ circuit_breaker_config=_cb_config(threshold=2),
235
+ per_endpoint_circuit_breaker=True,
236
+ breaker_key_fn=per_uuid,
237
+ )
238
+
239
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_A").mock(
240
+ return_value=httpx.Response(500, json={"error": "boom"}),
241
+ )
242
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}/mon_B").mock(
243
+ return_value=httpx.Response(200, json=_monitor_payload("mon_B")),
244
+ )
245
+
246
+ with pytest.raises(HyperpingAPIError):
247
+ client.get_monitor("mon_A")
248
+ with pytest.raises(HyperpingAPIError):
249
+ client.get_monitor("mon_A")
250
+
251
+ # With a per-UUID key fn, mon_B has its own breaker and the call goes through.
252
+ result = client.get_monitor("mon_B")
253
+ assert result.uuid == "mon_B"
254
+
255
+ assert seen, "custom key fn was not invoked"
256
+ assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_A") == CircuitState.OPEN
257
+ assert client.circuit_breaker_state_for(f"{Endpoint.MONITORS}/mon_B") == CircuitState.CLOSED
258
+
259
+ client.close()
260
+
261
+ @respx.mock
262
+ def test_open_error_message_includes_endpoint_key(self) -> None:
263
+ """OPEN error message identifies which endpoint was tripped."""
264
+ client = HyperpingClient(
265
+ api_key="sk_test",
266
+ retry_config=RetryConfig(max_retries=0),
267
+ circuit_breaker_config=_cb_config(threshold=1),
268
+ per_endpoint_circuit_breaker=True,
269
+ )
270
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(
271
+ return_value=httpx.Response(500, json={"error": "boom"}),
272
+ )
273
+
274
+ with pytest.raises(HyperpingAPIError):
275
+ client.list_monitors()
276
+
277
+ with pytest.raises(HyperpingAPIError, match=r"Circuit breaker OPEN for '/v1/monitors'"):
278
+ client.list_monitors()
279
+
280
+ client.close()
281
+
282
+ @respx.mock
283
+ def test_per_endpoint_threadsafe(self) -> None:
284
+ """50 concurrent calls across two paths: failing path opens, healthy path stays closed."""
285
+ client = HyperpingClient(
286
+ api_key="sk_test",
287
+ retry_config=RetryConfig(max_retries=0),
288
+ circuit_breaker_config=_cb_config(threshold=3),
289
+ per_endpoint_circuit_breaker=True,
290
+ )
291
+
292
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(
293
+ return_value=httpx.Response(500, json={"error": "boom"}),
294
+ )
295
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock(
296
+ return_value=httpx.Response(200, json={"incidents": []}),
297
+ )
298
+
299
+ def hit_monitors() -> None:
300
+ try:
301
+ client.list_monitors()
302
+ except HyperpingAPIError:
303
+ pass
304
+
305
+ def hit_incidents() -> None:
306
+ client.list_incidents()
307
+
308
+ threads: list[threading.Thread] = []
309
+ for i in range(50):
310
+ target = hit_monitors if i % 2 == 0 else hit_incidents
311
+ threads.append(threading.Thread(target=target))
312
+ for t in threads:
313
+ t.start()
314
+ for t in threads:
315
+ t.join()
316
+
317
+ assert client.circuit_breaker_state_for(str(Endpoint.MONITORS)) == CircuitState.OPEN
318
+ assert client.circuit_breaker_state_for(str(Endpoint.INCIDENTS)) == CircuitState.CLOSED
319
+
320
+ client.close()
321
+
322
+
323
+ # ==================== async ====================
324
+
325
+
326
+ @pytest_asyncio.fixture
327
+ async def per_endpoint_async_client():
328
+ client = AsyncHyperpingClient(
329
+ api_key="sk_test",
330
+ retry_config=RetryConfig(max_retries=0),
331
+ circuit_breaker_config=_cb_config(threshold=2),
332
+ per_endpoint_circuit_breaker=True,
333
+ )
334
+ yield client
335
+ await client.close()
336
+
337
+
338
+ class TestPerEndpointCircuitBreakerAsync:
339
+ """Per-endpoint isolation on AsyncHyperpingClient."""
340
+
341
+ @pytest.mark.asyncio
342
+ @respx.mock
343
+ async def test_per_endpoint_async(
344
+ self, per_endpoint_async_client: AsyncHyperpingClient
345
+ ) -> None:
346
+ client = per_endpoint_async_client
347
+
348
+ respx.get(f"{API_BASE}{Endpoint.MONITORS}").mock(
349
+ return_value=httpx.Response(500, json={"error": "boom"}),
350
+ )
351
+ respx.get(f"{API_BASE}{Endpoint.INCIDENTS}").mock(
352
+ return_value=httpx.Response(200, json={"incidents": []}),
353
+ )
354
+
355
+ for _ in range(2):
356
+ with pytest.raises(HyperpingAPIError):
357
+ await client.list_monitors()
358
+
359
+ with pytest.raises(HyperpingAPIError, match="Circuit breaker OPEN"):
360
+ await client.list_monitors()
361
+
362
+ assert await client.list_incidents() == []
363
+
364
+ assert client.circuit_breaker_state_for(str(Endpoint.MONITORS)) == CircuitState.OPEN
365
+ assert client.circuit_breaker_state_for(str(Endpoint.INCIDENTS)) == CircuitState.CLOSED
@@ -1 +0,0 @@
1
- __version__ = "1.5.0"
File without changes
File without changes
File without changes
File without changes