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.
- {hyperping-1.5.0 → hyperping-1.6.0}/CHANGELOG.md +14 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/PKG-INFO +43 -1
- {hyperping-1.5.0 → hyperping-1.6.0}/README.md +42 -0
- hyperping-1.6.0/SECURITY.md +58 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/pyproject.toml +1 -1
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_client.py +96 -13
- hyperping-1.6.0/src/hyperping/_version.py +1 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/client.py +113 -13
- hyperping-1.6.0/tests/unit/test_per_endpoint_circuit_breaker.py +365 -0
- hyperping-1.5.0/src/hyperping/_version.py +0 -1
- {hyperping-1.5.0 → hyperping-1.6.0}/.gitignore +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/LICENSE +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_mcp_client.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_mcp_transport.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_internals.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_maintenance_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_mcp_transport.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/_utils.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/exceptions.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/mcp_client.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_integration_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_oncall_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_mcp_client.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_mcp_transport.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_async_preexisting.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_client_coverage.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_mcp_client.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_mcp_transport.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_outages.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_pagination.py +0 -0
- {hyperping-1.5.0 → hyperping-1.6.0}/tests/unit/test_sdk_surface.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
326
|
+
self._breaker_for(path).record_success()
|
|
240
327
|
if response.status_code == 204:
|
|
241
328
|
return {}
|
|
242
329
|
return response.json() # type: ignore[no-any-return]
|
|
@@ -262,13 +349,9 @@ class AsyncHyperpingClient(
|
|
|
262
349
|
Raises:
|
|
263
350
|
HyperpingAPIError: On API errors after retries exhausted
|
|
264
351
|
"""
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
raise HyperpingAPIError(
|
|
268
|
-
f"Circuit breaker OPEN - API calls suspended. "
|
|
269
|
-
f"Consecutive failures: {cb.failure_count}. "
|
|
270
|
-
f"Will recover after {cb.recovery_timeout}s."
|
|
271
|
-
)
|
|
352
|
+
breaker = self._breaker_for(path)
|
|
353
|
+
if not breaker.call_allowed():
|
|
354
|
+
raise HyperpingAPIError(self._circuit_open_message(breaker, path))
|
|
272
355
|
|
|
273
356
|
last_exception: Exception | None = None
|
|
274
357
|
delay = self.retry_config.initial_delay
|
|
@@ -299,7 +382,7 @@ class AsyncHyperpingClient(
|
|
|
299
382
|
continue
|
|
300
383
|
|
|
301
384
|
if response.status_code >= 500:
|
|
302
|
-
|
|
385
|
+
breaker.record_failure()
|
|
303
386
|
self._handle_response_error(response)
|
|
304
387
|
|
|
305
388
|
except (httpx.TimeoutException, httpx.RequestError) as e:
|
|
@@ -320,7 +403,7 @@ class AsyncHyperpingClient(
|
|
|
320
403
|
self.retry_config.max_delay,
|
|
321
404
|
)
|
|
322
405
|
continue
|
|
323
|
-
|
|
406
|
+
breaker.record_failure()
|
|
324
407
|
if isinstance(e, httpx.TimeoutException):
|
|
325
408
|
raise HyperpingAPIError(f"Request timeout after {max_attempts} attempts") from e
|
|
326
409
|
raise HyperpingAPIError(f"Request failed: {e}") from e
|
|
@@ -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.
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|