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.
- {hyperping-1.0.1 → hyperping-1.2.1}/CHANGELOG.md +25 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/PKG-INFO +37 -5
- {hyperping-1.0.1 → hyperping-1.2.1}/README.md +35 -3
- {hyperping-1.0.1 → hyperping-1.2.1}/pyproject.toml +8 -2
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/__init__.py +12 -1
- hyperping-1.2.1/src/hyperping/_async_client.py +351 -0
- hyperping-1.2.1/src/hyperping/_async_healthchecks_mixin.py +163 -0
- hyperping-1.2.1/src/hyperping/_async_incidents_mixin.py +182 -0
- hyperping-1.2.1/src/hyperping/_async_maintenance_mixin.py +177 -0
- hyperping-1.2.1/src/hyperping/_async_monitors_mixin.py +193 -0
- hyperping-1.2.1/src/hyperping/_async_outages_mixin.py +214 -0
- hyperping-1.2.1/src/hyperping/_async_statuspages_mixin.py +245 -0
- hyperping-1.2.1/src/hyperping/_healthchecks_mixin.py +163 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_incidents_mixin.py +11 -1
- hyperping-1.2.1/src/hyperping/_internals.py +34 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_maintenance_mixin.py +5 -6
- hyperping-1.2.1/src/hyperping/_monitor_constants.py +31 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_monitors_mixin.py +4 -30
- hyperping-1.2.1/src/hyperping/_outages_mixin.py +205 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_protocols.py +23 -1
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_statuspages_mixin.py +58 -18
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_utils.py +91 -0
- hyperping-1.2.1/src/hyperping/_version.py +1 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/client.py +9 -30
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/endpoints.py +10 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/__init__.py +11 -1
- hyperping-1.2.1/src/hyperping/models/_healthcheck_models.py +76 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_outage_models.py +27 -0
- hyperping-1.2.1/tests/unit/test_async_client.py +966 -0
- hyperping-1.2.1/tests/unit/test_healthchecks.py +312 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_outages.py +11 -5
- hyperping-1.2.1/tests/unit/test_pagination.py +221 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_sdk_surface.py +16 -4
- hyperping-1.0.1/src/hyperping/_outages_mixin.py +0 -102
- hyperping-1.0.1/src/hyperping/_version.py +0 -1
- {hyperping-1.0.1 → hyperping-1.2.1}/.gitignore +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/CONTRIBUTING.md +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/LICENSE +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/exceptions.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_monitor_models.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/src/hyperping/py.typed +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/__init__.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/__init__.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/conftest.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.0.1 → hyperping-1.2.1}/tests/unit/test_monitors.py +0 -0
- {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.
|
|
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>=
|
|
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.
|
|
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>=
|
|
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
|
-
#
|
|
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
|