hyperping 1.0.1__tar.gz → 1.2.1__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 (52) hide show
  1. {hyperping-1.0.1 → hyperping-1.2.1}/CHANGELOG.md +25 -0
  2. {hyperping-1.0.1 → hyperping-1.2.1}/PKG-INFO +37 -5
  3. {hyperping-1.0.1 → hyperping-1.2.1}/README.md +35 -3
  4. {hyperping-1.0.1 → hyperping-1.2.1}/pyproject.toml +8 -2
  5. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/__init__.py +12 -1
  6. hyperping-1.2.1/src/hyperping/_async_client.py +351 -0
  7. hyperping-1.2.1/src/hyperping/_async_healthchecks_mixin.py +163 -0
  8. hyperping-1.2.1/src/hyperping/_async_incidents_mixin.py +182 -0
  9. hyperping-1.2.1/src/hyperping/_async_maintenance_mixin.py +177 -0
  10. hyperping-1.2.1/src/hyperping/_async_monitors_mixin.py +193 -0
  11. hyperping-1.2.1/src/hyperping/_async_outages_mixin.py +214 -0
  12. hyperping-1.2.1/src/hyperping/_async_statuspages_mixin.py +245 -0
  13. hyperping-1.2.1/src/hyperping/_healthchecks_mixin.py +163 -0
  14. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_incidents_mixin.py +11 -1
  15. hyperping-1.2.1/src/hyperping/_internals.py +34 -0
  16. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_maintenance_mixin.py +5 -6
  17. hyperping-1.2.1/src/hyperping/_monitor_constants.py +31 -0
  18. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_monitors_mixin.py +4 -30
  19. hyperping-1.2.1/src/hyperping/_outages_mixin.py +205 -0
  20. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_protocols.py +23 -1
  21. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_statuspages_mixin.py +58 -18
  22. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_utils.py +91 -0
  23. hyperping-1.2.1/src/hyperping/_version.py +1 -0
  24. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/client.py +9 -30
  25. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/endpoints.py +10 -0
  26. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/__init__.py +11 -1
  27. hyperping-1.2.1/src/hyperping/models/_healthcheck_models.py +76 -0
  28. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_outage_models.py +27 -0
  29. hyperping-1.2.1/tests/unit/test_async_client.py +966 -0
  30. hyperping-1.2.1/tests/unit/test_healthchecks.py +312 -0
  31. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_outages.py +11 -5
  32. hyperping-1.2.1/tests/unit/test_pagination.py +221 -0
  33. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_sdk_surface.py +16 -4
  34. hyperping-1.0.1/src/hyperping/_outages_mixin.py +0 -102
  35. hyperping-1.0.1/src/hyperping/_version.py +0 -1
  36. {hyperping-1.0.1 → hyperping-1.2.1}/.gitignore +0 -0
  37. {hyperping-1.0.1 → hyperping-1.2.1}/CONTRIBUTING.md +0 -0
  38. {hyperping-1.0.1 → hyperping-1.2.1}/LICENSE +0 -0
  39. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_circuit_breaker.py +0 -0
  40. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/exceptions.py +0 -0
  41. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_incident_models.py +0 -0
  42. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_maintenance_models.py +0 -0
  43. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_monitor_models.py +0 -0
  44. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_statuspage_models.py +0 -0
  45. {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/py.typed +0 -0
  46. {hyperping-1.0.1 → hyperping-1.2.1}/tests/__init__.py +0 -0
  47. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/__init__.py +0 -0
  48. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/conftest.py +0 -0
  49. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_incidents.py +0 -0
  50. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_maintenance.py +0 -0
  51. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_monitors.py +0 -0
  52. {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_statuspages.py +0 -0
@@ -5,6 +5,31 @@ 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.1.0] - 2026-04-09
9
+
10
+ ### Added
11
+
12
+ - **`AsyncHyperpingClient`** — full async counterpart to `HyperpingClient`. All resources
13
+ (monitors, incidents, maintenance, outages, status pages, healthchecks) are available via
14
+ `await`. Retry logic uses `asyncio.sleep`; circuit breaker and `RetryConfig` are shared with
15
+ the sync client. Exported from `hyperping` top-level.
16
+ - **`HealthchecksMixin`** — full CRUD for push-based cron/heartbeat monitoring:
17
+ `list_healthchecks`, `get_healthcheck`, `create_healthcheck`, `update_healthcheck`,
18
+ `delete_healthcheck`, `pause_healthcheck`, `resume_healthcheck`. `Healthcheck`,
19
+ `HealthcheckCreate`, `HealthcheckUpdate` models exported from `hyperping`.
20
+ - **Pagination** on `list_outages`, `list_status_pages`, `list_subscribers`. Pass
21
+ `page=None` (default) to auto-fetch all pages via `hasNextPage`; pass an explicit
22
+ `int` to retrieve a single page. `status` and `outage_type` filter params added to
23
+ `list_outages`.
24
+ - **Typed `OutageAction`** return type for `acknowledge_outage`, `resolve_outage`,
25
+ `escalate_outage`, `unacknowledge_outage` (was `dict[str, Any]`).
26
+ - **`_internals.py`** — shared `RETRY_AFTER_MAX`, `DEFAULT_USER_AGENT`, `sanitize_for_log`
27
+ used by both sync and async clients (eliminates private cross-module imports).
28
+ - **`_monitor_constants.py`** — shared `VALID_PERIODS`, `MONITOR_WRITABLE_FIELDS`
29
+ constants used by both sync and async monitor mixins.
30
+ - **`collect_all_pages` / `collect_all_pages_async`** helpers in `_utils.py` for
31
+ transparent multi-page result aggregation.
32
+
8
33
  ## [1.0.0] - 2026-04-05
9
34
 
10
35
  First stable release. The public API is production-ready and covered by semver
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.0.1
3
+ Version: 1.2.1
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
@@ -30,7 +30,7 @@ Requires-Dist: mypy>=1.10; extra == 'dev'
30
30
  Requires-Dist: pip-audit>=2.7; extra == 'dev'
31
31
  Requires-Dist: pydantic; extra == 'dev'
32
32
  Requires-Dist: pytest-cov; extra == 'dev'
33
- Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
34
34
  Requires-Dist: respx>=0.21; extra == 'dev'
35
35
  Requires-Dist: ruff>=0.4; extra == 'dev'
36
36
  Description-Content-Type: text/markdown
@@ -80,6 +80,24 @@ with HyperpingClient(api_key="sk_...") as client:
80
80
  client.resolve_incident(incident.uuid, "All systems operational")
81
81
  ```
82
82
 
83
+ ## Async Client
84
+
85
+ An async-first client is available for use with `asyncio` and `anyio`-based frameworks:
86
+
87
+ ```python
88
+ from hyperping import AsyncHyperpingClient
89
+
90
+ async def main():
91
+ async with AsyncHyperpingClient(api_key="sk_...") as client:
92
+ monitors = await client.list_monitors()
93
+ for m in monitors:
94
+ print(f"{m.name}: {'down' if m.down else 'up'}")
95
+
96
+ outage = await client.acknowledge_outage("out_uuid", message="On it")
97
+ ```
98
+
99
+ The async client supports all the same resources, retry behaviour, and circuit breaker as the sync client. Use `RetryConfig` and `CircuitBreakerConfig` in exactly the same way.
100
+
83
101
  ## Authentication
84
102
 
85
103
  Pass your API key directly or via environment variable:
@@ -140,7 +158,8 @@ in_maint = client.is_monitor_in_maintenance("mon_uuid")
140
158
  ### Outages
141
159
 
142
160
  ```python
143
- outages = client.list_outages()
161
+ outages = client.list_outages() # auto-fetches all pages
162
+ outages = client.list_outages(page=0) # single page
144
163
  client.acknowledge_outage("out_uuid", message="On it")
145
164
  client.resolve_outage("out_uuid", message="Fixed")
146
165
  client.escalate_outage("out_uuid")
@@ -149,18 +168,31 @@ client.escalate_outage("out_uuid")
149
168
  ### Status Pages
150
169
 
151
170
  ```python
152
- pages = client.list_status_pages(search="prod")
171
+ pages = client.list_status_pages(search="prod") # auto-fetches all pages
172
+ pages = client.list_status_pages(page=0) # single page
153
173
  page = client.get_status_page("sp_uuid")
154
174
  created = client.create_status_page(StatusPageCreate(name="Prod", subdomain="prod-status"))
155
175
  client.update_status_page("sp_uuid", StatusPageUpdate(name="Production Status"))
156
176
  client.delete_status_page("sp_uuid")
157
177
 
158
178
  # Subscribers
159
- subs = client.list_subscribers("sp_uuid")
179
+ subs = client.list_subscribers("sp_uuid") # auto-fetches all pages
160
180
  sub = client.add_subscriber("sp_uuid", "user@example.com")
161
181
  client.remove_subscriber("sp_uuid", sub.id)
162
182
  ```
163
183
 
184
+ ### Healthchecks
185
+
186
+ ```python
187
+ checks = client.list_healthchecks()
188
+ check = client.get_healthcheck("hc_uuid")
189
+ created = client.create_healthcheck(HealthcheckCreate(name="Nightly Job", period=86400, grace=3600))
190
+ client.update_healthcheck("hc_uuid", HealthcheckUpdate(grace=7200))
191
+ client.pause_healthcheck("hc_uuid")
192
+ client.resume_healthcheck("hc_uuid")
193
+ client.delete_healthcheck("hc_uuid")
194
+ ```
195
+
164
196
  ## Error Handling
165
197
 
166
198
  ```python
@@ -43,6 +43,24 @@ with HyperpingClient(api_key="sk_...") as client:
43
43
  client.resolve_incident(incident.uuid, "All systems operational")
44
44
  ```
45
45
 
46
+ ## Async Client
47
+
48
+ An async-first client is available for use with `asyncio` and `anyio`-based frameworks:
49
+
50
+ ```python
51
+ from hyperping import AsyncHyperpingClient
52
+
53
+ async def main():
54
+ async with AsyncHyperpingClient(api_key="sk_...") as client:
55
+ monitors = await client.list_monitors()
56
+ for m in monitors:
57
+ print(f"{m.name}: {'down' if m.down else 'up'}")
58
+
59
+ outage = await client.acknowledge_outage("out_uuid", message="On it")
60
+ ```
61
+
62
+ The async client supports all the same resources, retry behaviour, and circuit breaker as the sync client. Use `RetryConfig` and `CircuitBreakerConfig` in exactly the same way.
63
+
46
64
  ## Authentication
47
65
 
48
66
  Pass your API key directly or via environment variable:
@@ -103,7 +121,8 @@ in_maint = client.is_monitor_in_maintenance("mon_uuid")
103
121
  ### Outages
104
122
 
105
123
  ```python
106
- outages = client.list_outages()
124
+ outages = client.list_outages() # auto-fetches all pages
125
+ outages = client.list_outages(page=0) # single page
107
126
  client.acknowledge_outage("out_uuid", message="On it")
108
127
  client.resolve_outage("out_uuid", message="Fixed")
109
128
  client.escalate_outage("out_uuid")
@@ -112,18 +131,31 @@ client.escalate_outage("out_uuid")
112
131
  ### Status Pages
113
132
 
114
133
  ```python
115
- pages = client.list_status_pages(search="prod")
134
+ pages = client.list_status_pages(search="prod") # auto-fetches all pages
135
+ pages = client.list_status_pages(page=0) # single page
116
136
  page = client.get_status_page("sp_uuid")
117
137
  created = client.create_status_page(StatusPageCreate(name="Prod", subdomain="prod-status"))
118
138
  client.update_status_page("sp_uuid", StatusPageUpdate(name="Production Status"))
119
139
  client.delete_status_page("sp_uuid")
120
140
 
121
141
  # Subscribers
122
- subs = client.list_subscribers("sp_uuid")
142
+ subs = client.list_subscribers("sp_uuid") # auto-fetches all pages
123
143
  sub = client.add_subscriber("sp_uuid", "user@example.com")
124
144
  client.remove_subscriber("sp_uuid", sub.id)
125
145
  ```
126
146
 
147
+ ### Healthchecks
148
+
149
+ ```python
150
+ checks = client.list_healthchecks()
151
+ check = client.get_healthcheck("hc_uuid")
152
+ created = client.create_healthcheck(HealthcheckCreate(name="Nightly Job", period=86400, grace=3600))
153
+ client.update_healthcheck("hc_uuid", HealthcheckUpdate(grace=7200))
154
+ client.pause_healthcheck("hc_uuid")
155
+ client.resume_healthcheck("hc_uuid")
156
+ client.delete_healthcheck("hc_uuid")
157
+ ```
158
+
127
159
  ## Error Handling
128
160
 
129
161
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperping"
7
- version = "1.0.1"
7
+ version = "1.2.1"
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"}
@@ -31,7 +31,7 @@ dependencies = [
31
31
 
32
32
  [project.optional-dependencies]
33
33
  dev = [
34
- "pytest>=8.0",
34
+ "pytest>=9.0.3",
35
35
  "pytest-cov",
36
36
  "respx>=0.21",
37
37
  "ruff>=0.4",
@@ -56,6 +56,7 @@ exclude = [".claude/", ".github/", "dist/", "uv.lock", "BACKLOG.md"]
56
56
  [tool.pytest.ini_options]
57
57
  testpaths = ["tests"]
58
58
  addopts = "--cov=hyperping --cov-report=term-missing --cov-fail-under=85"
59
+ asyncio_mode = "auto"
59
60
 
60
61
  [tool.mypy]
61
62
  python_version = "3.11"
@@ -68,3 +69,8 @@ target-version = "py311"
68
69
 
69
70
  [tool.ruff.lint]
70
71
  select = ["E", "F", "I", "N", "W", "UP"]
72
+
73
+ [dependency-groups]
74
+ dev = [
75
+ "pytest-asyncio>=0.23.0",
76
+ ]
@@ -14,6 +14,7 @@ Quick start::
14
14
  print(f"{m.name}: {'down' if m.down else 'up'}")
15
15
  """
16
16
 
17
+ from hyperping._async_client import AsyncHyperpingClient
17
18
  from hyperping._version import __version__
18
19
  from hyperping.client import (
19
20
  CircuitBreaker,
@@ -38,6 +39,9 @@ from hyperping.models import (
38
39
  DEFAULT_REGIONS,
39
40
  AddIncidentUpdateRequest,
40
41
  DnsRecordType,
42
+ Healthcheck,
43
+ HealthcheckCreate,
44
+ HealthcheckUpdate,
41
45
  HttpMethod,
42
46
  Incident,
43
47
  IncidentCreate,
@@ -60,6 +64,7 @@ from hyperping.models import (
60
64
  MonitorUpdate,
61
65
  NotificationOption,
62
66
  Outage,
67
+ OutageAction,
63
68
  OutageDetail,
64
69
  OutageStats,
65
70
  Region,
@@ -74,7 +79,8 @@ from hyperping.models import (
74
79
  __all__ = [
75
80
  # Version
76
81
  "__version__",
77
- # Client
82
+ # Clients
83
+ "AsyncHyperpingClient",
78
84
  "HyperpingClient",
79
85
  # Configuration
80
86
  "RetryConfig",
@@ -130,6 +136,11 @@ __all__ = [
130
136
  "OutageStats",
131
137
  # Outages
132
138
  "Outage",
139
+ "OutageAction",
140
+ # Healthchecks
141
+ "Healthcheck",
142
+ "HealthcheckCreate",
143
+ "HealthcheckUpdate",
133
144
  # Status Pages
134
145
  "StatusPage",
135
146
  "StatusPageCreate",
@@ -0,0 +1,351 @@
1
+ """Async Hyperping API client with retry logic and error handling.
2
+
3
+ This module provides the :class:`AsyncHyperpingClient` class, a fully async
4
+ counterpart to :class:`~hyperping.client.HyperpingClient`.
5
+
6
+ Example::
7
+
8
+ async with AsyncHyperpingClient(api_key="sk_...") as client:
9
+ monitors = await client.list_monitors()
10
+ for m in monitors:
11
+ print(f"{m.name}: {'down' if m.down else 'up'}")
12
+ """
13
+
14
+ import asyncio
15
+ import logging
16
+ import random
17
+ from typing import Any
18
+
19
+ import httpx
20
+ from pydantic import SecretStr
21
+
22
+ from hyperping._async_healthchecks_mixin import AsyncHealthchecksMixin
23
+ from hyperping._async_incidents_mixin import AsyncIncidentsMixin
24
+ from hyperping._async_maintenance_mixin import AsyncMaintenanceMixin
25
+ from hyperping._async_monitors_mixin import AsyncMonitorsMixin
26
+ from hyperping._async_outages_mixin import AsyncOutagesMixin
27
+ from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin
28
+ from hyperping._circuit_breaker import (
29
+ CircuitBreaker,
30
+ CircuitBreakerConfig,
31
+ )
32
+ from hyperping._internals import DEFAULT_USER_AGENT, RETRY_AFTER_MAX, sanitize_for_log
33
+ from hyperping.client import DEFAULT_RETRY_CONFIG, RetryConfig
34
+ from hyperping.endpoints import API_BASE
35
+ from hyperping.exceptions import (
36
+ HyperpingAPIError,
37
+ HyperpingAuthError,
38
+ HyperpingRateLimitError,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class AsyncHyperpingClient(
45
+ AsyncMonitorsMixin,
46
+ AsyncIncidentsMixin,
47
+ AsyncMaintenanceMixin,
48
+ AsyncOutagesMixin,
49
+ AsyncStatusPagesMixin,
50
+ AsyncHealthchecksMixin,
51
+ ):
52
+ """Async client for interacting with the Hyperping API.
53
+
54
+ Handles authentication, retry logic, and error mapping using
55
+ ``httpx.AsyncClient`` for non-blocking I/O.
56
+
57
+ Example::
58
+
59
+ async with AsyncHyperpingClient(api_key="sk_xxx") as client:
60
+ monitors = await client.list_monitors()
61
+ for m in monitors:
62
+ print(f"{m.name}: {'down' if m.down else 'up'}")
63
+ """
64
+
65
+ DEFAULT_BASE_URL = API_BASE
66
+ DEFAULT_TIMEOUT = 30.0
67
+
68
+ def __init__(
69
+ self,
70
+ api_key: str | SecretStr,
71
+ base_url: str | None = None,
72
+ timeout: float = DEFAULT_TIMEOUT,
73
+ retry_config: RetryConfig | None = None,
74
+ circuit_breaker_config: CircuitBreakerConfig | None = None,
75
+ user_agent: str | None = None,
76
+ ) -> None:
77
+ """Initialize the async Hyperping API client.
78
+
79
+ Args:
80
+ api_key: Hyperping API key (starts with ``sk_``). Accepts a plain
81
+ string or a :class:`pydantic.SecretStr`.
82
+ base_url: Override the default API base URL.
83
+ timeout: HTTP request timeout in seconds.
84
+ retry_config: Retry behaviour configuration.
85
+ circuit_breaker_config: Circuit breaker configuration.
86
+ user_agent: Custom ``User-Agent`` header value.
87
+ """
88
+ raw_key = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
89
+ if not raw_key or not raw_key.strip():
90
+ raise ValueError("api_key must be a non-empty string")
91
+ self._api_key = SecretStr(raw_key) if isinstance(api_key, str) else api_key
92
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
93
+ self.timeout = timeout
94
+ self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
95
+ self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
96
+
97
+ self._client = httpx.AsyncClient(
98
+ base_url=self.base_url,
99
+ headers={
100
+ "Authorization": f"Bearer {self._api_key.get_secret_value()}",
101
+ "Content-Type": "application/json",
102
+ "Accept": "application/json",
103
+ "User-Agent": user_agent or DEFAULT_USER_AGENT,
104
+ },
105
+ timeout=self.timeout,
106
+ )
107
+
108
+ def __repr__(self) -> str:
109
+ return f"AsyncHyperpingClient(base_url={self.base_url!r})"
110
+
111
+ async def close(self) -> None:
112
+ """Close the async HTTP client."""
113
+ await self._client.aclose()
114
+
115
+ async def __aenter__(self) -> "AsyncHyperpingClient":
116
+ return self
117
+
118
+ async def __aexit__(self, *args: Any) -> None:
119
+ await self.close()
120
+
121
+ @property
122
+ def circuit_breaker(self) -> CircuitBreaker:
123
+ """Access the circuit breaker state (for monitoring)."""
124
+ return self._circuit_breaker
125
+
126
+ # ==================== Error Handling ====================
127
+
128
+ def _parse_error_body(self, response: httpx.Response) -> dict[str, Any]:
129
+ """Parse the JSON body from an error response."""
130
+ try:
131
+ return response.json() # type: ignore[no-any-return]
132
+ except (ValueError, httpx.DecodingError):
133
+ return {"error": response.text or "Unknown error"}
134
+
135
+ def _parse_retry_after(self, response: httpx.Response) -> int | None:
136
+ """Extract and parse the ``Retry-After`` header value."""
137
+ retry_after = response.headers.get("Retry-After")
138
+ if not retry_after:
139
+ return None
140
+ try:
141
+ return int(retry_after)
142
+ except ValueError:
143
+ return None
144
+
145
+ def _handle_response_error(self, response: httpx.Response) -> None:
146
+ """Map HTTP errors to typed exceptions."""
147
+ from hyperping.exceptions import (
148
+ HyperpingNotFoundError,
149
+ HyperpingValidationError,
150
+ )
151
+
152
+ status = response.status_code
153
+ request_id = response.headers.get("x-request-id")
154
+ body = self._parse_error_body(response)
155
+ error_msg = body.get("error") or body.get("message") or f"HTTP {status}"
156
+
157
+ if status in (401, 403):
158
+ raise HyperpingAuthError(
159
+ message=f"Authentication failed: {error_msg}",
160
+ status_code=status,
161
+ response_body=None,
162
+ request_id=request_id,
163
+ )
164
+ if status == 404:
165
+ raise HyperpingNotFoundError(
166
+ message=f"Resource not found: {error_msg}",
167
+ status_code=status,
168
+ response_body=body,
169
+ request_id=request_id,
170
+ )
171
+ if status == 429:
172
+ raise HyperpingRateLimitError(
173
+ message=f"Rate limit exceeded: {error_msg}",
174
+ status_code=status,
175
+ response_body=body,
176
+ retry_after=self._parse_retry_after(response),
177
+ request_id=request_id,
178
+ )
179
+ if status in (400, 422):
180
+ from hyperping.exceptions import HyperpingValidationError
181
+ raise HyperpingValidationError(
182
+ message=f"Validation error: {error_msg}",
183
+ status_code=status,
184
+ response_body=body,
185
+ validation_errors=body.get("details") or body.get("errors"),
186
+ request_id=request_id,
187
+ )
188
+ raise HyperpingAPIError(
189
+ message=f"API error: {error_msg}",
190
+ status_code=status,
191
+ response_body=body,
192
+ request_id=request_id,
193
+ )
194
+
195
+ # ==================== Request Helpers ====================
196
+
197
+ def _compute_sleep_time(self, response: httpx.Response, delay: float) -> float:
198
+ """Compute how long to sleep before retrying a failed request."""
199
+ if response.status_code == 429:
200
+ retry_after = response.headers.get("Retry-After")
201
+ if retry_after:
202
+ try:
203
+ return min(float(retry_after), RETRY_AFTER_MAX)
204
+ except (ValueError, OverflowError):
205
+ pass
206
+ return delay + random.uniform(0, delay * 0.25)
207
+
208
+ def _should_retry(self, status_code: int, attempt: int) -> bool:
209
+ """Return True if this status/attempt combination warrants a retry."""
210
+ return (
211
+ status_code in self.retry_config.retry_on_status
212
+ and attempt < self.retry_config.max_retries
213
+ )
214
+
215
+ async def _execute_single_attempt(
216
+ self,
217
+ method: str,
218
+ path: str,
219
+ json: dict[str, Any] | None = None,
220
+ params: dict[str, Any] | None = None,
221
+ ) -> dict[str, Any] | list[dict[str, Any]] | httpx.Response:
222
+ """Execute a single async HTTP request attempt."""
223
+ logger.debug(
224
+ "API request: %s %s (attempt)",
225
+ method,
226
+ path,
227
+ extra={
228
+ "json": sanitize_for_log(json),
229
+ "params": sanitize_for_log(params),
230
+ },
231
+ )
232
+
233
+ response = await self._client.request(method=method, url=path, json=json, params=params)
234
+
235
+ if response.status_code >= 400:
236
+ return response
237
+
238
+ self._circuit_breaker.record_success()
239
+ if response.status_code == 204:
240
+ return {}
241
+ return response.json() # type: ignore[no-any-return]
242
+
243
+ async def _request(
244
+ self,
245
+ method: str,
246
+ path: str,
247
+ json: dict[str, Any] | None = None,
248
+ params: dict[str, Any] | None = None,
249
+ ) -> dict[str, Any] | list[dict[str, Any]]:
250
+ """Make an async HTTP request with retry logic.
251
+
252
+ Args:
253
+ method: HTTP method (GET, POST, PUT, DELETE)
254
+ path: API path (e.g., Endpoint.MONITORS)
255
+ json: Request body as dict
256
+ params: Query parameters
257
+
258
+ Returns:
259
+ Response body as dict or list
260
+
261
+ Raises:
262
+ HyperpingAPIError: On API errors after retries exhausted
263
+ """
264
+ if not self._circuit_breaker.call_allowed():
265
+ cb = self._circuit_breaker
266
+ raise HyperpingAPIError(
267
+ f"Circuit breaker OPEN - API calls suspended. "
268
+ f"Consecutive failures: {cb.failure_count}. "
269
+ f"Will recover after {cb.recovery_timeout}s."
270
+ )
271
+
272
+ last_exception: Exception | None = None
273
+ delay = self.retry_config.initial_delay
274
+ max_attempts = self.retry_config.max_retries + 1
275
+
276
+ for attempt in range(max_attempts):
277
+ try:
278
+ result = await self._execute_single_attempt(method, path, json, params)
279
+
280
+ if not isinstance(result, httpx.Response):
281
+ return result
282
+
283
+ response = result
284
+ if self._should_retry(response.status_code, attempt):
285
+ sleep_time = self._compute_sleep_time(response, delay)
286
+ logger.warning(
287
+ "Retrying after %.2fs due to %d (attempt %d/%d)",
288
+ sleep_time,
289
+ response.status_code,
290
+ attempt + 1,
291
+ max_attempts,
292
+ )
293
+ await asyncio.sleep(sleep_time)
294
+ delay = min(
295
+ delay * self.retry_config.backoff_factor,
296
+ self.retry_config.max_delay,
297
+ )
298
+ continue
299
+
300
+ if response.status_code >= 500:
301
+ self._circuit_breaker.record_failure()
302
+ self._handle_response_error(response)
303
+
304
+ except (httpx.TimeoutException, httpx.RequestError) as e:
305
+ last_exception = e
306
+ if attempt < self.retry_config.max_retries:
307
+ label = "timeout" if isinstance(e, httpx.TimeoutException) else str(e)
308
+ sleep_time = delay + random.uniform(0, delay * 0.25)
309
+ logger.warning(
310
+ "Request %s, retrying after %.2fs (attempt %d/%d)",
311
+ label,
312
+ sleep_time,
313
+ attempt + 1,
314
+ max_attempts,
315
+ )
316
+ await asyncio.sleep(sleep_time)
317
+ delay = min(
318
+ delay * self.retry_config.backoff_factor,
319
+ self.retry_config.max_delay,
320
+ )
321
+ continue
322
+ self._circuit_breaker.record_failure()
323
+ if isinstance(e, httpx.TimeoutException):
324
+ raise HyperpingAPIError(
325
+ f"Request timeout after {max_attempts} attempts"
326
+ ) from e
327
+ raise HyperpingAPIError(f"Request failed: {e}") from e
328
+
329
+ raise HyperpingAPIError( # pragma: no cover
330
+ "Request failed after all retries"
331
+ ) from last_exception
332
+
333
+ # ==================== Health Check ====================
334
+
335
+ async def ping(self) -> bool:
336
+ """Test API connectivity and authentication.
337
+
338
+ Returns:
339
+ True if connection successful
340
+
341
+ Raises:
342
+ HyperpingAuthError: If authentication fails
343
+ HyperpingAPIError: If connection fails
344
+ """
345
+ try:
346
+ await self.list_monitors()
347
+ return True
348
+ except HyperpingAuthError:
349
+ raise
350
+ except (HyperpingAPIError, httpx.RequestError, httpx.TimeoutException) as e:
351
+ raise HyperpingAPIError(f"API connectivity test failed: {e}") from e