Skip to content

Commit 921e5a4

Browse files
feat: add AsyncHyperpingClient with full async surface (SDK-PY-03) (#13)
* feat: add AsyncHyperpingClient with full async surface (SDK-PY-03) Introduces AsyncHyperpingClient backed by httpx.AsyncClient with the same retry, circuit-breaker, and error-mapping logic as HyperpingClient, but using await/asyncio.sleep throughout. Adds six async mixin modules, _AsyncClientProtocol base class, pytest-asyncio dependency, and 40 new async unit tests covering all major code paths. * fix: address all PR #12 and PR #13 review issues - Extract shared internals to _internals.py (RETRY_AFTER_MAX, DEFAULT_USER_AGENT, sanitize_for_log) to eliminate private cross-module imports in _async_client.py - Extract monitor constants to _monitor_constants.py (VALID_PERIODS, MONITOR_WRITABLE_FIELDS) to eliminate duplication between sync/async mixins - Add collect_all_pages_async() to _utils.py for async pagination parity - Bring _async_outages_mixin.py to full feature parity: add page/status/outage_type params with validation and auto-pagination via collect_all_pages_async - Bring _async_statuspages_mixin.py to full feature parity: add page/search to list_status_pages and page/subscriber_type to list_subscribers - Fix _statuspages_mixin.py: remove dead params-or-None in explicit-page branches, add HyperpingNotFoundError guard on collect_all_pages for list_status_pages - Fix pyproject.toml: pytest-asyncio version was 1.3.0 (non-existent), corrected to 0.23.0 - Add tests for new async pagination paths; coverage now at 86.88%
1 parent 03113fa commit 921e5a4

20 files changed

Lines changed: 2694 additions & 70 deletions

‎pyproject.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ exclude = [".claude/", ".github/", "dist/", "uv.lock", "BACKLOG.md"]
5656
[tool.pytest.ini_options]
5757
testpaths = ["tests"]
5858
addopts = "--cov=hyperping --cov-report=term-missing --cov-fail-under=85"
59+
asyncio_mode = "auto"
5960

6061
[tool.mypy]
6162
python_version = "3.11"
@@ -68,3 +69,8 @@ target-version = "py311"
6869

6970
[tool.ruff.lint]
7071
select = ["E", "F", "I", "N", "W", "UP"]
72+
73+
[dependency-groups]
74+
dev = [
75+
"pytest-asyncio>=0.23.0",
76+
]

‎src/hyperping/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
print(f"{m.name}: {'down' if m.down else 'up'}")
1515
"""
1616

17+
from hyperping._async_client import AsyncHyperpingClient
1718
from hyperping._version import __version__
1819
from hyperping.client import (
1920
CircuitBreaker,
@@ -78,7 +79,8 @@
7879
__all__ = [
7980
# Version
8081
"__version__",
81-
# Client
82+
# Clients
83+
"AsyncHyperpingClient",
8284
"HyperpingClient",
8385
# Configuration
8486
"RetryConfig",

‎src/hyperping/_async_client.py‎

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)